xAPI-client 1.0.11__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.
xapi/__init__.py ADDED
@@ -0,0 +1,63 @@
1
+ from .apiKey import APIKey
2
+ from .client import Client
3
+ from .endpoint import Endpoint
4
+ from .path import Path, Parameters
5
+ from .rateLimit import RateLimiter
6
+ from .resource import Resource
7
+ from .retry import RetryConfig
8
+ from .type import NotGiven, NotGivenOr, NOTGIVEN
9
+ from .options import Options, IntOptions
10
+ from .model import ResponseModel, ResponseModelList, ResponseModels
11
+ from .query import Query
12
+ from .logger import logger
13
+ from .exceptions import (
14
+ APIError,
15
+ APIStatusError,
16
+ APIResponseValidationError,
17
+ APIConnectionError,
18
+ APITimeoutError,
19
+ BadRequestError,
20
+ AuthenticationError,
21
+ PermissionDeniedError,
22
+ NotFoundError,
23
+ ConflictError,
24
+ UnprocessableEntityError,
25
+ RateLimitError,
26
+ InternalServerError,
27
+ )
28
+
29
+ logger.configure()
30
+
31
+ __all__ = [
32
+ "Client",
33
+ "Resource",
34
+ "Endpoint",
35
+ "APIKey",
36
+ "RetryConfig",
37
+ "RateLimiter",
38
+ "Path",
39
+ "Parameters",
40
+ "NotGiven",
41
+ "NotGivenOr",
42
+ "NOTGIVEN",
43
+ "Options",
44
+ "IntOptions",
45
+ "ResponseModel",
46
+ "ResponseModelList",
47
+ "ResponseModels",
48
+ "Query",
49
+ "logger",
50
+ "APIError",
51
+ "APIStatusError",
52
+ "APIResponseValidationError",
53
+ "APIConnectionError",
54
+ "APITimeoutError",
55
+ "BadRequestError",
56
+ "AuthenticationError",
57
+ "PermissionDeniedError",
58
+ "NotFoundError",
59
+ "ConflictError",
60
+ "UnprocessableEntityError",
61
+ "RateLimitError",
62
+ "InternalServerError",
63
+ ]
xapi/apiKey.py ADDED
@@ -0,0 +1,90 @@
1
+ from __future__ import annotations
2
+ from typing import Literal
3
+ from dataclasses import dataclass
4
+ from .query import Query
5
+ from .type import Headers
6
+ from httpx import Headers as httpxHeaders
7
+
8
+
9
+ @dataclass
10
+ class APIKey:
11
+ """API key authentication configuration.
12
+
13
+ Controls how and when authentication is applied to requests.
14
+
15
+ **Scope** determines which endpoints require authentication:
16
+ - ``"All"``: Every request includes the API key automatically.
17
+ - ``"Endpoint"``: Only endpoints with ``requireAuth=True`` on their
18
+ parent resource include the API key.
19
+ - ``"Disabled"``: No authentication is applied.
20
+
21
+ **Scheme** determines where the API key is sent:
22
+ - ``"Header"``: Included as an HTTP header on every matching request.
23
+ - ``"Query"``: Appended as a query parameter on matching requests.
24
+
25
+ Args:
26
+ key: The header or query parameter name (e.g. ``"x-api-key"``).
27
+ secret: The API key value.
28
+ scope: Authentication scope (``"All"``, ``"Endpoint"``, or ``"Disabled"``).
29
+ scheme: Delivery method (``"Header"`` or ``"Query"``).
30
+
31
+ Usage::
32
+
33
+ apiKey = APIKey(key="x-api-key", secret="sk-...", scope="All", scheme="Header")
34
+ client = Client(url="https://api.example.com", apiKey=apiKey)
35
+ """
36
+
37
+ key: str
38
+ secret: str
39
+ scope: Literal["All", "Endpoint", "Disabled"]
40
+ scheme: Literal["Header", "Query"]
41
+
42
+ @property
43
+ def authenticate(self) -> dict[str, str]:
44
+ return {self.key: self.secret}
45
+
46
+ @property
47
+ def header(self) -> Headers:
48
+ return {self.key: self.secret}
49
+
50
+ @property
51
+ def query(self) -> Query:
52
+ return Query({self.key: self.secret})
53
+
54
+ @property
55
+ def util(self) -> APIKeyUtil:
56
+ return APIKeyUtil(self)
57
+
58
+ class APIKeyUtil:
59
+ """Internal helper that injects API key credentials into headers or query params.
60
+
61
+ Used by the Client during request building to apply authentication based
62
+ on the APIKey's scope and scheme configuration.
63
+ """
64
+ _apiKey: APIKey
65
+ def __init__(self, apiKey: APIKey):
66
+ self._apiKey = apiKey
67
+
68
+ def allInHeader(self, header: httpxHeaders):
69
+ """Inject API key into headers if scope is ``"All"``."""
70
+ if self._apiKey.scope == "All":
71
+ header.update(self._apiKey.header)
72
+ return header
73
+
74
+ def allInQuery(self, query: Query):
75
+ """Inject API key into query params if scope is ``"All"``."""
76
+ if self._apiKey.scope == "All":
77
+ return query.addPair(self._apiKey.key, self._apiKey.secret)
78
+ return query
79
+
80
+ def endpointInHeader(self, header: httpxHeaders, auth: bool):
81
+ """Inject API key into headers if scope is ``"Endpoint"`` and auth is required."""
82
+ if self._apiKey.scope == "Endpoint" and auth:
83
+ return header.update(self._apiKey.header)
84
+ return header
85
+
86
+ def endpointInQuery(self, query: Query, auth: bool):
87
+ """Inject API key into query params if scope is ``"Endpoint"`` and auth is required."""
88
+ if self._apiKey.scope == "Endpoint" and auth:
89
+ return query.addPair(self._apiKey.key, self._apiKey.secret)
90
+ return query
xapi/client.py ADDED
@@ -0,0 +1,286 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Dict, Type, Optional, get_origin
4
+
5
+ import httpx
6
+ from pydantic import TypeAdapter
7
+ from .apiKey import APIKey
8
+ from .exceptions import (APIConnectionError, APIResponseValidationError,
9
+ APIStatusError, APITimeoutError)
10
+ from .resource import Resource
11
+ from .model import ResponseModels
12
+ from .endpoint import PP
13
+ from .retry import RetryConfig
14
+ from .query import Query
15
+ from .type import DEFAULT_TIMEOUT, JSON, HTTPMethod
16
+ from .url import URL
17
+ from .util import DataManager, Headersx
18
+ from .rateLimit import RateLimiter
19
+ from .logger import logger
20
+
21
+
22
+ class Client:
23
+ """Async, resource-oriented API client for interacting with RESTful APIs.
24
+
25
+ Organizes endpoints into a tree of Resources, accessed via dot notation
26
+ (e.g. ``client.coins.coin(...)``). Handles authentication, rate limiting,
27
+ retries, and response parsing automatically.
28
+
29
+ Args:
30
+ url: Base URL of the API (e.g. ``"https://api.example.com/v1/"``).
31
+ apiKey: Optional API key configuration for authentication.
32
+ timeout: Request timeout. Defaults to 30s overall, 5s connect.
33
+ rateLimit: Optional sliding-window rate limiter.
34
+ retry: Retry configuration with exponential backoff. Defaults to 3 attempts.
35
+ headers: Additional headers merged into every request.
36
+ debug: Enable debug logging for requests, bindings, and responses.
37
+
38
+ Usage::
39
+
40
+ async with Client(url="https://api.example.com/v1/") as client:
41
+ client.add(coinsResource)
42
+ coin = await client.coins.coin(parameters=CoinParams.Bitcoin)
43
+ """
44
+ _logger = logger.debug
45
+
46
+
47
+ def __init__(self,
48
+ url: str,
49
+ apiKey: APIKey | None = None,
50
+ timeout: httpx.Timeout = DEFAULT_TIMEOUT,
51
+ rateLimit: RateLimiter | None = None,
52
+ retry: RetryConfig | None = None,
53
+ headers: httpx.Headers | None = None,
54
+ debug: bool = False):
55
+ self.debug: bool = debug
56
+ logger.configure() if self.debug else None
57
+ self.url = URL.normalizeBaseURL(url)
58
+ self.apiKey = apiKey
59
+ self.retry = retry or RetryConfig()
60
+ self.timeout = timeout
61
+ self.rateLimit = rateLimit
62
+ self.headers: httpx.Headers = httpx.Headers({**Headersx.default()})
63
+ self.headers.update(headers) if headers else {}
64
+
65
+ self.apiKey.util.allInHeader(self.headers) if self.apiKey else None
66
+
67
+ self.client = httpx.AsyncClient(
68
+ base_url=self.url,
69
+ timeout=self.timeout,
70
+ headers=self.headers
71
+ # event_hooks=HttpEventHooks.EventHooks()
72
+ )
73
+ self.unsetNestedData: bool = True
74
+ self._resources: Dict[str, Resource] = {}
75
+
76
+
77
+ def add(self, resource: Resource | list[Resource]) -> None:
78
+ """Register one or more resources with this client.
79
+
80
+ Each resource is bound to the client and becomes accessible as an
81
+ attribute (e.g. ``client.coins``). Raises ``ValueError`` if a
82
+ resource with the same name is already registered.
83
+
84
+ Args:
85
+ resource: A single Resource or a list of Resources to register.
86
+ """
87
+ _resources = resource if isinstance(resource, list) else [resource]
88
+
89
+ for r in _resources:
90
+ if r.name in self._resources:
91
+ raise ValueError(f"Resource {r.name} already exists")
92
+ r._bind(self)
93
+ self._resources[r.name] = r
94
+ setattr(self, r.name, r)
95
+ self._logger(f"[Client +] Resource->{r.name}") if self.debug else None
96
+
97
+ def __getattr__(self, item: str) -> Any:
98
+ resources = self.__dict__.get("_resources")
99
+ if resources is None:
100
+ raise AttributeError(item)
101
+ if item in resources:
102
+ return resources[item]
103
+ raise AttributeError(item)
104
+
105
+ async def _sendRequest(self,
106
+ url: str,
107
+ path: str,
108
+ pathPrefix: str,
109
+ response: Optional[Type[ResponseModels]] = None,
110
+ query: Query | None = None,
111
+ method: HTTPMethod | str = 'GET',
112
+ auth: bool = False,
113
+ strictValidation: bool = False) -> ResponseModels | JSON:
114
+ """Send an HTTP request and parse the response.
115
+
116
+ Handles the full request lifecycle: authentication injection, rate limiting,
117
+ request building, response parsing, and retry logic.
118
+
119
+ List vs dict responses are auto-detected from the ``response`` type using
120
+ ``get_origin()``. If ``response`` is ``list[Model]``, the raw JSON array
121
+ is validated directly. Otherwise, nested ``data`` keys are unwrapped and
122
+ API metadata is injected into the parsed ResponseModel.
123
+
124
+ Args:
125
+ url: The full request path (prefix + endpoint path).
126
+ path: The endpoint-specific path segment.
127
+ pathPrefix: The resource prefix path.
128
+ response: The Pydantic model type for response parsing. Pass
129
+ ``list[Model]`` for array responses, or a single ``ResponseModel``
130
+ subclass for object responses. ``None`` returns raw JSON.
131
+ query: Optional query parameters.
132
+ method: HTTP method (default ``GET``).
133
+ auth: Whether this request requires authentication.
134
+ strictValidation: Enable strict Pydantic validation mode.
135
+
136
+ Returns:
137
+ Parsed model instance, list of models, or raw JSON dict.
138
+
139
+ Raises:
140
+ APIStatusError: On 4xx/5xx responses (subclassed by status code).
141
+ APITimeoutError: When all retry attempts time out.
142
+ APIConnectionError: When all retry attempts fail to connect.
143
+ APIResponseValidationError: When response data doesn't match the model.
144
+ """
145
+
146
+ url = URL.normalizePath(url)
147
+ reqHeaders = self.headers
148
+ rateLimit = self.rateLimit
149
+ queries = query if query else Query({})
150
+
151
+
152
+ if (self.apiKey and self.apiKey.scope == "All") and auth:
153
+ self.apiKey.util.allInQuery(queries) if self.apiKey.scheme == "Query" else None
154
+
155
+ if self.apiKey and self.apiKey.scope == "Endpoint":
156
+ self.apiKey.util.endpointInQuery(queries, auth) if self.apiKey.scheme == "Query" else None
157
+
158
+ queries = queries.toQueryParams()
159
+ request = self.client.build_request(method, url, params=queries, headers=reqHeaders)
160
+
161
+ for attempt in range(self.retry.attempts):
162
+ try:
163
+ if rateLimit:
164
+ await rateLimit.acquire()
165
+ request = self.client.build_request(
166
+ method,
167
+ url,
168
+ params=queries,
169
+ headers=reqHeaders,
170
+ )
171
+
172
+ resp = await self.client.send(request)
173
+
174
+ apiData = DataManager.buildAPIReponseData(
175
+ elapsed=resp.elapsed.total_seconds(),
176
+ method=method,
177
+ url=self.url + pathPrefix + path,
178
+ path=path,
179
+ pathPrefix=pathPrefix,
180
+ authScope=self.apiKey.scope if self.apiKey else "Disabled",
181
+ query=query.activeQueries if query else None)
182
+
183
+ logger.http(f"{method} /{pathPrefix + path} {resp.elapsed.total_seconds():.2f}s") if self.debug else None
184
+
185
+ if resp.status_code >= 400:
186
+ body = None
187
+ try:
188
+ body = resp.json()
189
+ except Exception:
190
+ body = resp.text
191
+
192
+ message = f"HTTP {resp.status_code}: {resp.reason_phrase} {resp.url} {resp.headers.get_list}"
193
+ raise APIStatusError.withStatus(message, response=resp, body=body)
194
+ logger.http(f"{resp}") if self.debug else None
195
+ rawData = resp.json()
196
+
197
+ responseIsList = get_origin(response) is list
198
+
199
+ if responseIsList:
200
+ parsedData = rawData
201
+ else:
202
+ parsedData: dict[
203
+ str,
204
+ Any] = rawData if not self.unsetNestedData else DataManager.unsetNestData(
205
+ rawData)
206
+
207
+ if response is None:
208
+ return parsedData
209
+
210
+ try:
211
+ if responseIsList:
212
+ adapter = TypeAdapter(response)
213
+ parsed = adapter.validate_python(parsedData, strict=strictValidation)
214
+ else:
215
+ parsedData.update({"api": apiData})
216
+ adapter = TypeAdapter(response)
217
+ parsed = adapter.validate_python(parsedData, strict=strictValidation)
218
+ except Exception as err:
219
+ raise APIResponseValidationError(
220
+ response=resp,
221
+ body=parsedData,
222
+ message=f"Failed to parse response: {err}") from err
223
+ return parsed
224
+
225
+ except httpx.TimeoutException as err:
226
+ if attempt == self.retry.attempts - 1:
227
+ raise APITimeoutError(request=request) from err
228
+ await self.retry.sleep(attempt)
229
+
230
+ except APIStatusError as err:
231
+ if err.status_code < 500:
232
+ raise # 4xx errors — don't retry
233
+ if attempt == self.retry.attempts - 1:
234
+ raise
235
+ await self.retry.sleep(attempt)
236
+
237
+ except httpx.ConnectError as err:
238
+ if attempt == self.retry.attempts - 1:
239
+ raise APIConnectionError(request=request) from err
240
+
241
+ await self.retry.sleep(attempt)
242
+
243
+ except Exception:
244
+ if attempt == self.retry.attempts - 1:
245
+ raise
246
+ await self.retry.sleep(attempt)
247
+
248
+ raise RuntimeError("Request failed")
249
+
250
+ async def _endpointRequest(self,
251
+ id: str,
252
+ path: str,
253
+ prefix: str,
254
+ method: HTTPMethod | str,
255
+ auth: bool,
256
+ parameters: Optional[PP | list[PP]] = None,
257
+ query: Query | None = None,
258
+ model: Optional[Type[ResponseModels]] = None,
259
+ strictValidation: bool = False):
260
+ """Internal bridge between Resource.prepareRequest and _sendRequest.
261
+
262
+ Assembles the full URL from prefix + path and delegates to _sendRequest.
263
+ """
264
+ url = prefix + path
265
+ return await self._sendRequest(url=url,
266
+ path=path,
267
+ pathPrefix=prefix,
268
+ response=model,
269
+ query=query,
270
+ method=method,
271
+ auth=auth,
272
+ strictValidation=strictValidation)
273
+
274
+ async def __aenter__(self) -> Client:
275
+ return self
276
+
277
+ async def __aexit__(self, exc_type, exc_val, exc_tb) -> None:
278
+ await self.client.aclose()
279
+
280
+ async def close(self) -> None:
281
+ """Close the underlying HTTP connection pool.
282
+
283
+ Called automatically when using the client as an async context manager.
284
+ """
285
+ self._logger("[Client] Closing client") if self.debug else None
286
+ await self.client.aclose()
xapi/endpoint.py ADDED
@@ -0,0 +1,128 @@
1
+ from __future__ import annotations
2
+ from typing import (Protocol, TypeVar, Optional, Type, TYPE_CHECKING)
3
+ from dataclasses import dataclass, field
4
+ from .path import Path, Parameters
5
+ from .type import HTTPMethod
6
+ from .logger import logger
7
+ from .model import ResponseModel, ResponseModels
8
+ from .exceptions import BindingError
9
+ import shortuuid
10
+ if TYPE_CHECKING:
11
+ from .client import Client
12
+ from .resource import Resource
13
+
14
+ logger.configure()
15
+
16
+ R = TypeVar("R", bound="ResponseModel", covariant=True)
17
+ PP = TypeVar("PP", bound="Parameters", contravariant=True)
18
+
19
+ class EndpointCall(Protocol[R, PP]):
20
+ """Protocol for callable endpoint objects."""
21
+
22
+ async def call(self, parameters: Optional[PP | list[PP]] = None, **kwargs):
23
+ ...
24
+
25
+ @dataclass
26
+ class Endpoint[R, PP]:
27
+ """A single API endpoint definition.
28
+
29
+ Represents one callable API operation (e.g. ``GET /coins/{id}``). Endpoints
30
+ are attached to a Resource and become callable via dot notation on the client.
31
+
32
+ The ``response`` type determines how the API response is parsed:
33
+ - ``ResponseModel`` subclass: parsed as a single object, with API metadata
34
+ injected into the ``api`` field.
35
+ - ``list[BaseModel]``: parsed as a JSON array of models (auto-detected).
36
+ - ``None``: returns raw JSON dict without parsing.
37
+
38
+ Args:
39
+ name: Identifier used as the Python attribute name on the resource.
40
+ path: Path object defining the URL template and parameter requirements.
41
+ nameOverride: Optional display name for the endpoint (defaults to ``name``).
42
+ response: Pydantic model type for response parsing. Use ``list[Model]`` for
43
+ array responses.
44
+ method: HTTP method. Defaults to ``GET``.
45
+ strict: Enable strict Pydantic validation mode.
46
+ debug: Enable debug logging for this endpoint.
47
+ """
48
+
49
+ __id__: str = field(init=False)
50
+ __client__: Optional[Client] = field(init=False, default=None)
51
+ __boundResource__: Optional[Resource] = field(init=False, default=None)
52
+ __prefix__: str = field(init=False, default="")
53
+ __apiName__: str = field(init=False, default="")
54
+ _requireAuth: bool = field(init=False, default=False)
55
+ name: str
56
+ path: Path[PP]
57
+ nameOverride: str = ""
58
+ response: Optional[Type[ResponseModels]] = None
59
+ method: HTTPMethod = "GET"
60
+ strict: bool = False
61
+
62
+ debug: bool = False
63
+
64
+ def __post_init__(self):
65
+ self.__id__ = str(shortuuid.uuid(name=self.name+self.path.endpointPath))
66
+ self.__apiName__ = self.nameOverride if self.nameOverride != "" else self.name
67
+
68
+ def _bind(self, resource: Resource, prefix: str = "") -> None:
69
+ self.__boundResource__ = resource
70
+ self.debug = self.__boundResource__.debug
71
+ self._requireAuth = self.__boundResource__._requireAuth
72
+ logger.debug(f"[Bind] {resource.name}<-{self.name}") if self.debug else None
73
+
74
+ @property
75
+ def id(self) -> str:
76
+ return self.__id__
77
+
78
+ def _getPath(self, params: Optional[PP | list[PP]] = None) -> str:
79
+ if params is not None and self.path.requiresParameters:
80
+ return self.path.path(params=params)
81
+ return self.path.path()
82
+
83
+ async def call(self, parameters: Optional[PP | list[PP]] = None, **kwargs):
84
+ """Execute this endpoint's API call.
85
+
86
+ Resolves the URL path with any provided parameters, then delegates
87
+ to the bound resource to send the request through the client.
88
+
89
+ Args:
90
+ parameters: Path parameter values to inject into the URL template.
91
+ **kwargs: Additional options. Pass ``query=Query(...)`` to include
92
+ query parameters.
93
+
94
+ Returns:
95
+ Parsed response model, list of models, or raw JSON dict.
96
+
97
+ Raises:
98
+ BindingError: If this endpoint hasn't been added to a resource.
99
+ """
100
+ if not self.__boundResource__:
101
+ raise BindingError(self.name, "Resource")
102
+
103
+ if self.path.requiresParameters and parameters:
104
+ path = self._getPath(parameters)
105
+ else:
106
+ path = self._getPath()
107
+
108
+ if not self.__boundResource__._pathProtection:
109
+ pFix = self.__boundResource__._prefix
110
+ else:
111
+ pFix = self.__boundResource__._prefix
112
+
113
+ queries = kwargs.get("query")
114
+
115
+ result = await self.__boundResource__.prepareRequest(
116
+ id=self.__id__,
117
+ path=path,
118
+ prefix=pFix,
119
+ method=self.method,
120
+ auth=self._requireAuth,
121
+ model=self.response,
122
+ strictValidation=self.strict,
123
+ query=queries,
124
+ )
125
+ return result
126
+
127
+
128
+ type Endpoints[R, PP] = Endpoint[R, PP]