novastack-utils 1.0.0__tar.gz

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,85 @@
1
+ # Byte-compiled / optimized / DLL files
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+
6
+ # C extensions
7
+ *.so
8
+
9
+ #IDE
10
+ .DS_Store
11
+ .idea
12
+ .vscode
13
+
14
+ # Distribution / packaging
15
+ .Python
16
+ build/
17
+ develop-eggs/
18
+ dist/
19
+ downloads/
20
+ eggs/
21
+ .eggs/
22
+ lib/
23
+ lib64/
24
+ parts/
25
+ sdist/
26
+ var/
27
+ wheels/
28
+ share/python-wheels/
29
+ *.egg-info/
30
+ .installed.cfg
31
+ *.egg
32
+ MANIFEST
33
+
34
+ # PyInstaller
35
+ # Usually these files are written by a python script from a template
36
+ # before PyInstaller builds the exe, so as to inject date/other infos into it.
37
+ *.manifest
38
+ *.spec
39
+
40
+ # Installer logs
41
+ pip-log.txt
42
+ pip-delete-this-directory.txt
43
+
44
+ # Unit test / coverage reports
45
+ htmlcov/
46
+ .tox/
47
+ .nox/
48
+ .coverage
49
+ .coverage.*
50
+ .cache
51
+ nosetests.xml
52
+ coverage.xml
53
+ *.cover
54
+ *.py,cover
55
+ .hypothesis/
56
+ .pytest_cache/
57
+ cover/
58
+
59
+ # Mkdocs documentation
60
+ docs/_build/
61
+ docs/api_reference/site/
62
+
63
+ # Ruff
64
+ .ruff_cache/
65
+
66
+ # PyBuilder
67
+ .pybuilder/
68
+ target/
69
+
70
+ # Jupyter Notebook
71
+ .ipynb_checkpoints
72
+
73
+ # pyenv
74
+ # For a library or package, you might want to ignore these files since the code is
75
+ # intended to run in multiple environments; otherwise, check them in:
76
+ .python-version
77
+
78
+ # Environments
79
+ .env
80
+ .venv
81
+ env/
82
+ venv/
83
+ ENV/
84
+ env.bak/
85
+ venv.bak/
@@ -0,0 +1,26 @@
1
+ Metadata-Version: 2.4
2
+ Name: novastack-utils
3
+ Version: 1.0.0
4
+ Summary: This library contains utilities used across various packages.
5
+ Project-URL: Repository, https://github.com/novastack-project/novastack/tree/main/novastack-utils
6
+ Author-email: Leonardo Furnielis <leonardofurnielis@outlook.com>
7
+ License: Apache-2.0
8
+ Requires-Python: <3.14,>=3.11
9
+ Requires-Dist: httpx<1.0.0,>=0.28.1
10
+ Requires-Dist: pydantic<3.0.0,>=2.12.5
11
+ Provides-Extra: dev
12
+ Requires-Dist: pytest-asyncio<2.0.0,>=1.3.0; extra == 'dev'
13
+ Requires-Dist: pytest-mock<4.0.0,>=3.15.1; extra == 'dev'
14
+ Requires-Dist: pytest<10.0.0,>=9.0.3; extra == 'dev'
15
+ Requires-Dist: ruff<1.0.0,>=0.15.8; extra == 'dev'
16
+ Description-Content-Type: text/markdown
17
+
18
+ # Novastack utils
19
+
20
+ This library contains utils utilities used across various packages.
21
+
22
+ ## Installation
23
+
24
+ ```bash
25
+ pip install novastack-utils
26
+ ```
@@ -0,0 +1,9 @@
1
+ # Novastack utils
2
+
3
+ This library contains utils utilities used across various packages.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ pip install novastack-utils
9
+ ```
@@ -0,0 +1,6 @@
1
+ from novastack_utils.utils import validate_enum, validate_type
2
+
3
+ __all__ = [
4
+ "validate_enum",
5
+ "validate_type",
6
+ ]
@@ -0,0 +1,5 @@
1
+ from novastack_utils.http.base import HttpService
2
+
3
+ __all__ = [
4
+ "HttpService",
5
+ ]
@@ -0,0 +1,21 @@
1
+ from novastack_utils.http.authenticators.basic_authenticator import (
2
+ BasicAuthenticator,
3
+ )
4
+ from novastack_utils.http.authenticators.ibm_iam_authenticator import (
5
+ IBMIAMAuthenticator,
6
+ )
7
+ from novastack_utils.http.authenticators.no_auth_authenticator import (
8
+ NoAuthAuthenticator,
9
+ )
10
+ from novastack_utils.http.authenticators.oauth2_authenticator import (
11
+ OAuth2Authenticator,
12
+ OAuth2GrantType,
13
+ )
14
+
15
+ __all__ = [
16
+ "BasicAuthenticator",
17
+ "IBMIAMAuthenticator",
18
+ "NoAuthAuthenticator",
19
+ "OAuth2Authenticator",
20
+ "OAuth2GrantType",
21
+ ]
@@ -0,0 +1,22 @@
1
+ from abc import ABC, abstractmethod
2
+
3
+ from pydantic import BaseModel
4
+
5
+
6
+ class BaseAuthenticator(BaseModel, ABC):
7
+ """
8
+ Abstract base class for authentication.
9
+
10
+ All authentication strategies must implement the authenticate method
11
+ to provide authentication headers for HTTP requests.
12
+ """
13
+
14
+ model_config = {
15
+ "arbitrary_types_allowed": True,
16
+ "validate_assignment": True,
17
+ "validate_default": True,
18
+ }
19
+
20
+ @abstractmethod
21
+ def authenticate(self) -> dict[str, str]:
22
+ """Authenticate and return headers to be added to requests."""
@@ -0,0 +1,37 @@
1
+ import base64
2
+
3
+ from pydantic import Field, SecretStr
4
+
5
+ from novastack_utils.http.authenticators.base import BaseAuthenticator
6
+ from novastack_utils.http.exceptions import HttpAuthenticationError
7
+
8
+
9
+ class BasicAuthenticator(BaseAuthenticator):
10
+ """
11
+ HTTP Basic Authentication.
12
+
13
+ Implements standard HTTP Basic authentication using Base64 encoding
14
+ of username and password credentials.
15
+ """
16
+
17
+ username: str = Field(..., min_length=1, description="Username for authentication")
18
+ password: SecretStr = Field(..., description="Password for authentication")
19
+
20
+ def authenticate(self) -> dict[str, str]:
21
+ """Generate Basic authentication header."""
22
+ try:
23
+ password_value = self.password.get_secret_value()
24
+
25
+ # Combine username and password
26
+ credentials = f"{self.username}:{password_value}"
27
+
28
+ # Encode to Base64
29
+ encoded = base64.b64encode(credentials.encode("utf-8")).decode("utf-8")
30
+
31
+ # Return Authorization header
32
+ return {"Authorization": f"Basic {encoded}"}
33
+
34
+ except Exception as e:
35
+ raise HttpAuthenticationError(
36
+ f"Failed to generate BasicAuthenticator header: {e}"
37
+ )
@@ -0,0 +1,99 @@
1
+ from datetime import datetime, timedelta
2
+
3
+ import httpx
4
+ from pydantic import Field, PrivateAttr, SecretStr
5
+
6
+ from novastack_utils.http.authenticators.base import BaseAuthenticator
7
+ from novastack_utils.http.exceptions import HttpAuthenticationError
8
+
9
+
10
+ class IBMIAMAuthenticator(BaseAuthenticator):
11
+ """
12
+ IBM Cloud IAM authentication.
13
+
14
+ Uses IBM Cloud API Key to obtain access tokens from IBM IAM service.
15
+ Automatically manages token lifecycle including expiration tracking and refresh.
16
+ """
17
+
18
+ api_key: SecretStr = Field(..., description="IBM Cloud API Key")
19
+ token_url: str = Field(
20
+ default="https://iam.cloud.ibm.com/identity/token",
21
+ description="IBM IAM token endpoint URL",
22
+ )
23
+
24
+ _access_token: str | None = PrivateAttr(default=None)
25
+ _token_type: str = PrivateAttr(default="Bearer")
26
+ _expires_at: datetime | None = PrivateAttr(default=None)
27
+ _refresh_token: str | None = PrivateAttr(default=None)
28
+
29
+ def authenticate(self) -> dict[str, str]:
30
+ """
31
+ Get authentication headers with valid access token.
32
+
33
+ Automatically refreshes token if expired.
34
+ """
35
+ # Check if token needs refresh
36
+ if self.is_expired():
37
+ self._get_access_token()
38
+
39
+ return {"Authorization": f"{self._token_type} {self._access_token}"}
40
+
41
+ def is_expired(self) -> bool:
42
+ """Check if access token is expired."""
43
+ if not self._access_token or not self._expires_at:
44
+ return True
45
+
46
+ # Consider token expired if it expires within 60 seconds
47
+ buffer = timedelta(seconds=60)
48
+ return datetime.now() >= (self._expires_at - buffer)
49
+
50
+ def _get_access_token(self) -> None:
51
+ """Get IBM IAM access token."""
52
+ try:
53
+ data = {
54
+ "grant_type": "urn:ibm:params:oauth:grant-type:apikey",
55
+ "apikey": self.api_key.get_secret_value(),
56
+ }
57
+
58
+ # Make token request
59
+ response = httpx.post(
60
+ self.token_url,
61
+ data=data,
62
+ headers={"Content-Type": "application/x-www-form-urlencoded"},
63
+ )
64
+
65
+ # Raise for any HTTP error status
66
+ response.raise_for_status()
67
+
68
+ token_data = response.json()
69
+
70
+ self._access_token = token_data.get("access_token")
71
+ if not self._access_token:
72
+ raise HttpAuthenticationError(
73
+ "No 'access_token' in IBM IAM token response"
74
+ )
75
+
76
+ self._token_type = token_data.get("token_type", "Bearer")
77
+ self._refresh_token = token_data.get("refresh_token")
78
+
79
+ # Calculate expiration time
80
+ expires_in = token_data.get("expires_in")
81
+ if expires_in:
82
+ self._expires_at = datetime.now() + timedelta(seconds=int(expires_in))
83
+ else:
84
+ # Default to 1 hour if not provided
85
+ self._expires_at = datetime.now() + timedelta(hours=1)
86
+
87
+ except httpx.HTTPStatusError as e:
88
+ error_detail = e.response.text
89
+ raise HttpAuthenticationError(
90
+ f"IBM IAM token request failed with status {e.response.status_code}: {error_detail}"
91
+ )
92
+ except httpx.TimeoutException as e:
93
+ raise HttpAuthenticationError(f"IBM IAM token request timed out: {e}")
94
+ except httpx.ConnectError as e:
95
+ raise HttpAuthenticationError(f"Failed to connect to IBM IAM endpoint: {e}")
96
+ except HttpAuthenticationError:
97
+ raise
98
+ except Exception as e:
99
+ raise HttpAuthenticationError(f"Failed to obtain IBM IAM token: {e}")
@@ -0,0 +1,11 @@
1
+ from novastack_utils.http.authenticators.base import BaseAuthenticator
2
+
3
+
4
+ class NoAuthAuthenticator(BaseAuthenticator):
5
+ """
6
+ This is the default authentication when no authentication is required.
7
+ It returns empty dict, adding no authentication headers to requests.
8
+ """
9
+
10
+ def authenticate(self) -> dict[str, str]:
11
+ return {}
@@ -0,0 +1,200 @@
1
+ from datetime import datetime, timedelta
2
+ from typing import Any
3
+
4
+ import httpx
5
+ from pydantic import Field, PrivateAttr, SecretStr, field_validator
6
+
7
+ from novastack_utils import validate_enum
8
+ from novastack_utils.http.authenticators.base import BaseAuthenticator
9
+ from novastack_utils.http.exceptions import HttpAuthenticationError
10
+
11
+
12
+ class OAuth2GrantType:
13
+ """
14
+ Supported OAuth2 grant type.
15
+
16
+ Attributes:
17
+ CLIENT_CREDENTIALS (str): "client_credentials"
18
+ PASSWORD (str):"password"
19
+ AUTHORIZATION_CODE (str): "authorization_code"
20
+ REFRESH_TOKEN (str): "refresh_token"
21
+ """
22
+
23
+ CLIENT_CREDENTIALS = "client_credentials"
24
+ PASSWORD = "password"
25
+ AUTHORIZATION_CODE = "authorization_code"
26
+ REFRESH_TOKEN = "refresh_token"
27
+
28
+
29
+ class OAuth2Authenticator(BaseAuthenticator):
30
+ """
31
+ OAuth 2.0 authentication.
32
+
33
+ Automatically manages token lifecycle including expiration tracking and refresh.
34
+ """
35
+
36
+ token_url: str = Field(..., description="OAuth2 token endpoint URL")
37
+ client_id: str = Field(..., min_length=1, description="OAuth2 client ID")
38
+ client_secret: SecretStr = Field(..., description="OAuth2 client secret")
39
+ grant_type: str = Field(
40
+ default=OAuth2GrantType.CLIENT_CREDENTIALS, description="OAuth2 grant type"
41
+ )
42
+ username: str | None = Field(
43
+ default=None, description="Username for password grant_type"
44
+ )
45
+ password: SecretStr | None = Field(
46
+ default=None, description="Password for password grant_type"
47
+ )
48
+ scope: str | None = Field(
49
+ default=None, description="Optional OAuth scope to request"
50
+ )
51
+
52
+ _access_token: str | None = PrivateAttr(default=None)
53
+ _token_type: str = PrivateAttr(default="Bearer")
54
+ _expires_at: datetime | None = PrivateAttr(default=None)
55
+ _refresh_token: str | None = PrivateAttr(default=None)
56
+
57
+ @field_validator("grant_type")
58
+ def _validate_grant_type(cls, v):
59
+ validate_enum(el=v, el_name="grant_type", expected_enum=OAuth2GrantType)
60
+ return v
61
+
62
+ def model_post_init(self, __context: Any) -> None: # noqa: PYI063
63
+ if self.grant_type == OAuth2GrantType.PASSWORD:
64
+ if not self.username or not self.password:
65
+ raise HttpAuthenticationError(
66
+ "'username' and 'password' are required for PASSWORD grant_type."
67
+ )
68
+
69
+ def authenticate(self) -> dict[str, str]:
70
+ """
71
+ Get authentication headers with valid access token.
72
+
73
+ Automatically refreshes token if expired.
74
+ """
75
+ # Check if token needs refresh
76
+ if self.is_expired():
77
+ self.refresh_token()
78
+
79
+ # If still no token, get a new one
80
+ if not self._access_token:
81
+ self._get_access_token()
82
+
83
+ return {"Authorization": f"{self._token_type} {self._access_token}"}
84
+
85
+ def refresh_token(self) -> None:
86
+ """
87
+ Refresh OAuth2 token.
88
+
89
+ Attempts to use refresh token if available, otherwise obtains new token.
90
+ """
91
+ # If we have a refresh token, try to use it
92
+ if self._refresh_token:
93
+ try:
94
+ self._get_access_token(use_refresh_token=True)
95
+ return
96
+ except HttpAuthenticationError:
97
+ # If refresh fails, fall through to get new token
98
+ pass
99
+
100
+ self._get_access_token()
101
+
102
+ def is_expired(self) -> bool:
103
+ """Check if access token is expired."""
104
+ if not self._access_token or not self._expires_at:
105
+ return True
106
+
107
+ # Consider token expired if it expires within 60 seconds
108
+ buffer = timedelta(seconds=60)
109
+ return datetime.now() >= (self._expires_at - buffer)
110
+
111
+ def _get_access_token(self, use_refresh_token: bool = False) -> None:
112
+ """
113
+ Get OAuth2 access token from token endpoint.
114
+
115
+ Args:
116
+ use_refresh_token: Whether to use refresh token grant
117
+ """
118
+ try:
119
+ # Build request payload
120
+ data = self._build_token_request_data(use_refresh_token)
121
+
122
+ # Make token request
123
+ response = httpx.post(
124
+ self.token_url,
125
+ data=data,
126
+ headers={"Content-Type": "application/x-www-form-urlencoded"},
127
+ )
128
+
129
+ # Raise for any HTTP error status
130
+ response.raise_for_status()
131
+
132
+ token_data = response.json()
133
+
134
+ self._access_token = token_data.get("access_token")
135
+ if not self._access_token:
136
+ raise HttpAuthenticationError(
137
+ "No 'access_token' in authenticator response"
138
+ )
139
+
140
+ self._token_type = token_data.get("token_type", "Bearer")
141
+ self._refresh_token = token_data.get("refresh_token")
142
+
143
+ # Calculate expiration time
144
+ expires_in = token_data.get("expires_in")
145
+ if expires_in:
146
+ self._expires_at = datetime.now() + timedelta(seconds=int(expires_in))
147
+ else:
148
+ # Default to 1 hour if not provided
149
+ self._expires_at = datetime.now() + timedelta(hours=1)
150
+
151
+ except httpx.HTTPStatusError as e:
152
+ error_detail = e.response.text
153
+ raise HttpAuthenticationError(
154
+ f"Token request failed with status {e.response.status_code}: {error_detail}"
155
+ )
156
+ except httpx.TimeoutException as e:
157
+ raise HttpAuthenticationError(f"Token request timed out: {e}")
158
+ except httpx.ConnectError as e:
159
+ raise HttpAuthenticationError(f"Failed to connect to token endpoint: {e}")
160
+ except HttpAuthenticationError:
161
+ raise
162
+ except Exception as e:
163
+ raise HttpAuthenticationError(f"Failed to get OAuth2 token: {e}")
164
+
165
+ def _build_token_request_data(self, use_refresh_token: bool) -> dict[str, str]:
166
+ """
167
+ Build OAuth2 token request payload.
168
+
169
+ Args:
170
+ use_refresh_token: Whether to use refresh token grant
171
+ """
172
+ base_data = {
173
+ "client_id": self.client_id,
174
+ "client_secret": self.client_secret.get_secret_value(),
175
+ }
176
+
177
+ if use_refresh_token and self._refresh_token:
178
+ data = {
179
+ "grant_type": OAuth2GrantType.REFRESH_TOKEN,
180
+ "refresh_token": self._refresh_token,
181
+ **base_data,
182
+ }
183
+ elif self.grant_type == OAuth2GrantType.PASSWORD:
184
+ data = {
185
+ "grant_type": self.grant_type,
186
+ "username": self.username,
187
+ "password": self.password.get_secret_value() if self.password else None,
188
+ **base_data,
189
+ }
190
+ else:
191
+ data = {
192
+ "grant_type": self.grant_type,
193
+ **base_data,
194
+ }
195
+
196
+ # Add optional scope
197
+ if self.scope:
198
+ data["scope"] = self.scope
199
+
200
+ return data
@@ -0,0 +1,330 @@
1
+ from typing import Any
2
+
3
+ import httpx
4
+ from pydantic import BaseModel, Field, PrivateAttr
5
+
6
+ from novastack_utils.http.authenticators import NoAuthAuthenticator
7
+ from novastack_utils.http.authenticators.base import BaseAuthenticator
8
+ from novastack_utils.http.exceptions import (
9
+ HttpConnectionError,
10
+ HttpRequestError,
11
+ HttpRequestTimeoutError,
12
+ )
13
+ from novastack_utils.http.types import HttpResponse
14
+
15
+
16
+ class HttpService(BaseModel):
17
+ """
18
+ Enterprise-grade HTTP service with authentication and connection pooling.
19
+
20
+ Provides both synchronous and asynchronous HTTP methods with pluggable
21
+ authentication strategies.
22
+ """
23
+
24
+ model_config = {
25
+ "arbitrary_types_allowed": True,
26
+ "validate_assignment": True,
27
+ "validate_default": True,
28
+ }
29
+
30
+ base_url: str = Field(..., description="Base URL for all requests")
31
+ timeout: float = Field(default=30.0, gt=0, description="Request timeout in seconds")
32
+ verify_ssl: bool = Field(
33
+ default=True, description="Whether to verify SSL certificates"
34
+ )
35
+ headers: dict[str, str] = Field(
36
+ default_factory=dict, description="Default headers for all requests"
37
+ )
38
+ authenticator: BaseAuthenticator = Field(
39
+ default_factory=NoAuthAuthenticator, description="Authentication strategy."
40
+ )
41
+
42
+ _client: httpx.Client = PrivateAttr()
43
+ _async_client: httpx.AsyncClient = PrivateAttr()
44
+
45
+ def model_post_init(self, __context: Any) -> None: # noqa: PYI063
46
+ """Initialize HTTP clients after model creation."""
47
+ # Initialize sync client
48
+ self._client = httpx.Client(
49
+ base_url=self.base_url,
50
+ timeout=self.timeout,
51
+ verify=self.verify_ssl,
52
+ headers=self.headers,
53
+ )
54
+
55
+ # Initialize async client
56
+ self._async_client = httpx.AsyncClient(
57
+ base_url=self.base_url,
58
+ timeout=self.timeout,
59
+ verify=self.verify_ssl,
60
+ headers=self.headers,
61
+ )
62
+
63
+ def _prepare_headers(self, headers: dict[str, str] | None = None) -> dict[str, str]:
64
+ """
65
+ Prepare request headers with authentication.
66
+
67
+ Args:
68
+ headers: Additional headers to include
69
+ """
70
+ combined_headers = self.headers.copy()
71
+ if headers:
72
+ combined_headers.update(headers)
73
+
74
+ # Apply authentication if strategy is provided
75
+ if self.authenticator:
76
+ auth_headers = self.authenticator.authenticate()
77
+ combined_headers.update(auth_headers)
78
+
79
+ return combined_headers
80
+
81
+ def _handle_response(self, response: httpx.Response) -> HttpResponse:
82
+ """
83
+ Handle HTTP response and convert to HttpResponse object.
84
+
85
+ Args:
86
+ response: httpx response object
87
+ """
88
+ return HttpResponse(
89
+ status_code=response.status_code,
90
+ headers=dict(response.headers),
91
+ content=response.content,
92
+ url=str(response.url),
93
+ )
94
+
95
+ def get(
96
+ self,
97
+ url: str,
98
+ params: dict[str, Any] | None = None,
99
+ headers: dict[str, str] | None = None,
100
+ ) -> HttpResponse:
101
+ """
102
+ Perform synchronous GET request.
103
+
104
+ Args:
105
+ url: Request url
106
+ params: Query parameters
107
+ headers: Additional headers
108
+ """
109
+ try:
110
+ prepared_headers = self._prepare_headers(headers)
111
+ response = self._client.get(url, params=params, headers=prepared_headers)
112
+ return self._handle_response(response)
113
+
114
+ except httpx.TimeoutException as e:
115
+ raise HttpRequestTimeoutError(str(e))
116
+ except httpx.ConnectError as e:
117
+ raise HttpConnectionError(f"Failed to connect: {e}")
118
+ except Exception as e:
119
+ raise HttpRequestError(f"GET request failed: {e}")
120
+
121
+ def post(
122
+ self,
123
+ url: str,
124
+ data: dict[str, Any] | None = None,
125
+ json: dict[str, Any] | None = None,
126
+ params: dict[str, Any] | None = None,
127
+ headers: dict[str, str] | None = None,
128
+ ) -> HttpResponse:
129
+ """
130
+ Perform synchronous POST request.
131
+
132
+ Args:
133
+ url: Request url (relative to base_url)
134
+ data: Form data
135
+ json: JSON data
136
+ params: Query parameters
137
+ headers: Additional headers
138
+ """
139
+ try:
140
+ prepared_headers = self._prepare_headers(headers)
141
+ response = self._client.post(
142
+ url, data=data, json=json, params=params, headers=prepared_headers
143
+ )
144
+ return self._handle_response(response)
145
+ except httpx.TimeoutException as e:
146
+ raise HttpRequestTimeoutError(str(e))
147
+ except httpx.ConnectError as e:
148
+ raise HttpConnectionError(f"Failed to connect: {e}")
149
+ except Exception as e:
150
+ raise HttpRequestError(f"POST request failed: {e}")
151
+
152
+ def put(
153
+ self,
154
+ url: str,
155
+ data: dict[str, Any] | None = None,
156
+ json: dict[str, Any] | None = None,
157
+ params: dict[str, Any] | None = None,
158
+ headers: dict[str, str] | None = None,
159
+ ) -> HttpResponse:
160
+ """
161
+ Perform synchronous PUT request.
162
+
163
+ Args:
164
+ url: Request url (relative to base_url)
165
+ data: Form data
166
+ json: JSON data
167
+ params: Query parameters
168
+ headers: Additional headers
169
+ """
170
+ try:
171
+ prepared_headers = self._prepare_headers(headers)
172
+ response = self._client.put(
173
+ url, data=data, json=json, params=params, headers=prepared_headers
174
+ )
175
+ return self._handle_response(response)
176
+ except httpx.TimeoutException as e:
177
+ raise HttpRequestTimeoutError(str(e))
178
+ except httpx.ConnectError as e:
179
+ raise HttpConnectionError(f"Failed to connect: {e}")
180
+ except Exception as e:
181
+ raise HttpRequestError(f"PUT request failed: {e}")
182
+
183
+ def delete(
184
+ self,
185
+ url: str,
186
+ params: dict[str, Any] | None = None,
187
+ headers: dict[str, str] | None = None,
188
+ ) -> HttpResponse:
189
+ """
190
+ Perform synchronous DELETE request.
191
+
192
+ Args:
193
+ url: Request url (relative to base_url)
194
+ params: Query parameters
195
+ headers: Additional headers
196
+ """
197
+ try:
198
+ prepared_headers = self._prepare_headers(headers)
199
+ response = self._client.delete(url, params=params, headers=prepared_headers)
200
+ return self._handle_response(response)
201
+ except httpx.TimeoutException as e:
202
+ raise HttpRequestTimeoutError(str(e))
203
+ except httpx.ConnectError as e:
204
+ raise HttpConnectionError(f"Failed to connect: {e}")
205
+ except Exception as e:
206
+ raise HttpRequestError(f"DELETE request failed: {e}")
207
+
208
+ async def aget(
209
+ self,
210
+ url: str,
211
+ params: dict[str, Any] | None = None,
212
+ headers: dict[str, str] | None = None,
213
+ ) -> HttpResponse:
214
+ """
215
+ Perform asynchronous GET request.
216
+
217
+ Args:
218
+ url: Request url (relative to base_url)
219
+ params: Query parameters
220
+ headers: Additional headers
221
+ """
222
+ try:
223
+ prepared_headers = self._prepare_headers(headers)
224
+ response = await self._async_client.get(
225
+ url, params=params, headers=prepared_headers
226
+ )
227
+ return self._handle_response(response)
228
+ except httpx.TimeoutException as e:
229
+ raise HttpRequestTimeoutError(str(e))
230
+ except httpx.ConnectError as e:
231
+ raise HttpConnectionError(f"Failed to connect: {e}")
232
+ except Exception as e:
233
+ raise HttpRequestError(f"GET request failed: {e}")
234
+
235
+ async def apost(
236
+ self,
237
+ url: str,
238
+ data: dict[str, Any] | None = None,
239
+ json: dict[str, Any] | None = None,
240
+ params: dict[str, Any] | None = None,
241
+ headers: dict[str, str] | None = None,
242
+ ) -> HttpResponse:
243
+ """
244
+ Perform asynchronous POST request.
245
+
246
+ Args:
247
+ url: Request url (relative to base_url)
248
+ data: Form data
249
+ json: JSON data
250
+ params: Query parameters
251
+ headers: Additional headers
252
+ """
253
+ try:
254
+ prepared_headers = self._prepare_headers(headers)
255
+ response = await self._async_client.post(
256
+ url, data=data, json=json, params=params, headers=prepared_headers
257
+ )
258
+ return self._handle_response(response)
259
+ except httpx.TimeoutException as e:
260
+ raise HttpRequestTimeoutError(str(e))
261
+ except httpx.ConnectError as e:
262
+ raise HttpConnectionError(f"Failed to connect: {e}")
263
+ except Exception as e:
264
+ raise HttpRequestError(f"POST request failed: {e}")
265
+
266
+ async def aput(
267
+ self,
268
+ url: str,
269
+ data: dict[str, Any] | None = None,
270
+ json: dict[str, Any] | None = None,
271
+ params: dict[str, Any] | None = None,
272
+ headers: dict[str, str] | None = None,
273
+ ) -> HttpResponse:
274
+ """
275
+ Perform asynchronous PUT request.
276
+
277
+ Args:
278
+ url: Request url (relative to base_url)
279
+ data: Form data
280
+ json: JSON data
281
+ params: Query parameters
282
+ headers: Additional headers
283
+ """
284
+ try:
285
+ prepared_headers = self._prepare_headers(headers)
286
+ response = await self._async_client.put(
287
+ url, data=data, json=json, params=params, headers=prepared_headers
288
+ )
289
+ return self._handle_response(response)
290
+ except httpx.TimeoutException as e:
291
+ raise HttpRequestTimeoutError(str(e))
292
+ except httpx.ConnectError as e:
293
+ raise HttpConnectionError(f"Failed to connect: {e}")
294
+ except Exception as e:
295
+ raise HttpRequestError(f"PUT request failed: {e}")
296
+
297
+ async def adelete(
298
+ self,
299
+ url: str,
300
+ params: dict[str, Any] | None = None,
301
+ headers: dict[str, str] | None = None,
302
+ ) -> HttpResponse:
303
+ """
304
+ Perform asynchronous DELETE request.
305
+
306
+ Args:
307
+ url: Request url (relative to base_url)
308
+ params: Query parameters
309
+ headers: Additional headers
310
+ """
311
+ try:
312
+ prepared_headers = self._prepare_headers(headers)
313
+ response = await self._async_client.delete(
314
+ url, params=params, headers=prepared_headers
315
+ )
316
+ return self._handle_response(response)
317
+ except httpx.TimeoutException as e:
318
+ raise HttpRequestTimeoutError(str(e))
319
+ except httpx.ConnectError as e:
320
+ raise HttpConnectionError(f"Failed to connect: {e}")
321
+ except Exception as e:
322
+ raise HttpRequestError(f"DELETE request failed: {e}")
323
+
324
+ def close(self) -> None:
325
+ if self._client:
326
+ self._client.close()
327
+
328
+ async def aclose(self) -> None:
329
+ if self._async_client:
330
+ await self._async_client.aclose()
@@ -0,0 +1,14 @@
1
+ class HttpRequestError(Exception):
2
+ """Generic exception for HTTP service errors."""
3
+
4
+
5
+ class HttpAuthenticationError(Exception):
6
+ """Raised when authentication fails."""
7
+
8
+
9
+ class HttpRequestTimeoutError(HttpRequestError):
10
+ """Raised when a request times out."""
11
+
12
+
13
+ class HttpConnectionError(HttpRequestError):
14
+ """Raised when connection to server fails."""
@@ -0,0 +1,36 @@
1
+ from typing import Any
2
+
3
+ from pydantic import BaseModel, Field
4
+
5
+
6
+ class HttpRetryConfig(BaseModel):
7
+ """Configuration for retry behavior."""
8
+
9
+ max_retries: int = Field(
10
+ default=3, ge=0, description="Maximum number of retry attempts"
11
+ )
12
+ retry_delay: float = Field(
13
+ default=1.0, gt=0, description="Delay between retries in seconds"
14
+ )
15
+
16
+
17
+ class HttpResponse(BaseModel):
18
+ """HTTP Response wrapper."""
19
+
20
+ model_config = {"arbitrary_types_allowed": True}
21
+
22
+ status_code: int = Field(..., description="HTTP status code")
23
+ headers: dict[str, str] = Field(
24
+ default_factory=dict, description="HTTP Response Headers"
25
+ )
26
+ content: bytes = Field(default=b"", description="Raw Response Content")
27
+ url: str = Field(...)
28
+
29
+ def json_dump(self) -> Any:
30
+ """Parse response content as JSON."""
31
+ import json
32
+
33
+ try:
34
+ return json.loads(self.content.decode("utf-8"))
35
+ except (json.JSONDecodeError, UnicodeDecodeError) as e:
36
+ raise ValueError(f"Failed to parse response as JSON: {e}")
@@ -0,0 +1,19 @@
1
+ from novastack_utils.retry.decorator import retry
2
+ from novastack_utils.retry.strategies import (
3
+ retry_if_exception,
4
+ stop_after_attempt,
5
+ stop_after_delay,
6
+ wait_exponential,
7
+ wait_fixed,
8
+ wait_random,
9
+ )
10
+
11
+ __all__ = [
12
+ "retry",
13
+ "retry_if_exception",
14
+ "stop_after_attempt",
15
+ "stop_after_delay",
16
+ "wait_exponential",
17
+ "wait_fixed",
18
+ "wait_random",
19
+ ]
@@ -0,0 +1,108 @@
1
+ import asyncio
2
+ import functools
3
+ import time
4
+ from typing import Any, Callable, TypeVar
5
+
6
+ from novastack_utils.retry.strategies import (
7
+ retry_if_exception,
8
+ stop_after_attempt,
9
+ wait_fixed,
10
+ )
11
+ from novastack_utils.retry.types import (
12
+ RetryCondition,
13
+ RetryState,
14
+ StopCondition,
15
+ WaitStrategy,
16
+ )
17
+
18
+ T = TypeVar("T")
19
+
20
+
21
+ def _build_retry_state(
22
+ retry_number: int,
23
+ elapsed_time: float,
24
+ last_exception: Exception | None = None,
25
+ ) -> RetryState:
26
+ return RetryState(
27
+ retry_number=retry_number - 1,
28
+ elapsed_time=elapsed_time,
29
+ last_exception=last_exception,
30
+ )
31
+
32
+
33
+ def retry(
34
+ stop: StopCondition | None = None,
35
+ when: RetryCondition | None = None,
36
+ wait: WaitStrategy | None = None,
37
+ reraise: bool = True,
38
+ ) -> Callable[[Callable[..., T]], Callable[..., T | None]]:
39
+ stop_condition = stop or stop_after_attempt(3)
40
+ retry_condition = when or retry_if_exception()
41
+ wait_strategy = wait or wait_fixed(1)
42
+
43
+ def decorator(func: Callable[..., T]) -> Callable[..., T | None]:
44
+ if asyncio.iscoroutinefunction(func):
45
+
46
+ @functools.wraps(func)
47
+ async def async_wrapper(*args: Any, **kwargs: Any) -> T | None:
48
+ start_time = time.monotonic()
49
+ retry_number = 0
50
+
51
+ while True:
52
+ retry_number += 1
53
+ try:
54
+ return await func(*args, **kwargs)
55
+ except Exception as exc:
56
+ retry_state = _build_retry_state(
57
+ retry_number=retry_number,
58
+ elapsed_time=time.monotonic() - start_time,
59
+ last_exception=exc,
60
+ )
61
+
62
+ if not retry_condition(exc):
63
+ if reraise:
64
+ raise
65
+ return None
66
+
67
+ if stop_condition(retry_state):
68
+ if reraise:
69
+ raise
70
+ return None
71
+
72
+ sleep_time = wait_strategy(retry_state)
73
+ await asyncio.sleep(sleep_time)
74
+
75
+ return async_wrapper # type: ignore[return-value]
76
+
77
+ @functools.wraps(func)
78
+ def sync_wrapper(*args: Any, **kwargs: Any) -> T | None:
79
+ start_time = time.monotonic()
80
+ retry_number = 0
81
+
82
+ while True:
83
+ retry_number += 1
84
+ try:
85
+ return func(*args, **kwargs)
86
+ except Exception as exc:
87
+ retry_state = _build_retry_state(
88
+ retry_number=retry_number,
89
+ elapsed_time=time.monotonic() - start_time,
90
+ last_exception=exc,
91
+ )
92
+
93
+ if not retry_condition(exc):
94
+ if reraise:
95
+ raise
96
+ return None
97
+
98
+ if stop_condition(retry_state):
99
+ if reraise:
100
+ raise
101
+ return None
102
+
103
+ sleep_time = wait_strategy(retry_state)
104
+ time.sleep(sleep_time)
105
+
106
+ return sync_wrapper # type: ignore[return-value]
107
+
108
+ return decorator
@@ -0,0 +1,115 @@
1
+ import random
2
+
3
+ from novastack_utils.retry.types import RetryState
4
+
5
+
6
+ class _BaseRetryCondition:
7
+ """Base class for retry predicates."""
8
+
9
+ def __call__(self, exception: Exception) -> bool:
10
+ raise NotImplementedError
11
+
12
+
13
+ class _BaseStopConditionBase:
14
+ """Base class for stop conditions."""
15
+
16
+ def __call__(self, retry_state: RetryState) -> bool:
17
+ raise NotImplementedError
18
+
19
+
20
+ class _BaseWaitStrategy:
21
+ """Base class for wait strategies."""
22
+
23
+ def __call__(self, retry_state: RetryState) -> float:
24
+ raise NotImplementedError
25
+
26
+
27
+ class retry_if_exception(_BaseRetryCondition):
28
+ def __call__(self, exception: Exception) -> bool:
29
+ return True
30
+
31
+
32
+ class stop_after_attempt(_BaseStopConditionBase):
33
+ def __init__(self, max_retries: int) -> None:
34
+ if max_retries < 1:
35
+ raise ValueError(
36
+ f"Invalid max_retries '{max_retries}' Input should be: greater than or equal to 1."
37
+ )
38
+ self.max_retries = max_retries
39
+
40
+ def __call__(self, retry_state: RetryState) -> bool:
41
+ return retry_state.retry_number >= self.max_retries
42
+
43
+
44
+ class stop_after_delay(_BaseStopConditionBase):
45
+ def __init__(self, max_delay: float) -> None:
46
+ if max_delay < 0:
47
+ raise ValueError(
48
+ f"Invalid max_delay '{max_delay}' Input should be: greater than or equal to 0."
49
+ )
50
+ self.max_delay = max_delay
51
+
52
+ def __call__(self, retry_state: RetryState) -> bool:
53
+ return retry_state.elapsed_time >= self.max_delay
54
+
55
+
56
+ class wait_fixed(_BaseWaitStrategy):
57
+ def __init__(self, wait: float) -> None:
58
+ if wait < 0:
59
+ raise ValueError(
60
+ f"Invalid wait '{wait}' Input should be: greater than or equal to 0."
61
+ )
62
+ self.wait = wait
63
+
64
+ def __call__(self, retry_state: RetryState) -> float:
65
+ return self.wait
66
+
67
+
68
+ class wait_exponential(_BaseWaitStrategy):
69
+ def __init__(
70
+ self,
71
+ multiplier: float = 1,
72
+ min: float = 0,
73
+ max: float | None = None,
74
+ ) -> None:
75
+ if multiplier < 0:
76
+ raise ValueError(
77
+ f"Invalid multiplier '{multiplier}' Input should be: greater than or equal to 0."
78
+ )
79
+ if min < 0:
80
+ raise ValueError(
81
+ f"Invalid min '{min}' Input should be: greater than or equal to 0."
82
+ )
83
+ if max is not None and max < min:
84
+ raise ValueError(
85
+ f"Invalid max '{max}' Input should be: greater than or equal to min."
86
+ )
87
+
88
+ self.multiplier = multiplier
89
+ self.min = min
90
+ self.max = max
91
+
92
+ def __call__(self, retry_state: RetryState) -> float:
93
+ delay = self.multiplier * (2 ** (retry_state.retry_number - 1))
94
+ delay = delay if delay > self.min else self.min
95
+ if self.max is not None and delay > self.max:
96
+ delay = self.max
97
+ return delay
98
+
99
+
100
+ class wait_random(_BaseWaitStrategy):
101
+ def __init__(self, min: float, max: float) -> None:
102
+ if min < 0:
103
+ raise ValueError(
104
+ f"Invalid min '{min}' Input should be: greater than or equal to 0."
105
+ )
106
+ if max < min:
107
+ raise ValueError(
108
+ f"Invalid max '{max}' Input should be: greater than or equal to min."
109
+ )
110
+
111
+ self.min = min
112
+ self.max = max
113
+
114
+ def __call__(self, retry_state: RetryState) -> float:
115
+ return random.uniform(self.min, self.max)
@@ -0,0 +1,27 @@
1
+ from dataclasses import dataclass
2
+ from typing import Protocol
3
+
4
+
5
+ @dataclass(slots=True)
6
+ class RetryState:
7
+ retry_number: int
8
+ elapsed_time: float
9
+ last_exception: Exception | None = None
10
+
11
+
12
+ class RetryCondition(Protocol):
13
+ """Protocol for retry predicates that decide whether an exception is retryable."""
14
+
15
+ def __call__(self, exception: Exception) -> bool: ...
16
+
17
+
18
+ class StopCondition(Protocol):
19
+ """Protocol for stop conditions that decide when to stop retrying."""
20
+
21
+ def __call__(self, retry_state: RetryState) -> bool: ...
22
+
23
+
24
+ class WaitStrategy(Protocol):
25
+ """Protocol for wait strategies that determine how long to wait between retries."""
26
+
27
+ def __call__(self, retry_state: RetryState) -> float: ...
@@ -0,0 +1,39 @@
1
+ def validate_enum(el: str, el_name: str, expected_enum) -> str:
2
+ allowed = {
3
+ getattr(expected_enum, attr) for attr in vars(expected_enum) if attr.isupper()
4
+ }
5
+
6
+ if el not in allowed:
7
+ raise ValueError(
8
+ "Invalid {} '{}'. Input should be: {}.".format(el_name, el, sorted(allowed))
9
+ )
10
+
11
+ return el
12
+
13
+
14
+ def validate_type(el, el_name: str, expected_type: type | list[type]):
15
+ if type(expected_type) is not type and type(expected_type) is not list:
16
+ raise TypeError(
17
+ "Invalid expected_type type '{}'. Input should be: ['type', 'list[type]'].".format(
18
+ expected_type
19
+ )
20
+ )
21
+
22
+ if type(expected_type) is list:
23
+ if not any(isinstance(el, t) for t in expected_type):
24
+ allowed = ", ".join(t.__name__ for t in expected_type)
25
+ raise TypeError(
26
+ "Invalid {} type '{}'. Input should be: [{}].".format(
27
+ el_name, type(el).__name__, allowed
28
+ )
29
+ )
30
+ return el
31
+ else:
32
+ if not isinstance(el, expected_type): # type: ignore
33
+ raise TypeError(
34
+ "Invalid {} type '{}'. Input should be: '{}'.".format(
35
+ el_name, type(el).__name__, expected_type.__name__
36
+ )
37
+ )
38
+
39
+ return el
@@ -0,0 +1,33 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "novastack-utils"
7
+ version = "1.0.0"
8
+ description = "This library contains utilities used across various packages."
9
+ authors = [{ name = "Leonardo Furnielis", email = "leonardofurnielis@outlook.com" }]
10
+ license = { text = "Apache-2.0" }
11
+ readme = "README.md"
12
+ requires-python = ">=3.11,<3.14"
13
+ dependencies = [
14
+ "httpx>=0.28.1,<1.0.0",
15
+ "pydantic>=2.12.5,<3.0.0",
16
+ ]
17
+
18
+ [project.optional-dependencies]
19
+ dev = [
20
+ "pytest>=9.0.3,<10.0.0",
21
+ "pytest-asyncio>=1.3.0,<2.0.0",
22
+ "pytest-mock>=3.15.1,<4.0.0",
23
+ "ruff>=0.15.8,<1.0.0",
24
+ ]
25
+
26
+ [project.urls]
27
+ Repository = "https://github.com/novastack-project/novastack/tree/main/novastack-utils"
28
+
29
+ [tool.hatch.build.targets.sdist]
30
+ include = ["novastack_utils/"]
31
+
32
+ [tool.hatch.build.targets.wheel]
33
+ include = ["novastack_utils/"]