lorebase-client 0.2.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.
- lorebase_client/__init__.py +86 -0
- lorebase_client/aclient.py +1585 -0
- lorebase_client/client.py +1587 -0
- lorebase_client/config.py +160 -0
- lorebase_client/core.py +330 -0
- lorebase_client/endpoints.py +1292 -0
- lorebase_client/errors.py +242 -0
- lorebase_client/models.py +1662 -0
- lorebase_client/py.typed +0 -0
- lorebase_client-0.2.3.dist-info/METADATA +7 -0
- lorebase_client-0.2.3.dist-info/RECORD +12 -0
- lorebase_client-0.2.3.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
"""Typed exceptions raised for failed requests.
|
|
2
|
+
|
|
3
|
+
Every Keeper error is a problem detail (RFC 9457, media type `application/problem+json`).
|
|
4
|
+
`error_from_response` maps one to a `LorebaseError` subclass: first by the problem `code`
|
|
5
|
+
(`stale-head`, `protected-branch`, ...), then by HTTP status. Members beyond the standard ones
|
|
6
|
+
stay available in `extra` (`issues`, `errors`, `current_head`, `permission`, ...) and through
|
|
7
|
+
the convenience properties.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import time
|
|
14
|
+
from collections.abc import Mapping
|
|
15
|
+
from email.utils import parsedate_to_datetime
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
import httpx
|
|
19
|
+
|
|
20
|
+
_STANDARD = frozenset(
|
|
21
|
+
{"type", "title", "status", "detail", "instance", "code", "request_id", "trace_id"}
|
|
22
|
+
)
|
|
23
|
+
_SNIPPET_CHARS = 300
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class LorebaseError(Exception):
|
|
27
|
+
"""Base class of every error raised by the client."""
|
|
28
|
+
|
|
29
|
+
def __init__(
|
|
30
|
+
self,
|
|
31
|
+
message: str,
|
|
32
|
+
*,
|
|
33
|
+
status: int | None = None,
|
|
34
|
+
type: str | None = None,
|
|
35
|
+
title: str | None = None,
|
|
36
|
+
detail: str | None = None,
|
|
37
|
+
code: str | None = None,
|
|
38
|
+
instance: str | None = None,
|
|
39
|
+
request_id: str | None = None,
|
|
40
|
+
trace_id: str | None = None,
|
|
41
|
+
extra: Mapping[str, Any] | None = None,
|
|
42
|
+
retry_after: float | None = None,
|
|
43
|
+
) -> None:
|
|
44
|
+
super().__init__(message)
|
|
45
|
+
self.message = message
|
|
46
|
+
self.status = status
|
|
47
|
+
self.type = type
|
|
48
|
+
self.title = title
|
|
49
|
+
self.detail = detail
|
|
50
|
+
self.code = code
|
|
51
|
+
self.instance = instance
|
|
52
|
+
self.request_id = request_id
|
|
53
|
+
self.trace_id = trace_id
|
|
54
|
+
self.extra: dict[str, Any] = dict(extra or {})
|
|
55
|
+
self.retry_after = retry_after
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def issues(self) -> list[dict[str, Any]]:
|
|
59
|
+
"""Validation findings of a rejected write (`issues`), each with code, path, message."""
|
|
60
|
+
return list(self.extra.get("issues") or [])
|
|
61
|
+
|
|
62
|
+
@property
|
|
63
|
+
def errors(self) -> list[dict[str, Any]]:
|
|
64
|
+
"""Invalid request members (`errors`): `{pointer, message, code}` each."""
|
|
65
|
+
return list(self.extra.get("errors") or [])
|
|
66
|
+
|
|
67
|
+
@property
|
|
68
|
+
def current_head(self) -> str | None:
|
|
69
|
+
"""Branch head reported by a `stale-head` conflict."""
|
|
70
|
+
value = self.extra.get("current_head")
|
|
71
|
+
return None if value is None else str(value)
|
|
72
|
+
|
|
73
|
+
def __str__(self) -> str:
|
|
74
|
+
if self.request_id:
|
|
75
|
+
return f"{self.message} (request {self.request_id})"
|
|
76
|
+
return self.message
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class LorebaseConnectionError(LorebaseError):
|
|
80
|
+
"""The server could not be reached or the connection failed (no HTTP response)."""
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class LorebaseConfigError(LorebaseError):
|
|
84
|
+
"""The client is not configured (for example no base URL)."""
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class ApiError(LorebaseError):
|
|
88
|
+
"""The server answered with an error status."""
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class BadRequestError(ApiError):
|
|
92
|
+
"""400."""
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class AuthenticationError(ApiError):
|
|
96
|
+
"""401: no valid credential."""
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class PermissionDeniedError(ApiError):
|
|
100
|
+
"""403: `extra['permission']` names the missing permission."""
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class NotFoundError(ApiError):
|
|
104
|
+
"""404."""
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class ConflictError(ApiError):
|
|
108
|
+
"""409: the state of the server does not allow the request."""
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class StaleHeadError(ConflictError):
|
|
112
|
+
"""409 `stale-head`: the branch moved; `current_head` is where it is now."""
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class ProtectedBranchError(ConflictError):
|
|
116
|
+
"""409 `protected-branch`: changes reach the branch through a Proposal."""
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class ReviewRequiredError(ConflictError):
|
|
120
|
+
"""409 `review-required`: the change needs a Proposal."""
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class AlreadyExistsError(ConflictError):
|
|
124
|
+
"""409 `already-exists`."""
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class IdempotencyConflictError(ConflictError):
|
|
128
|
+
"""409 `idempotency-conflict`: the key was used with other parameters."""
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class PayloadTooLargeError(ApiError):
|
|
132
|
+
"""413."""
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
class ValidationFailedError(ApiError):
|
|
136
|
+
"""422: invalid request (`errors`) or data that breaks the schema (`issues`)."""
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
class RateLimitedError(ApiError):
|
|
140
|
+
"""429; `retry_after` holds the wait the server asked for, in seconds."""
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
class ServerError(ApiError):
|
|
144
|
+
"""5xx."""
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
class UnavailableError(ServerError):
|
|
148
|
+
"""503."""
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
class BranchBusyError(UnavailableError):
|
|
152
|
+
"""503 `branch-busy`: another writer holds the branch; retry later."""
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
_BY_CODE: dict[str, type[ApiError]] = {
|
|
156
|
+
"stale-head": StaleHeadError,
|
|
157
|
+
"protected-branch": ProtectedBranchError,
|
|
158
|
+
"review-required": ReviewRequiredError,
|
|
159
|
+
"already-exists": AlreadyExistsError,
|
|
160
|
+
"idempotency-conflict": IdempotencyConflictError,
|
|
161
|
+
"branch-busy": BranchBusyError,
|
|
162
|
+
}
|
|
163
|
+
_BY_STATUS: dict[int, type[ApiError]] = {
|
|
164
|
+
400: BadRequestError,
|
|
165
|
+
401: AuthenticationError,
|
|
166
|
+
403: PermissionDeniedError,
|
|
167
|
+
404: NotFoundError,
|
|
168
|
+
409: ConflictError,
|
|
169
|
+
413: PayloadTooLargeError,
|
|
170
|
+
422: ValidationFailedError,
|
|
171
|
+
429: RateLimitedError,
|
|
172
|
+
503: UnavailableError,
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def parse_retry_after(value: str | None, *, now: float | None = None) -> float | None:
|
|
177
|
+
"""Seconds to wait from a `Retry-After` header (delta-seconds or HTTP date)."""
|
|
178
|
+
if not value:
|
|
179
|
+
return None
|
|
180
|
+
value = value.strip()
|
|
181
|
+
if value.isdigit():
|
|
182
|
+
return float(value)
|
|
183
|
+
try:
|
|
184
|
+
moment = parsedate_to_datetime(value)
|
|
185
|
+
except (TypeError, ValueError):
|
|
186
|
+
return None
|
|
187
|
+
return max(0.0, moment.timestamp() - (time.time() if now is None else now))
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _problem_of(response: httpx.Response) -> dict[str, Any] | None:
|
|
191
|
+
try:
|
|
192
|
+
body = json.loads(response.content)
|
|
193
|
+
except ValueError:
|
|
194
|
+
return None
|
|
195
|
+
return body if isinstance(body, dict) and "status" in body else None
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def is_problem(response: httpx.Response) -> bool:
|
|
199
|
+
"""True when the response is a problem document (so the server itself rejected the call)."""
|
|
200
|
+
return _problem_of(response) is not None
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def error_from_response(response: httpx.Response) -> ApiError:
|
|
204
|
+
"""The exception for an error response."""
|
|
205
|
+
status = response.status_code
|
|
206
|
+
retry_after = parse_retry_after(response.headers.get("retry-after"))
|
|
207
|
+
problem = _problem_of(response)
|
|
208
|
+
if problem is None:
|
|
209
|
+
snippet = response.text.strip()[:_SNIPPET_CHARS]
|
|
210
|
+
reason = response.reason_phrase or "error"
|
|
211
|
+
message = f"HTTP {status} {reason}" + (f": {snippet}" if snippet else "")
|
|
212
|
+
return _class_for(None, status)(
|
|
213
|
+
message, status=status, title=reason, detail=snippet or None, retry_after=retry_after
|
|
214
|
+
)
|
|
215
|
+
code = _text(problem.get("code"))
|
|
216
|
+
detail = _text(problem.get("detail"))
|
|
217
|
+
title = _text(problem.get("title"))
|
|
218
|
+
return _class_for(code, status)(
|
|
219
|
+
detail or title or f"HTTP {status}",
|
|
220
|
+
status=status,
|
|
221
|
+
type=_text(problem.get("type")),
|
|
222
|
+
title=title,
|
|
223
|
+
detail=detail,
|
|
224
|
+
code=code,
|
|
225
|
+
instance=_text(problem.get("instance")),
|
|
226
|
+
request_id=_text(problem.get("request_id")),
|
|
227
|
+
trace_id=_text(problem.get("trace_id")),
|
|
228
|
+
extra={k: v for k, v in problem.items() if k not in _STANDARD},
|
|
229
|
+
retry_after=retry_after,
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _text(value: object) -> str | None:
|
|
234
|
+
return value if isinstance(value, str) else None
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _class_for(code: str | None, status: int) -> type[ApiError]:
|
|
238
|
+
if code is not None and code in _BY_CODE:
|
|
239
|
+
return _BY_CODE[code]
|
|
240
|
+
if status in _BY_STATUS:
|
|
241
|
+
return _BY_STATUS[status]
|
|
242
|
+
return ServerError if status >= 500 else ApiError
|