pyrestkit 0.0.0__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.
- pyrestkit/__init__.py +35 -0
- pyrestkit/ai/__init__.py +17 -0
- pyrestkit/ai/analyzer.py +137 -0
- pyrestkit/ai/client.py +101 -0
- pyrestkit/ai/config/__init__.py +5 -0
- pyrestkit/ai/config/ai_config.py +200 -0
- pyrestkit/ai/exceptions.py +22 -0
- pyrestkit/ai/generators/__init__.py +0 -0
- pyrestkit/ai/models.py +44 -0
- pyrestkit/ai/parsers/__init__.py +0 -0
- pyrestkit/ai/prompts/failure_analysis.md +21 -0
- pyrestkit/ai/provider.py +58 -0
- pyrestkit/ai/providers/__init__.py +21 -0
- pyrestkit/ai/providers/anthropic.py +85 -0
- pyrestkit/ai/providers/azure_openai.py +84 -0
- pyrestkit/ai/providers/base.py +39 -0
- pyrestkit/ai/providers/bedrock.py +70 -0
- pyrestkit/ai/providers/cohere.py +82 -0
- pyrestkit/ai/providers/gemini.py +113 -0
- pyrestkit/ai/providers/groq.py +81 -0
- pyrestkit/ai/providers/mistral.py +88 -0
- pyrestkit/ai/providers/ollama.py +82 -0
- pyrestkit/ai/providers/openai.py +124 -0
- pyrestkit/ai/utils/__init__.py +0 -0
- pyrestkit/ai/utils/prompt_loader.py +52 -0
- pyrestkit/assertions/__init__.py +7 -0
- pyrestkit/assertions/assertion_exception.py +4 -0
- pyrestkit/assertions/response_assertions.py +181 -0
- pyrestkit/auth/__init__.py +11 -0
- pyrestkit/auth/auth_strategy.py +14 -0
- pyrestkit/auth/authentication_manager.py +35 -0
- pyrestkit/auth/strategies/__init__.py +5 -0
- pyrestkit/auth/strategies/api_key_auth.py +18 -0
- pyrestkit/auth/strategies/basic_auth.py +24 -0
- pyrestkit/auth/strategies/bearer_auth.py +17 -0
- pyrestkit/auth/token_cache.py +44 -0
- pyrestkit/auth/token_manager.py +32 -0
- pyrestkit/auth/token_provider.py +12 -0
- pyrestkit/auth/token_response.py +13 -0
- pyrestkit/builder/__init__.py +5 -0
- pyrestkit/builder/fluent_request_builder.py +167 -0
- pyrestkit/clients/__init__.py +7 -0
- pyrestkit/clients/base_client.py +68 -0
- pyrestkit/clients/user_client.py +66 -0
- pyrestkit/config/__init__.py +5 -0
- pyrestkit/config/config.py +97 -0
- pyrestkit/constants/__init__.py +0 -0
- pyrestkit/constants/content_types.py +0 -0
- pyrestkit/constants/headers.py +0 -0
- pyrestkit/constants/status_codes.py +0 -0
- pyrestkit/core/__init__.py +13 -0
- pyrestkit/core/api_client.py +129 -0
- pyrestkit/core/logger.py +41 -0
- pyrestkit/core/request_builder.py +45 -0
- pyrestkit/core/request_executor.py +64 -0
- pyrestkit/core/request_logger.py +0 -0
- pyrestkit/core/response_logger.py +0 -0
- pyrestkit/core/session_manager.py +19 -0
- pyrestkit/database/__init__.py +0 -0
- pyrestkit/endpoints/__init__.py +5 -0
- pyrestkit/endpoints/base_endpoints.py +32 -0
- pyrestkit/endpoints/order_endpoints.py +9 -0
- pyrestkit/endpoints/payment_endpoints.py +5 -0
- pyrestkit/endpoints/user_endpoints.py +48 -0
- pyrestkit/exceptions/__init__.py +21 -0
- pyrestkit/exceptions/api_exception.py +8 -0
- pyrestkit/exceptions/authentication_exception.py +10 -0
- pyrestkit/exceptions/configuration_exception.py +10 -0
- pyrestkit/exceptions/exception_mapper.py +32 -0
- pyrestkit/exceptions/network_exception.py +10 -0
- pyrestkit/exceptions/response_exception.py +10 -0
- pyrestkit/exceptions/serialization_exception.py +10 -0
- pyrestkit/exceptions/validation_exception.py +10 -0
- pyrestkit/factories/__init__.py +5 -0
- pyrestkit/factories/base_factory.py +25 -0
- pyrestkit/factories/user_factory.py +37 -0
- pyrestkit/hooks/__init__.py +5 -0
- pyrestkit/hooks/hook.py +27 -0
- pyrestkit/hooks/hook_manager.py +39 -0
- pyrestkit/hooks/request_hook.py +18 -0
- pyrestkit/hooks/response_hook.py +17 -0
- pyrestkit/hooks/timing_hook.py +32 -0
- pyrestkit/models/__init__.py +8 -0
- pyrestkit/models/base_response.py +11 -0
- pyrestkit/models/request/__init__.py +7 -0
- pyrestkit/models/request/create_user_request.py +11 -0
- pyrestkit/models/request/update_user_request.py +10 -0
- pyrestkit/models/response/__init__.py +5 -0
- pyrestkit/models/response/create_user_response.py +26 -0
- pyrestkit/models/response/get_user_response.py +28 -0
- pyrestkit/models/response/user_response.py +12 -0
- pyrestkit/pipeline/__init__.py +5 -0
- pyrestkit/pipeline/middleware.py +18 -0
- pyrestkit/pipeline/middleware_chain.py +11 -0
- pyrestkit/pipeline/pipeline.py +27 -0
- pyrestkit/pipeline/request_context.py +26 -0
- pyrestkit/response/__init__.py +8 -0
- pyrestkit/response/framework_response.py +271 -0
- pyrestkit/response/response_body.py +124 -0
- pyrestkit/retry/__init__.py +5 -0
- pyrestkit/retry/backoff.py +32 -0
- pyrestkit/retry/retry_handler.py +52 -0
- pyrestkit/retry/retry_policy.py +33 -0
- pyrestkit/serializers/__init__.py +0 -0
- pyrestkit/serializers/response_mapper.py +25 -0
- pyrestkit/types/__init__.py +0 -0
- pyrestkit/types/model_protocol.py +17 -0
- pyrestkit/utils/__init__.py +0 -0
- pyrestkit/validators/__init__.py +7 -0
- pyrestkit/validators/response_validator.py +57 -0
- pyrestkit/validators/schema_validator.py +33 -0
- pyrestkit-0.0.0.dist-info/METADATA +741 -0
- pyrestkit-0.0.0.dist-info/RECORD +115 -0
- pyrestkit-0.0.0.dist-info/WHEEL +5 -0
- pyrestkit-0.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
|
|
5
|
+
from pyrestkit.pipeline.request_context import RequestContext
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Middleware(ABC):
|
|
9
|
+
"""
|
|
10
|
+
Base class for all middleware.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
@abstractmethod
|
|
14
|
+
def process(
|
|
15
|
+
self,
|
|
16
|
+
context: RequestContext,
|
|
17
|
+
) -> RequestContext:
|
|
18
|
+
raise NotImplementedError
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
from pyrestkit.pipeline.middleware import Middleware
|
|
2
|
+
from pyrestkit.pipeline.request_context import RequestContext
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class RequestPipeline:
|
|
6
|
+
"""
|
|
7
|
+
Executes middleware sequentially.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
def __init__(self) -> None:
|
|
11
|
+
self._middlewares: list[Middleware] = []
|
|
12
|
+
|
|
13
|
+
def add(
|
|
14
|
+
self,
|
|
15
|
+
middleware: Middleware,
|
|
16
|
+
) -> None:
|
|
17
|
+
self._middlewares.append(middleware)
|
|
18
|
+
|
|
19
|
+
def execute(
|
|
20
|
+
self,
|
|
21
|
+
context: RequestContext,
|
|
22
|
+
) -> RequestContext:
|
|
23
|
+
|
|
24
|
+
for middleware in self._middlewares:
|
|
25
|
+
context = middleware.process(context)
|
|
26
|
+
|
|
27
|
+
return context
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass(slots=True)
|
|
8
|
+
class RequestContext:
|
|
9
|
+
"""
|
|
10
|
+
Represents a single HTTP request flowing through the middleware pipeline.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
method: str
|
|
14
|
+
url: str
|
|
15
|
+
|
|
16
|
+
headers: dict[str, str] = field(default_factory=dict)
|
|
17
|
+
|
|
18
|
+
params: dict[str, Any] = field(default_factory=dict)
|
|
19
|
+
|
|
20
|
+
json: dict[str, Any] | None = None
|
|
21
|
+
|
|
22
|
+
data: Any = None
|
|
23
|
+
|
|
24
|
+
timeout: int = 30
|
|
25
|
+
|
|
26
|
+
kwargs: dict[str, Any] = field(default_factory=dict)
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Mapping
|
|
4
|
+
from dataclasses import is_dataclass
|
|
5
|
+
from datetime import timedelta
|
|
6
|
+
from typing import TYPE_CHECKING, Any, TypeVar
|
|
7
|
+
|
|
8
|
+
import requests
|
|
9
|
+
|
|
10
|
+
from pyrestkit.ai.analyzer import FailureAnalyzer
|
|
11
|
+
from pyrestkit.ai.client import AIClient
|
|
12
|
+
from pyrestkit.ai.config import AIConfig
|
|
13
|
+
from pyrestkit.ai.exceptions import AIConfigurationError
|
|
14
|
+
from pyrestkit.ai.models import FailureAnalysis, FailureContext
|
|
15
|
+
from pyrestkit.response.response_body import ResponseBody
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from pyrestkit.assertions.response_assertions import ResponseAssertions
|
|
19
|
+
|
|
20
|
+
T = TypeVar("T")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class ResponseAIHelper:
|
|
24
|
+
"""
|
|
25
|
+
Optional AI helper attached to a FrameworkResponse.
|
|
26
|
+
|
|
27
|
+
It is lazy-loaded and degrades gracefully by raising a framework-specific
|
|
28
|
+
configuration error when no AI client is configured.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
def __init__(self, response: FrameworkResponse) -> None:
|
|
32
|
+
self._response = response
|
|
33
|
+
|
|
34
|
+
def explain_failure(
|
|
35
|
+
self,
|
|
36
|
+
*,
|
|
37
|
+
client: AIClient | None = None,
|
|
38
|
+
config: AIConfig | None = None,
|
|
39
|
+
prompt_loader: Any | None = None,
|
|
40
|
+
) -> FailureAnalysis:
|
|
41
|
+
if client is None:
|
|
42
|
+
if config is None:
|
|
43
|
+
raise AIConfigurationError("AI is not configured.")
|
|
44
|
+
|
|
45
|
+
client = AIClient(config=config)
|
|
46
|
+
|
|
47
|
+
analyzer = FailureAnalyzer(client, prompt_loader=prompt_loader)
|
|
48
|
+
context = FailureContext(
|
|
49
|
+
test_name="response_failure",
|
|
50
|
+
error_type="HTTPError",
|
|
51
|
+
error_message=self._response._response.reason,
|
|
52
|
+
request_method=None,
|
|
53
|
+
request_url=str(self._response._response.url or ""),
|
|
54
|
+
response_status=self._response.status,
|
|
55
|
+
response_body=self._response.text,
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
return analyzer.analyze(context)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class FrameworkResponse:
|
|
62
|
+
"""
|
|
63
|
+
Wrapper around requests.Response.
|
|
64
|
+
|
|
65
|
+
Adds framework features while still exposing
|
|
66
|
+
the original response.
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
def __init__(
|
|
70
|
+
self,
|
|
71
|
+
response: requests.Response,
|
|
72
|
+
) -> None:
|
|
73
|
+
self._response = response
|
|
74
|
+
self._body: ResponseBody | None = None
|
|
75
|
+
self._ai_helper: ResponseAIHelper | None = None
|
|
76
|
+
|
|
77
|
+
@property
|
|
78
|
+
def raw(
|
|
79
|
+
self,
|
|
80
|
+
) -> requests.Response:
|
|
81
|
+
return self._response
|
|
82
|
+
|
|
83
|
+
@property
|
|
84
|
+
def should(
|
|
85
|
+
self,
|
|
86
|
+
) -> ResponseAssertions:
|
|
87
|
+
from pyrestkit.assertions.response_assertions import (
|
|
88
|
+
ResponseAssertions,
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
return ResponseAssertions(self)
|
|
92
|
+
|
|
93
|
+
@property
|
|
94
|
+
def status(
|
|
95
|
+
self,
|
|
96
|
+
) -> int:
|
|
97
|
+
return self._response.status_code
|
|
98
|
+
|
|
99
|
+
@property
|
|
100
|
+
def status_code(
|
|
101
|
+
self,
|
|
102
|
+
) -> int:
|
|
103
|
+
return self._response.status_code
|
|
104
|
+
|
|
105
|
+
@property
|
|
106
|
+
def headers(
|
|
107
|
+
self,
|
|
108
|
+
) -> Mapping[str, str]:
|
|
109
|
+
return self._response.headers
|
|
110
|
+
|
|
111
|
+
@property
|
|
112
|
+
def ai(
|
|
113
|
+
self,
|
|
114
|
+
) -> ResponseAIHelper:
|
|
115
|
+
if self._ai_helper is None:
|
|
116
|
+
self._ai_helper = ResponseAIHelper(self)
|
|
117
|
+
|
|
118
|
+
return self._ai_helper
|
|
119
|
+
|
|
120
|
+
@property
|
|
121
|
+
def text(
|
|
122
|
+
self,
|
|
123
|
+
) -> str:
|
|
124
|
+
return self._response.text
|
|
125
|
+
|
|
126
|
+
@property
|
|
127
|
+
def elapsed(
|
|
128
|
+
self,
|
|
129
|
+
) -> timedelta:
|
|
130
|
+
return self._response.elapsed
|
|
131
|
+
|
|
132
|
+
@property
|
|
133
|
+
def body(
|
|
134
|
+
self,
|
|
135
|
+
) -> ResponseBody:
|
|
136
|
+
if self._body is None:
|
|
137
|
+
self._body = ResponseBody(
|
|
138
|
+
self._response.json(),
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
return self._body
|
|
142
|
+
|
|
143
|
+
def json(
|
|
144
|
+
self,
|
|
145
|
+
) -> Any:
|
|
146
|
+
return self._response.json()
|
|
147
|
+
|
|
148
|
+
def as_model(
|
|
149
|
+
self,
|
|
150
|
+
model: type[T],
|
|
151
|
+
path: str | None = None,
|
|
152
|
+
) -> T:
|
|
153
|
+
"""
|
|
154
|
+
Deserialize a JSON object into a dataclass.
|
|
155
|
+
|
|
156
|
+
Examples:
|
|
157
|
+
|
|
158
|
+
response.as_model(User)
|
|
159
|
+
|
|
160
|
+
response.as_model(User, path="data")
|
|
161
|
+
|
|
162
|
+
response.as_model(User, path="result.user")
|
|
163
|
+
"""
|
|
164
|
+
if not is_dataclass(model):
|
|
165
|
+
raise TypeError(
|
|
166
|
+
f"{model.__name__} is not a dataclass.",
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
data = self._get_value(path)
|
|
170
|
+
|
|
171
|
+
if not isinstance(data, dict):
|
|
172
|
+
raise TypeError(
|
|
173
|
+
"Expected a JSON object.",
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
return model(**data)
|
|
177
|
+
|
|
178
|
+
def _get_value(
|
|
179
|
+
self,
|
|
180
|
+
path: str | None = None,
|
|
181
|
+
) -> Any:
|
|
182
|
+
"""
|
|
183
|
+
Returns a value from the response JSON using dot notation.
|
|
184
|
+
|
|
185
|
+
Examples:
|
|
186
|
+
|
|
187
|
+
None
|
|
188
|
+
-> whole JSON
|
|
189
|
+
|
|
190
|
+
"data"
|
|
191
|
+
-> json()["data"]
|
|
192
|
+
|
|
193
|
+
"data.user"
|
|
194
|
+
-> json()["data"]["user"]
|
|
195
|
+
"""
|
|
196
|
+
data = self.json()
|
|
197
|
+
|
|
198
|
+
if path is None:
|
|
199
|
+
return data
|
|
200
|
+
|
|
201
|
+
for key in path.split("."):
|
|
202
|
+
if not isinstance(data, dict):
|
|
203
|
+
raise TypeError(
|
|
204
|
+
f"Cannot access '{key}' as the current value is not a JSON object.",
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
if key not in data:
|
|
208
|
+
raise KeyError(
|
|
209
|
+
f"Path '{path}' not found in response.",
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
data = data[key]
|
|
213
|
+
|
|
214
|
+
return data
|
|
215
|
+
|
|
216
|
+
def as_list(
|
|
217
|
+
self,
|
|
218
|
+
model: type[T],
|
|
219
|
+
path: str | None = None,
|
|
220
|
+
) -> list[T]:
|
|
221
|
+
"""
|
|
222
|
+
Deserialize a JSON array into a list of dataclasses.
|
|
223
|
+
|
|
224
|
+
Examples:
|
|
225
|
+
|
|
226
|
+
response.as_list(User)
|
|
227
|
+
|
|
228
|
+
response.as_list(User, path="data")
|
|
229
|
+
|
|
230
|
+
response.as_list(User, path="result.users")
|
|
231
|
+
"""
|
|
232
|
+
if not is_dataclass(model):
|
|
233
|
+
raise TypeError(
|
|
234
|
+
f"{model.__name__} is not a dataclass.",
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
data = self._get_value(path)
|
|
238
|
+
|
|
239
|
+
if not isinstance(data, list):
|
|
240
|
+
raise TypeError(
|
|
241
|
+
"Expected a JSON array.",
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
return [model(**item) for item in data]
|
|
245
|
+
|
|
246
|
+
def raise_for_status(
|
|
247
|
+
self,
|
|
248
|
+
) -> None:
|
|
249
|
+
self._response.raise_for_status()
|
|
250
|
+
|
|
251
|
+
def __getattr__(
|
|
252
|
+
self,
|
|
253
|
+
name: str,
|
|
254
|
+
) -> Any:
|
|
255
|
+
"""
|
|
256
|
+
Delegate unknown attributes to requests.Response.
|
|
257
|
+
"""
|
|
258
|
+
return getattr(
|
|
259
|
+
self._response,
|
|
260
|
+
name,
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
def __repr__(
|
|
264
|
+
self,
|
|
265
|
+
) -> str:
|
|
266
|
+
return repr(self._response)
|
|
267
|
+
|
|
268
|
+
def __str__(
|
|
269
|
+
self,
|
|
270
|
+
) -> str:
|
|
271
|
+
return str(self._response)
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Iterator
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class ResponseBody:
|
|
8
|
+
"""
|
|
9
|
+
Provides attribute-style access to JSON responses.
|
|
10
|
+
|
|
11
|
+
Example:
|
|
12
|
+
|
|
13
|
+
body.first_name
|
|
14
|
+
body.data.email
|
|
15
|
+
body.users[0].email
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
def __init__(
|
|
19
|
+
self,
|
|
20
|
+
data: Any,
|
|
21
|
+
) -> None:
|
|
22
|
+
self._data = data
|
|
23
|
+
|
|
24
|
+
def __getattr__(
|
|
25
|
+
self,
|
|
26
|
+
name: str,
|
|
27
|
+
) -> Any:
|
|
28
|
+
if not isinstance(self._data, dict):
|
|
29
|
+
raise AttributeError(
|
|
30
|
+
f"'{type(self._data).__name__}' has no attribute '{name}'."
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
if name not in self._data:
|
|
34
|
+
raise AttributeError(f"Field '{name}' not found.")
|
|
35
|
+
|
|
36
|
+
return self._wrap(
|
|
37
|
+
self._data[name],
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
def __getitem__(
|
|
41
|
+
self,
|
|
42
|
+
key: Any,
|
|
43
|
+
) -> Any:
|
|
44
|
+
return self._wrap(
|
|
45
|
+
self._data[key],
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
# def __iter__(
|
|
49
|
+
# self,
|
|
50
|
+
# ):
|
|
51
|
+
# if isinstance(
|
|
52
|
+
# self._data,
|
|
53
|
+
# list,
|
|
54
|
+
# ):
|
|
55
|
+
# for item in self._data:
|
|
56
|
+
# yield self._wrap(item)
|
|
57
|
+
# else:
|
|
58
|
+
# raise TypeError("ResponseBody is not iterable.")
|
|
59
|
+
|
|
60
|
+
# from collections.abc import Iterator
|
|
61
|
+
|
|
62
|
+
# ...
|
|
63
|
+
|
|
64
|
+
def __iter__(
|
|
65
|
+
self,
|
|
66
|
+
) -> Iterator[Any]:
|
|
67
|
+
if isinstance(
|
|
68
|
+
self._data,
|
|
69
|
+
list,
|
|
70
|
+
):
|
|
71
|
+
for item in self._data:
|
|
72
|
+
yield self._wrap(item)
|
|
73
|
+
else:
|
|
74
|
+
raise TypeError("ResponseBody is not iterable.")
|
|
75
|
+
|
|
76
|
+
@staticmethod
|
|
77
|
+
def _wrap(
|
|
78
|
+
value: Any,
|
|
79
|
+
) -> Any:
|
|
80
|
+
if isinstance(
|
|
81
|
+
value,
|
|
82
|
+
dict,
|
|
83
|
+
):
|
|
84
|
+
return ResponseBody(value)
|
|
85
|
+
|
|
86
|
+
if isinstance(
|
|
87
|
+
value,
|
|
88
|
+
list,
|
|
89
|
+
):
|
|
90
|
+
return [ResponseBody._wrap(item) for item in value]
|
|
91
|
+
|
|
92
|
+
return value
|
|
93
|
+
|
|
94
|
+
def to_dict(
|
|
95
|
+
self,
|
|
96
|
+
) -> Any:
|
|
97
|
+
"""
|
|
98
|
+
Returns the underlying JSON object.
|
|
99
|
+
"""
|
|
100
|
+
return self._data
|
|
101
|
+
|
|
102
|
+
def get(
|
|
103
|
+
self,
|
|
104
|
+
path: str,
|
|
105
|
+
) -> Any:
|
|
106
|
+
"""
|
|
107
|
+
Returns a JSON value using dot notation.
|
|
108
|
+
|
|
109
|
+
Example:
|
|
110
|
+
|
|
111
|
+
body.get("data.users")
|
|
112
|
+
"""
|
|
113
|
+
|
|
114
|
+
current: Any = self._data
|
|
115
|
+
|
|
116
|
+
for part in path.split("."):
|
|
117
|
+
if isinstance(current, dict):
|
|
118
|
+
if part not in current:
|
|
119
|
+
raise AttributeError(f"Field '{path}' not found.")
|
|
120
|
+
current = current[part]
|
|
121
|
+
else:
|
|
122
|
+
raise AttributeError(f"Field '{path}' not found.")
|
|
123
|
+
|
|
124
|
+
return current
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class ExponentialBackoff:
|
|
7
|
+
"""
|
|
8
|
+
Implements exponential backoff.
|
|
9
|
+
|
|
10
|
+
Example:
|
|
11
|
+
|
|
12
|
+
attempt=1 -> 1 sec
|
|
13
|
+
|
|
14
|
+
attempt=2 -> 2 sec
|
|
15
|
+
|
|
16
|
+
attempt=3 -> 4 sec
|
|
17
|
+
|
|
18
|
+
attempt=4 -> 8 sec
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
def __init__(
|
|
22
|
+
self,
|
|
23
|
+
factor: float = 1.0,
|
|
24
|
+
) -> None:
|
|
25
|
+
self._factor = factor
|
|
26
|
+
|
|
27
|
+
def sleep(
|
|
28
|
+
self,
|
|
29
|
+
attempt: int,
|
|
30
|
+
) -> None:
|
|
31
|
+
delay = self._factor * (2 ** (attempt - 1))
|
|
32
|
+
time.sleep(delay)
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
import requests
|
|
7
|
+
|
|
8
|
+
from pyrestkit.retry.backoff import ExponentialBackoff
|
|
9
|
+
from pyrestkit.retry.retry_policy import RetryPolicy
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class RetryHandler:
|
|
13
|
+
"""
|
|
14
|
+
Executes HTTP requests with retry support.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
def __init__(
|
|
18
|
+
self,
|
|
19
|
+
policy: RetryPolicy,
|
|
20
|
+
) -> None:
|
|
21
|
+
self._policy = policy
|
|
22
|
+
self._backoff = ExponentialBackoff(
|
|
23
|
+
factor=policy.backoff_factor,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
def execute(
|
|
27
|
+
self,
|
|
28
|
+
request: Callable[..., requests.Response],
|
|
29
|
+
**kwargs: Any,
|
|
30
|
+
) -> requests.Response:
|
|
31
|
+
last_exception: Exception | None = None
|
|
32
|
+
|
|
33
|
+
for attempt in range(
|
|
34
|
+
1,
|
|
35
|
+
self._policy.retries + 1,
|
|
36
|
+
):
|
|
37
|
+
try:
|
|
38
|
+
response = request(**kwargs)
|
|
39
|
+
|
|
40
|
+
if response.status_code not in self._policy.retry_on_status:
|
|
41
|
+
return response
|
|
42
|
+
|
|
43
|
+
except self._policy.retry_on_exceptions as exc:
|
|
44
|
+
last_exception = exc
|
|
45
|
+
|
|
46
|
+
if attempt < self._policy.retries:
|
|
47
|
+
self._backoff.sleep(attempt)
|
|
48
|
+
|
|
49
|
+
if last_exception is not None:
|
|
50
|
+
raise last_exception
|
|
51
|
+
|
|
52
|
+
return response
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
|
|
5
|
+
import requests
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(slots=True)
|
|
9
|
+
class RetryPolicy:
|
|
10
|
+
"""
|
|
11
|
+
Retry configuration.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
retries: int = 3
|
|
15
|
+
|
|
16
|
+
backoff_factor: float = 1.0
|
|
17
|
+
|
|
18
|
+
retry_on_status: set[int] = field(
|
|
19
|
+
default_factory=lambda: {
|
|
20
|
+
500,
|
|
21
|
+
502,
|
|
22
|
+
503,
|
|
23
|
+
504,
|
|
24
|
+
},
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
retry_on_exceptions: tuple[
|
|
28
|
+
type[Exception],
|
|
29
|
+
...,
|
|
30
|
+
] = (
|
|
31
|
+
requests.exceptions.Timeout,
|
|
32
|
+
requests.exceptions.ConnectionError,
|
|
33
|
+
)
|
|
File without changes
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, TypeVar, cast
|
|
4
|
+
|
|
5
|
+
from pyrestkit.exceptions.serialization_exception import SerializationException
|
|
6
|
+
|
|
7
|
+
T = TypeVar("T")
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ResponseMapper:
|
|
11
|
+
"""
|
|
12
|
+
Maps API response dictionaries to model objects.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
@staticmethod
|
|
16
|
+
def map(
|
|
17
|
+
data: dict[str, Any],
|
|
18
|
+
model: type[T],
|
|
19
|
+
) -> T:
|
|
20
|
+
try:
|
|
21
|
+
return cast(T, model.from_dict(data)) # type: ignore[attr-defined]
|
|
22
|
+
except Exception as exc:
|
|
23
|
+
raise SerializationException(
|
|
24
|
+
f"Unable to map response to {model.__name__}"
|
|
25
|
+
) from exc
|
|
File without changes
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Protocol, TypeVar
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class ModelProtocol(Protocol):
|
|
7
|
+
@classmethod
|
|
8
|
+
def from_dict(
|
|
9
|
+
cls,
|
|
10
|
+
data: dict[str, Any],
|
|
11
|
+
) -> ModelProtocol: ...
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
T = TypeVar(
|
|
15
|
+
"T",
|
|
16
|
+
bound=ModelProtocol,
|
|
17
|
+
)
|
|
File without changes
|