RESTFly 2.0.3__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.
restfly/_base.py ADDED
@@ -0,0 +1,340 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ import platform
5
+ from collections import defaultdict
6
+ from collections.abc import Callable
7
+ from ssl import SSLContext
8
+ from typing import Any
9
+
10
+ from pydantic import BaseModel
11
+ from pydantic_xml import BaseXmlModel
12
+
13
+ from ._errors import APIError, ErrorStatus, build_error_map
14
+ from ._utils import assign_annotations
15
+ from ._version import version as RESTFLY_VERSION
16
+ from .types import (
17
+ DEFAULT_LIMITS,
18
+ DEFAULT_MAX_REDIRECTS,
19
+ DEFAULT_TIMEOUT_CONFIG,
20
+ HTTPX_VERSION,
21
+ USE_CLIENT_DEFAULT,
22
+ AsyncBaseTransport,
23
+ AsyncClient,
24
+ AuthTypes,
25
+ BaseTransport,
26
+ CertTypes,
27
+ Client,
28
+ CookieTypes,
29
+ EventHook,
30
+ HeaderTypes,
31
+ HTTPMethods,
32
+ Limits,
33
+ Model,
34
+ ProxyTypes,
35
+ QueryParamTypes,
36
+ RequestContent,
37
+ RequestData,
38
+ RequestExtensions,
39
+ RequestFiles,
40
+ TimeoutTypes,
41
+ UseClientDefault,
42
+ XMLModel,
43
+ )
44
+
45
+
46
+ class APIClientBase:
47
+ __client_class__: type[Client | AsyncClient]
48
+ __endpoint_class__: type[APIBaseEndpoint]
49
+
50
+ _base_url: str = ""
51
+ """ The base URL fragment to prefix all API calls with. """
52
+
53
+ _client: Client | AsyncClient
54
+ """ HTTPX Client Session object """
55
+
56
+ _json_load_kwargs: dict[str, Any]
57
+ """ Client-level Pydantic BaseModel.model_validate kwargs """
58
+
59
+ _json_dump_kwargs: dict[str, Any]
60
+ """ Client-level Pydantic BaseModel.model_dump kwargs """
61
+
62
+ _xml_load_kwargs: dict[str, Any]
63
+ """ Client-level Pydantic-XML BaseXmlModel.from_xml kwargs """
64
+
65
+ _xml_dump_kwargs: dict[str, Any]
66
+ """ Client-level Pydantic-XML BaseXmlModel.to_xml kwargs """
67
+
68
+ _lib_name: str = "RESTFly"
69
+ """ Library name """
70
+
71
+ _lib_version: str = "2"
72
+ """ Library version """
73
+
74
+ _retry_max: int = 5
75
+ """ Maximum number of retries to attempt before giving up. """
76
+
77
+ _logger: logging.Logger
78
+ """ Logger for the client """
79
+
80
+ _error_class: type[APIError] = APIError
81
+ """ API Exception Class to use for all API Errors/Exceptions """
82
+
83
+ __error_map__: dict[int, ErrorStatus] | None = None
84
+ """ The default built-in error map. If None the library default is used. """
85
+
86
+ _error_map: defaultdict[int, ErrorStatus]
87
+ """
88
+ The error map determining how to handle non-OK status codes. Represented by the
89
+ integer status code along with an ErrorStatus data-class object detailing how to
90
+ handle the response. Any overloads to the base error map should be provided here
91
+ and this attribute will be then be replaced at initialization with the merging of
92
+ the default map, this attribute, and anything passed to the constructor. The
93
+ resulting error map will be used during operation of the APIClass object.
94
+ """
95
+
96
+ def __init__(
97
+ self,
98
+ *,
99
+ auth: AuthTypes | None = None,
100
+ params: QueryParamTypes | None = None,
101
+ headers: HeaderTypes | None = None,
102
+ cookies: CookieTypes | None = None,
103
+ verify: SSLContext | str | bool = True,
104
+ cert: CertTypes | None = None,
105
+ http1: bool = True,
106
+ http2: bool = False,
107
+ proxy: ProxyTypes | None = None,
108
+ mounts: (dict[str, Any]) | None = None,
109
+ timeout: TimeoutTypes = DEFAULT_TIMEOUT_CONFIG,
110
+ follow_redirects: bool = False,
111
+ limits: Limits = DEFAULT_LIMITS,
112
+ max_redirects: int = DEFAULT_MAX_REDIRECTS,
113
+ event_hooks: (dict[str, list[EventHook]]) | None = None,
114
+ base_url: str | None = None,
115
+ transport: AsyncBaseTransport | BaseTransport | None = None,
116
+ trust_env: bool = True,
117
+ default_encoding: str | Callable[[bytes], str] = "utf-8",
118
+ vendor: str = "unknown",
119
+ product: str = "unknown",
120
+ build: str = "unknown",
121
+ retry_max: int = 5,
122
+ json_load_kwargs: dict[str, Any] | None = None,
123
+ json_dump_kwargs: dict[str, Any] | None = None,
124
+ xml_load_kwargs: dict[str, Any] | None = None,
125
+ xml_dump_kwargs: dict[str, Any] | None = None,
126
+ error_map: dict[int, ErrorStatus] | None = None,
127
+ error_class: type[APIError] | None = None,
128
+ ) -> None:
129
+ # Initialize mutables.
130
+ headers = {} if headers is None else headers
131
+ error_map = {} if error_map is None else error_map
132
+ error_class = self._error_class if error_class is None else error_class
133
+
134
+ # Initialize the private attributes for the client object.
135
+ self._base_url = base_url if base_url else self._base_url
136
+ self._logger = logging.getLogger(__name__)
137
+ self._retry_max = retry_max if retry_max else self._retry_max
138
+ self._json_load_kwargs = (
139
+ json_load_kwargs
140
+ if json_load_kwargs
141
+ else getattr(self, "_json_load_kwargs", {})
142
+ )
143
+ self._json_dump_kwargs = (
144
+ json_dump_kwargs
145
+ if json_dump_kwargs
146
+ else getattr(self, "_json_dump_kwargs", {})
147
+ )
148
+ self._xml_load_kwargs = (
149
+ xml_load_kwargs
150
+ if xml_load_kwargs
151
+ else getattr(self, "_xml_load_kwargs", {})
152
+ )
153
+ self._xml_dump_kwargs = (
154
+ xml_dump_kwargs
155
+ if xml_dump_kwargs
156
+ else getattr(self, "_xml_dump_kwargs", {})
157
+ )
158
+
159
+ # Construct the error map default dict using the built-in error_map as well as
160
+ # the over loadable _error_map extension and then lastly update with any
161
+ # error_map provided at initialization. Lastly store the resulting defaultdict
162
+ # over the _error_map private attribute.
163
+ self._error_map = build_error_map(
164
+ base_map=self.__error_map__, overloads=error_map, error_class=error_class
165
+ )
166
+
167
+ # If we had received a pydantic model for the client params, then we will first
168
+ # coerce them into a python dictionary.
169
+ if isinstance(params, BaseModel):
170
+ params = params.model_dump(mode="json", exclude_none=True)
171
+
172
+ # Instantiate the HTTPX Client with the arguments we have.
173
+ self._client = self.__client_class__(
174
+ auth=auth,
175
+ params=params,
176
+ headers=headers,
177
+ cookies=cookies,
178
+ verify=verify,
179
+ cert=cert,
180
+ http1=http1,
181
+ http2=http2,
182
+ proxy=proxy,
183
+ mounts=mounts,
184
+ timeout=timeout,
185
+ follow_redirects=follow_redirects,
186
+ limits=limits,
187
+ max_redirects=max_redirects,
188
+ event_hooks=event_hooks,
189
+ base_url=self._base_url,
190
+ transport=transport, # type: ignore[arg-type] #ty: ignore[invalid-argument-type]
191
+ trust_env=trust_env,
192
+ default_encoding=default_encoding,
193
+ )
194
+
195
+ # Update the client with the User-Agent header.
196
+ user_agent = (
197
+ f"Integration/1.0 ({vendor}; {product}; Build/{build}) "
198
+ f"{self._lib_name}/{self._lib_version} "
199
+ f"(RESTFly/{RESTFLY_VERSION}; "
200
+ f"HTTPX/{self.__client_class__.__name__}-{HTTPX_VERSION}; "
201
+ f"Python/{platform.python_version()}; "
202
+ f"{platform.system()}/{platform.machine()})"
203
+ )
204
+ self._client.headers.update({"User-Agent": user_agent})
205
+
206
+ # Lastly, we will run the annotation assignment build-in to make sure that the
207
+ # public endpoints are properly grafted to the client class.
208
+ self.__assign_annotations__()
209
+
210
+ def _request_pre_process(
211
+ self,
212
+ method: HTTPMethods,
213
+ url: str,
214
+ params: QueryParamTypes | None = None,
215
+ content: RequestContent | None = None,
216
+ data: RequestData | None = None,
217
+ files: RequestFiles | None = None,
218
+ json: Model | object | None = None,
219
+ xml: XMLModel | str | bytes | None = None,
220
+ headers: dict[str, str] | None = None,
221
+ request_model_kwargs: dict[str, Any] | None = None,
222
+ cookies: CookieTypes | None = None,
223
+ timeout: TimeoutTypes | UseClientDefault = USE_CLIENT_DEFAULT,
224
+ extensions: RequestExtensions | None = None,
225
+ ) -> dict[str, Any]:
226
+ """
227
+ Performs preflight pre-processing of the request in preparation to be sent to
228
+ HTTPX. Most notably this method is what handles any potential model marshalling
229
+ into the request format. The output is passed as the kwargs into HTTPXs
230
+ request builder.
231
+
232
+ Args:
233
+ method: The HTTP Method.
234
+ url: The HTTP url (this can be either a partial or a full URL).
235
+ params: Query parameters.
236
+ content: Raw request content.
237
+ data: The HTTP request body.
238
+ files: Any files to upload with the request.
239
+ json: Overrides the content and data attributes with JSON-formatted data.
240
+ xml: Overrides the content and data attributes with XML-formatted data.
241
+ headers: Any additional headers to pass into the request.
242
+ request_model_kwargs:
243
+ The pydantic/pydantic-xml kwargs to be passed to the model as part of
244
+ marshalling the data into the expected format.
245
+ cookies: The cookies to pass to the request.
246
+ timeout: The HTTP request timeout.
247
+ extensions: Any request extensions passed as part of the request.
248
+
249
+ Returns:
250
+ The kwargs dictionary to be passed to the request builder.
251
+ """
252
+ # Initialize mutables
253
+ headers = {} if headers is None else headers
254
+ request_model_kwargs = (
255
+ {} if request_model_kwargs is None else request_model_kwargs
256
+ )
257
+
258
+ # The query parameters must always be transformed into a dictionary to be
259
+ # properly passed to HTTPX
260
+ if isinstance(params, BaseModel):
261
+ params = params.model_dump(mode="json", **self._json_dump_kwargs)
262
+
263
+ # If the XML model actually is Pydantic-XML model, then we will want to marshal
264
+ # it into bytes and set the content type to XML.
265
+ if isinstance(xml, BaseXmlModel):
266
+ xml_kwargs: dict[str, Any] = {}
267
+ for updates in (
268
+ getattr(self, "_xml_dump_kwargs", {}),
269
+ request_model_kwargs,
270
+ ):
271
+ xml_kwargs.update(updates)
272
+ content = xml.to_xml(**xml_kwargs)
273
+ headers["Content-Type"] = "application/xml"
274
+
275
+ # If the xml attribute is already a string or bytes object, then simply set
276
+ # the content type to XML.
277
+ elif isinstance(xml, (str, bytes)):
278
+ content = xml
279
+ headers["Content-Type"] = "application/xml"
280
+
281
+ # If the json attribute is actually a Pydantic model, then we will want to
282
+ # marshal the data using Pydantics model dump and set the content type to JSON
283
+ elif isinstance(json, BaseModel):
284
+ pydantic_kwargs: dict[str, Any] = {}
285
+ for updates in (
286
+ getattr(self, "_json_dump_kwargs", {}),
287
+ request_model_kwargs,
288
+ ):
289
+ pydantic_kwargs.update(updates)
290
+ content = json.model_dump_json(**pydantic_kwargs)
291
+ headers["Content-Type"] = "application/json"
292
+ json = None
293
+
294
+ # Return the kwargs to the caller.
295
+ return {
296
+ "method": method,
297
+ "url": url,
298
+ "params": params,
299
+ "content": content,
300
+ "data": data,
301
+ "files": files,
302
+ "json": json,
303
+ "headers": headers,
304
+ "cookies": cookies,
305
+ "timeout": timeout,
306
+ "extensions": extensions,
307
+ }
308
+
309
+ def __assign_annotations__(self) -> None:
310
+ """
311
+ Handles Annotation assignment for API Endpoints.
312
+ """
313
+ assign_annotations(self, self.__endpoint_class__)
314
+
315
+
316
+ class APIBaseEndpoint:
317
+ _path: str | None = None
318
+ """ Path fragment to append to the base url already stored within the client. """
319
+
320
+ _logger: logging.Logger
321
+ """
322
+ The same logging handler that the client has, it also exists here for convenience.
323
+ """
324
+
325
+ _client: APIClientBase
326
+ """
327
+ The client object that this endpoint is associated with. Provided here in order to
328
+ interact with other endpoints that may be grafted to the client.
329
+ """
330
+
331
+ def __init__(self, client: APIClientBase | APIBaseEndpoint) -> None:
332
+ match client:
333
+ case APIClientBase():
334
+ self._client = client
335
+ case APIBaseEndpoint():
336
+ self._client = client._client
337
+ case _:
338
+ raise TypeError(f"Client {client} is not a valid client type.")
339
+ self._logger = client._logger
340
+ assign_annotations(self, self._client.__endpoint_class__)
restfly/_errors.py ADDED
@@ -0,0 +1,213 @@
1
+ """
2
+ Error handling classes and data-classes.
3
+ """
4
+
5
+ import logging
6
+ from collections import defaultdict
7
+ from dataclasses import dataclass, replace
8
+
9
+ from httpx import Response
10
+ from pydantic import BaseModel
11
+
12
+
13
+ class RetryError(Exception):
14
+ """
15
+ RetryError is thrown when too many retries have been made to the endpoint and we
16
+ have been forced to stop attempting to call the API endpoint.
17
+ """
18
+
19
+ def __init__(self, url: str, method: str, attempts: int):
20
+ super().__init__(f"Too many attempts ({attempts}) to {method} {url}")
21
+
22
+
23
+ class APIError(Exception):
24
+ """
25
+ The Base API Error class to be thrown in the event that a non-OK status code is
26
+ returned.
27
+ """
28
+
29
+ status_code: int
30
+ """ Status code of the HTTP Response """
31
+
32
+ response: Response
33
+ """ HTTPX Response object of the exception """
34
+
35
+ obj: BaseModel | None
36
+ """ Coerced data obj of the response (if any) """
37
+
38
+ def __init__(
39
+ self,
40
+ response: Response,
41
+ template: str,
42
+ obj: BaseModel | None = None,
43
+ ):
44
+ self.status_code = response.status_code
45
+ self.obj = obj
46
+ self.response = response
47
+ msg = template.format(response=response, request=response.request)
48
+ logger = logging.getLogger(__name__)
49
+ logger.warning(msg)
50
+ super().__init__(msg)
51
+
52
+
53
+ @dataclass
54
+ class ErrorStatus:
55
+ """
56
+ Defines the attributes associated to each non-OK status code.
57
+
58
+ Parameters:
59
+ retry:
60
+ Should the request be retried?
61
+ template:
62
+ The log/exception to be sent. This string template will accept both the
63
+ request and response objects.
64
+ model:
65
+ The Pydantic model (if any) to coerce the response body into.
66
+ exception:
67
+ The Exception class to throw.
68
+ backoff:
69
+ How many seconds to wait before retrying? This field will be overloaded
70
+ if the client detects a Retry-After header and will use the response
71
+ value instead of this if it exists.
72
+ jitter:
73
+ If this value is set to a non-zero value, then when calculating the amount
74
+ of time to wait, the client will randomly select a value between 0 and this
75
+ value and append it to the wait before retying. Normally we will want this
76
+ set to something in order to ensure that we stagger retries to the API to
77
+ prevent overwhelming it.
78
+ """
79
+
80
+ retry: bool = False
81
+ template: str = (
82
+ r"[{response.status_code}] Response from {request.method} {request.url}"
83
+ )
84
+ model: type[BaseModel] | None = None
85
+ exception: type[APIError] = APIError
86
+ backoff: float = 1.0
87
+ jitter: float = 0.5
88
+
89
+
90
+ def build_error_map(
91
+ base_map: dict[int, ErrorStatus] | None = None,
92
+ overloads: dict[int, ErrorStatus] | None = None,
93
+ error_class: type[APIError] | None = None,
94
+ ) -> defaultdict[int, ErrorStatus]:
95
+ """
96
+ Error map builder.
97
+ """
98
+ if base_map is None:
99
+ base_map = ERROR_MAP
100
+ if overloads is None:
101
+ overloads = {}
102
+ if error_class is None:
103
+ error_class = APIError
104
+
105
+ def error_status() -> ErrorStatus:
106
+ return ErrorStatus(exception=error_class if error_class else APIError)
107
+
108
+ error_map = defaultdict(
109
+ error_status,
110
+ {k: replace(v, exception=error_class) for k, v in base_map.items()},
111
+ )
112
+ error_map.update(
113
+ {k: replace(v, exception=error_class) for k, v in overloads.items()}
114
+ )
115
+ return error_map
116
+
117
+
118
+ ERROR_MAP = {
119
+ 400: ErrorStatus(template=r"[400] Bad Request {request.method} {request.url}"),
120
+ 401: ErrorStatus(template=r"[401] Unauthorized {request.method} {request.url}"),
121
+ 402: ErrorStatus(template=r"[402] Payment Required {request.method} {request.url}"),
122
+ 403: ErrorStatus(template=r"[403] Access Forbidden {request.method} {request.url}"),
123
+ 404: ErrorStatus(template=r"[404] Not Found {request.method} {request.url}"),
124
+ 405: ErrorStatus(
125
+ template=r"[405] Method Not Allowed {request.method} {request.url}"
126
+ ),
127
+ 406: ErrorStatus(template=r"[406] Not Acceptable {request.method} {request.url}"),
128
+ 407: ErrorStatus(
129
+ template=r"[407] Proxy Auth Required {request.method} {request.url}"
130
+ ),
131
+ 408: ErrorStatus(
132
+ template=r"[408] Request Timed Out {request.method} {request.url}"
133
+ ),
134
+ 409: ErrorStatus(template=r"[409] Request Conflict {request.method} {request.url}"),
135
+ 410: ErrorStatus(template=r"[410] Gone {request.method} {request.url}"),
136
+ 411: ErrorStatus(template=r"[411] Length Required {request.method} {request.url}"),
137
+ 412: ErrorStatus(
138
+ template=r"[412] Precondition Failed {request.method} {request.url}"
139
+ ),
140
+ 413: ErrorStatus(
141
+ template=r"[413] Content Too Large {request.method} {request.url}"
142
+ ),
143
+ 414: ErrorStatus(template=r"[414] URI Too Long {request.method} {request.url}"),
144
+ 415: ErrorStatus(
145
+ template=r"[415] Unsupported Media Type {request.method} {request.url}"
146
+ ),
147
+ 416: ErrorStatus(
148
+ template=r"[416] Range Not Satisfiable {request.method} {request.url}"
149
+ ),
150
+ 417: ErrorStatus(
151
+ template=r"[417] Expectation Failed {request.method} {request.url}"
152
+ ),
153
+ 418: ErrorStatus(template=r"[418] I'm a Teapot {request.method} {request.url}"),
154
+ 421: ErrorStatus(
155
+ template=r"[421] Misdirected Request {request.method} {request.url}"
156
+ ),
157
+ 422: ErrorStatus(
158
+ template=r"[422] Unprocessable Content {request.method} {request.url}"
159
+ ),
160
+ 423: ErrorStatus(template=r"[423] Locked {request.method} {request.url}"),
161
+ 424: ErrorStatus(
162
+ template=r"[424] Failed Dependency {request.method} {request.url}"
163
+ ),
164
+ 425: ErrorStatus(template=r"[425] Too Early {request.method} {request.url}"),
165
+ 426: ErrorStatus(template=r"[426] Upgrade Required {request.method} {request.url}"),
166
+ 428: ErrorStatus(
167
+ template=r"[428] Precondition Required {request.method} {request.url}"
168
+ ),
169
+ 429: ErrorStatus(
170
+ retry=True,
171
+ template=r"[429] Too Many Requests to {request.method} {request.url}",
172
+ backoff=30,
173
+ ),
174
+ 431: ErrorStatus(
175
+ template=r"[431] Request Header Fields Too Large {request.method} {request.url}"
176
+ ),
177
+ 451: ErrorStatus(
178
+ template=r"[451] Unavailable For Legal Reasons {request.method} {request.url}"
179
+ ),
180
+ 500: ErrorStatus(
181
+ template=r"[500] Internal Server Error {request.method} {request.url}"
182
+ ),
183
+ 501: ErrorStatus(template=r"[501] Not Implemented {request.method} {request.url}"),
184
+ 502: ErrorStatus(
185
+ retry=True,
186
+ template=r"[502] Bad Gateway Response {request.method} {request.url}",
187
+ backoff=10,
188
+ ),
189
+ 503: ErrorStatus(
190
+ retry=True,
191
+ template=r"[503] Service Unavailable {request.method} {request.url}",
192
+ backoff=10,
193
+ ),
194
+ 504: ErrorStatus(
195
+ retry=True,
196
+ template=r"[504] Gateway Timeout {request.method} {request.url}",
197
+ backoff=10,
198
+ ),
199
+ 505: ErrorStatus(
200
+ template=r"[505] HTTP Version Not Supported {request.method} {request.url}"
201
+ ),
202
+ 506: ErrorStatus(
203
+ template=r"[506] Variant Also Negotiates {request.method} {request.url}"
204
+ ),
205
+ 507: ErrorStatus(
206
+ template=r"[507] Insufficient Storage {request.method} {request.url}"
207
+ ),
208
+ 508: ErrorStatus(template=r"[508] Loop Detected {request.method} {request.url}"),
209
+ 510: ErrorStatus(template=r"[510] Not Extended {request.method} {request.url}"),
210
+ 511: ErrorStatus(
211
+ template=r"[511] Network Authentication Required {request.method} {request.url}"
212
+ ),
213
+ }