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.
@@ -0,0 +1,160 @@
1
+ """Client configuration: constructor arguments, then environment variables, then defaults.
2
+
3
+ | Argument | Environment variable | Default |
4
+ |----------|----------------------|---------|
5
+ | `base_url` | `LOREBASE_URL` | required |
6
+ | `api_key` | `LOREBASE_API_KEY` | none |
7
+ | `token` | `LOREBASE_TOKEN` | none |
8
+ | `timeout` | `LOREBASE_TIMEOUT` (seconds) | `DEFAULT_TIMEOUT_S` |
9
+ | `api_prefix` | `LOREBASE_API_PREFIX` | `DEFAULT_API_PREFIX` |
10
+ | `session_id`, `tool`, `trace` | none | none (agent trace headers) |
11
+
12
+ An API key (`lbk_...`) and an OIDC access token both travel as `Authorization: Bearer`; when
13
+ both are set the API key wins. `token` may be a callable returning the current token, called
14
+ for every request, so that a refreshing token source plugs in.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import os
20
+ import re
21
+ from collections.abc import Callable, Mapping
22
+ from dataclasses import dataclass, field
23
+ from typing import Literal
24
+
25
+ from lorebase_client.errors import LorebaseConfigError
26
+
27
+ ENV_URL = "LOREBASE_URL"
28
+ ENV_API_KEY = "LOREBASE_API_KEY"
29
+ ENV_TOKEN = "LOREBASE_TOKEN"
30
+ ENV_TIMEOUT = "LOREBASE_TIMEOUT"
31
+ ENV_API_PREFIX = "LOREBASE_API_PREFIX"
32
+
33
+ DEFAULT_API_PREFIX = "/api/v1"
34
+ DEFAULT_TIMEOUT_S = 30.0
35
+ DEFAULT_MAX_RETRIES = 3
36
+ DEFAULT_BACKOFF_BASE_S = 0.5
37
+ DEFAULT_BACKOFF_MAX_S = 30.0
38
+ DEFAULT_CACHE_ENTRIES = 256
39
+ RETRY_STATUSES = frozenset({429, 503})
40
+
41
+ SESSION_HEADER = "X-Lorebase-Session"
42
+ TOOL_HEADER = "X-Lorebase-Tool"
43
+ TRACE_HEADER = "X-Lorebase-Trace"
44
+ """Default names of the agent trace headers (Keeper can be configured with others; pass those
45
+ through `headers=`)."""
46
+ SESSION_ID_PATTERN = re.compile(r"[A-Za-z0-9._:-]{1,128}")
47
+
48
+ TokenSource = str | Callable[[], str]
49
+ TraceMode = Literal["on", "debug"]
50
+
51
+
52
+ def check_session_id(session_id: str) -> str:
53
+ """The session id, or `LorebaseConfigError` when it is not 1-128 of `A-Za-z0-9._:-`."""
54
+ if not SESSION_ID_PATTERN.fullmatch(session_id):
55
+ raise LorebaseConfigError("session_id must be 1-128 characters of A-Za-z0-9._:-")
56
+ return session_id
57
+
58
+
59
+ @dataclass(frozen=True)
60
+ class RetryPolicy:
61
+ """Retries on 429, 503 and connection failures, with exponential backoff.
62
+
63
+ The wait before attempt `n` (from 0) is `min(backoff_max_s, backoff_base_s * 2**n)`, or the
64
+ `Retry-After` of the response when there is one (capped at `backoff_max_s`). A random
65
+ share up to `jitter` of the computed wait is subtracted so that clients do not retry in step.
66
+ """
67
+
68
+ max_retries: int = DEFAULT_MAX_RETRIES
69
+ backoff_base_s: float = DEFAULT_BACKOFF_BASE_S
70
+ backoff_max_s: float = DEFAULT_BACKOFF_MAX_S
71
+ jitter: float = 0.25
72
+ statuses: frozenset[int] = RETRY_STATUSES
73
+
74
+
75
+ @dataclass(frozen=True)
76
+ class ClientConfig:
77
+ """Resolved settings of a client."""
78
+
79
+ base_url: str
80
+ api_prefix: str = DEFAULT_API_PREFIX
81
+ api_key: str | None = None
82
+ token: TokenSource | None = None
83
+ timeout_s: float = DEFAULT_TIMEOUT_S
84
+ retry: RetryPolicy = field(default_factory=RetryPolicy)
85
+ auto_idempotency_keys: bool = False
86
+ cache_entries: int | None = None
87
+ """Entries of the ETag cache; `None` leaves caching off."""
88
+ headers: Mapping[str, str] = field(default_factory=dict)
89
+ session_id: str | None = None
90
+ """Agent trace session sent as `X-Lorebase-Session` with every request."""
91
+ tool: str | None = None
92
+ """Tool name sent as `X-Lorebase-Tool`."""
93
+ trace: TraceMode | None = None
94
+ """`X-Lorebase-Trace` value: `on` opts requests in, `debug` adds query plans."""
95
+
96
+ @property
97
+ def root(self) -> str:
98
+ """URL every request path is appended to: server root plus API prefix."""
99
+ return self.base_url.rstrip("/") + self.api_prefix
100
+
101
+ def bearer(self) -> str | None:
102
+ """The credential to send, or `None` for anonymous requests."""
103
+ if self.api_key:
104
+ return self.api_key
105
+ if callable(self.token):
106
+ return self.token() or None
107
+ return self.token or None
108
+
109
+
110
+ def resolve_config(
111
+ base_url: str | None = None,
112
+ *,
113
+ api_key: str | None = None,
114
+ token: TokenSource | None = None,
115
+ timeout: float | None = None,
116
+ api_prefix: str | None = None,
117
+ retry: RetryPolicy | None = None,
118
+ auto_idempotency_keys: bool = False,
119
+ cache: bool | int = False,
120
+ headers: Mapping[str, str] | None = None,
121
+ session_id: str | None = None,
122
+ tool: str | None = None,
123
+ trace: TraceMode | None = None,
124
+ env: Mapping[str, str] | None = None,
125
+ ) -> ClientConfig:
126
+ """Merge arguments with the environment (`env`, default `os.environ`).
127
+
128
+ `cache` turns the ETag cache on (`True`: `DEFAULT_CACHE_ENTRIES` entries; an integer:
129
+ that many).
130
+ """
131
+ environment = os.environ if env is None else env
132
+ url = base_url or environment.get(ENV_URL)
133
+ if not url:
134
+ raise LorebaseConfigError(f"no server URL: pass base_url or set {ENV_URL}")
135
+ timeout_s = timeout
136
+ if timeout_s is None:
137
+ raw = environment.get(ENV_TIMEOUT)
138
+ try:
139
+ timeout_s = float(raw) if raw else DEFAULT_TIMEOUT_S
140
+ except ValueError:
141
+ raise LorebaseConfigError(f"{ENV_TIMEOUT} must be a number of seconds") from None
142
+ entries = None
143
+ if cache is True:
144
+ entries = DEFAULT_CACHE_ENTRIES
145
+ elif cache:
146
+ entries = int(cache)
147
+ return ClientConfig(
148
+ base_url=url,
149
+ api_prefix=api_prefix or environment.get(ENV_API_PREFIX) or DEFAULT_API_PREFIX,
150
+ api_key=api_key or environment.get(ENV_API_KEY) or None,
151
+ token=token or environment.get(ENV_TOKEN) or None,
152
+ timeout_s=timeout_s,
153
+ retry=retry or RetryPolicy(),
154
+ auto_idempotency_keys=auto_idempotency_keys,
155
+ cache_entries=entries,
156
+ headers=dict(headers or {}),
157
+ session_id=check_session_id(session_id) if session_id else None,
158
+ tool=tool or None,
159
+ trace=trace,
160
+ )
@@ -0,0 +1,330 @@
1
+ """The request layer shared by the sync and async clients.
2
+
3
+ It is sans-IO: a `Call` describes one API request; an `Exchange` turns it into `httpx` request
4
+ arguments and decides, for each outcome, whether the answer is final or the request is sent
5
+ again after a wait. The clients only run the loop (send, sleep) with their own I/O:
6
+
7
+ exchange = Exchange(core, call)
8
+ while True:
9
+ try:
10
+ response = http.send(http.build_request(**exchange.request_args()))
11
+ except httpx.TransportError as error:
12
+ step = exchange.on_error(error)
13
+ else:
14
+ step = exchange.on_response(response)
15
+ if isinstance(step, Done):
16
+ return step.value
17
+ sleep(step.delay)
18
+
19
+ Retries (policy in `config.RetryPolicy`):
20
+
21
+ - 429 and, for problem documents, 503 are retried for every request: the server refused them
22
+ before doing anything. A 503 that is not a problem document (a proxy) is retried only for
23
+ requests that are safe to repeat.
24
+ - Connection and pool failures, where the request was never sent, are retried for every request.
25
+ - Other transport failures (timeouts while reading, resets) are retried only for requests that
26
+ are safe to repeat: reads, deletes and writes carrying an idempotency key.
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import random
32
+ import re
33
+ import threading
34
+ import uuid
35
+ from collections import OrderedDict
36
+ from collections.abc import Callable, Mapping
37
+ from dataclasses import dataclass, field, replace
38
+ from typing import Any
39
+
40
+ import httpx
41
+ from pydantic import BaseModel
42
+
43
+ from lorebase_client.config import SESSION_HEADER, TOOL_HEADER, TRACE_HEADER, ClientConfig
44
+ from lorebase_client.errors import (
45
+ LorebaseConnectionError,
46
+ error_from_response,
47
+ is_problem,
48
+ parse_retry_after,
49
+ )
50
+
51
+ IDEMPOTENCY_HEADER = "Idempotency-Key"
52
+ _KEY_CHARS = re.compile(r"[^A-Za-z0-9._:-]")
53
+ _KEY_MAX = 255
54
+
55
+
56
+ def new_idempotency_key(prefix: str | None = None) -> str:
57
+ """A fresh key for a write, valid for the `Idempotency-Key` header.
58
+
59
+ Retrying a write with the same key returns the original result instead of repeating it.
60
+ """
61
+ token = uuid.uuid4().hex
62
+ if not prefix:
63
+ return token
64
+ return f"{_KEY_CHARS.sub('-', prefix)[: _KEY_MAX - len(token) - 1]}-{token}"
65
+
66
+
67
+ @dataclass(frozen=True)
68
+ class Call[T]:
69
+ """One API request and how to read its answer."""
70
+
71
+ method: str
72
+ path: str
73
+ parse: Callable[[httpx.Response], T]
74
+ params: Mapping[str, Any] = field(default_factory=dict)
75
+ body: Any = None
76
+ """JSON body."""
77
+ content: str | None = None
78
+ """Raw text body (used instead of `body`) with `content_type`."""
79
+ content_type: str | None = None
80
+ files: tuple[tuple[str, str, bytes], ...] | None = None
81
+ """Multipart upload (used instead of `body`): form field, file name and content."""
82
+ form: Mapping[str, str] = field(default_factory=dict)
83
+ """Text fields sent along with `files`."""
84
+ accept: str = "application/json"
85
+ safe: bool = False
86
+ """True when sending the request twice is harmless (reads, deletes)."""
87
+ accepts_key: bool = False
88
+ """True for writes that honor an `Idempotency-Key`."""
89
+ idempotency_key: str | None = None
90
+ cacheable: bool = False
91
+ """GET reads that carry an ETag; used by the opt-in cache."""
92
+
93
+ def with_params(self, **updates: Any) -> Call[T]:
94
+ """The same call with query parameters replaced (for example the page cursor)."""
95
+ return replace(self, params={**self.params, **updates})
96
+
97
+ def with_key(self, key: str | None) -> Call[T]:
98
+ return replace(self, idempotency_key=key)
99
+
100
+ @property
101
+ def repeatable(self) -> bool:
102
+ """Whether a request whose fate is unknown may be sent again."""
103
+ return self.safe or self.idempotency_key is not None
104
+
105
+
106
+ def parse_model[M: BaseModel](model: type[M]) -> Callable[[httpx.Response], M]:
107
+ """Parser of a JSON body into a response model."""
108
+ return lambda response: model.model_validate_json(response.content)
109
+
110
+
111
+ def parse_none(response: httpx.Response) -> None:
112
+ """Parser of answers without a body."""
113
+ return None
114
+
115
+
116
+ def parse_text(response: httpx.Response) -> str:
117
+ """Parser of text answers (the YAML schema)."""
118
+ return response.text
119
+
120
+
121
+ def parse_json(response: httpx.Response) -> Any:
122
+ """Parser of JSON answers kept as plain data."""
123
+ return response.json()
124
+
125
+
126
+ @dataclass(frozen=True)
127
+ class Done[T]:
128
+ """The final value of a call."""
129
+
130
+ value: T
131
+
132
+
133
+ @dataclass(frozen=True)
134
+ class Retry:
135
+ """Send the request again after `delay` seconds."""
136
+
137
+ delay: float
138
+
139
+
140
+ @dataclass(frozen=True)
141
+ class _Entry:
142
+ content: bytes
143
+ headers: tuple[tuple[str, str], ...]
144
+
145
+
146
+ class ETagCache:
147
+ """In-memory LRU of GET answers keyed by URL, revalidated with `If-None-Match`.
148
+
149
+ The server binds the `ETag` of a read to the Revision it was served from, so an entry stays
150
+ valid until the branch moves; the server answers 304 and the cached body is returned.
151
+ """
152
+
153
+ def __init__(self, max_entries: int) -> None:
154
+ self._max = max_entries
155
+ self._entries: OrderedDict[tuple[Any, ...], tuple[str, _Entry]] = OrderedDict()
156
+ self._lock = threading.Lock()
157
+
158
+ def __len__(self) -> int:
159
+ return len(self._entries)
160
+
161
+ def get(self, key: tuple[Any, ...]) -> tuple[str, _Entry] | None:
162
+ with self._lock:
163
+ found = self._entries.get(key)
164
+ if found is not None:
165
+ self._entries.move_to_end(key)
166
+ return found
167
+
168
+ def put(self, key: tuple[Any, ...], etag: str, response: httpx.Response) -> None:
169
+ entry = _Entry(response.content, tuple(response.headers.items()))
170
+ with self._lock:
171
+ self._entries[key] = (etag, entry)
172
+ self._entries.move_to_end(key)
173
+ while len(self._entries) > self._max:
174
+ self._entries.popitem(last=False)
175
+
176
+ def clear(self) -> None:
177
+ with self._lock:
178
+ self._entries.clear()
179
+
180
+
181
+ class RequestCore:
182
+ """Configuration, credentials, cache and clock of retries shared by one client."""
183
+
184
+ def __init__(self, config: ClientConfig, *, rng: Callable[[], float] = random.random) -> None:
185
+ self.config = config
186
+ self.cache = None if config.cache_entries is None else ETagCache(config.cache_entries)
187
+ self._rng = rng
188
+
189
+ def url(self, path: str) -> str:
190
+ return self.config.root + path
191
+
192
+ def headers(self, call: Call[Any]) -> dict[str, str]:
193
+ config = self.config
194
+ headers = {"Accept": call.accept, **config.headers}
195
+ for name, value in (
196
+ (SESSION_HEADER, config.session_id),
197
+ (TOOL_HEADER, config.tool),
198
+ (TRACE_HEADER, config.trace),
199
+ ):
200
+ if value:
201
+ headers[name] = value
202
+ bearer = self.config.bearer()
203
+ if bearer:
204
+ headers["Authorization"] = f"Bearer {bearer}"
205
+ if call.content_type:
206
+ headers["Content-Type"] = call.content_type
207
+ if call.idempotency_key:
208
+ headers[IDEMPOTENCY_HEADER] = call.idempotency_key
209
+ return headers
210
+
211
+ def delay(self, attempt: int, retry_after: float | None) -> float:
212
+ """Seconds to wait before retry number `attempt + 1`."""
213
+ policy = self.config.retry
214
+ if retry_after is not None:
215
+ return min(retry_after, policy.backoff_max_s)
216
+ computed = min(policy.backoff_max_s, policy.backoff_base_s * float(2**attempt))
217
+ return computed * (1.0 - policy.jitter * self._rng())
218
+
219
+
220
+ class Exchange[T]:
221
+ """The state of one call across its attempts."""
222
+
223
+ def __init__(self, core: RequestCore, call: Call[T]) -> None:
224
+ if call.accepts_key and call.idempotency_key is None and core.config.auto_idempotency_keys:
225
+ call = call.with_key(new_idempotency_key())
226
+ self._core = core
227
+ self.call = call
228
+ self._attempt = 0
229
+ self._cache_key = self._key() if self._uses_cache() else None
230
+
231
+ def _uses_cache(self) -> bool:
232
+ return self._core.cache is not None and self.call.cacheable and self.call.method == "GET"
233
+
234
+ def _key(self) -> tuple[Any, ...]:
235
+ params = tuple(sorted((k, str(v)) for k, v in _flatten(self.call.params) if v is not None))
236
+ return (self._core.url(self.call.path), self.call.accept, params)
237
+
238
+ def request_args(self) -> dict[str, Any]:
239
+ """Keyword arguments of `build_request`."""
240
+ call = self.call
241
+ headers = self._core.headers(call)
242
+ if self._cache_key is not None and self._core.cache is not None:
243
+ cached = self._core.cache.get(self._cache_key)
244
+ if cached is not None:
245
+ headers["If-None-Match"] = cached[0]
246
+ args: dict[str, Any] = {
247
+ "method": call.method,
248
+ "url": self._core.url(call.path),
249
+ "params": _query(call.params),
250
+ "headers": headers,
251
+ }
252
+ if call.files is not None:
253
+ args["files"] = [(name, (filename, data)) for name, filename, data in call.files]
254
+ if call.form:
255
+ args["data"] = dict(call.form)
256
+ elif call.content is not None:
257
+ args["content"] = call.content.encode()
258
+ elif call.body is not None:
259
+ args["json"] = call.body
260
+ return args
261
+
262
+ def on_response(self, response: httpx.Response) -> Done[T] | Retry:
263
+ """Final value, or a retry; raises the typed error for a final failure."""
264
+ status = response.status_code
265
+ if status == 304 and self._cache_key is not None and self._core.cache is not None:
266
+ cached = self._core.cache.get(self._cache_key)
267
+ if cached is not None:
268
+ replay = httpx.Response(
269
+ 200,
270
+ headers=list(cached[1].headers),
271
+ content=cached[1].content,
272
+ request=response.request,
273
+ )
274
+ return Done(self.call.parse(replay))
275
+ if status >= 400:
276
+ return self._failed(response)
277
+ if self._cache_key is not None and self._core.cache is not None:
278
+ etag = response.headers.get("etag")
279
+ if etag and status == 200:
280
+ self._core.cache.put(self._cache_key, etag, response)
281
+ return Done(self.call.parse(response))
282
+
283
+ def _failed(self, response: httpx.Response) -> Retry:
284
+ policy = self._core.config.retry
285
+ status = response.status_code
286
+ retryable = status in policy.statuses and (
287
+ status != 503 or self.call.repeatable or is_problem(response)
288
+ )
289
+ if retryable and self._attempt < policy.max_retries:
290
+ wait = self._core.delay(
291
+ self._attempt, parse_retry_after(response.headers.get("retry-after"))
292
+ )
293
+ self._attempt += 1
294
+ return Retry(wait)
295
+ raise error_from_response(response)
296
+
297
+ def on_error(self, error: httpx.TransportError) -> Retry:
298
+ """A retry for a failure that may be repeated; raises `LorebaseConnectionError` else."""
299
+ policy = self._core.config.retry
300
+ never_sent = isinstance(
301
+ error, httpx.ConnectError | httpx.ConnectTimeout | httpx.PoolTimeout
302
+ )
303
+ if (never_sent or self.call.repeatable) and self._attempt < policy.max_retries:
304
+ wait = self._core.delay(self._attempt, None)
305
+ self._attempt += 1
306
+ return Retry(wait)
307
+ text = str(error) or type(error).__name__
308
+ raise LorebaseConnectionError(
309
+ f"cannot reach the server at {self._core.config.base_url}: {text}"
310
+ ) from error
311
+
312
+
313
+ def _flatten(params: Mapping[str, Any]) -> list[tuple[str, Any]]:
314
+ out: list[tuple[str, Any]] = []
315
+ for name, value in params.items():
316
+ if isinstance(value, list | tuple):
317
+ out.extend((name, item) for item in value)
318
+ else:
319
+ out.append((name, value))
320
+ return out
321
+
322
+
323
+ def _query(params: Mapping[str, Any]) -> list[tuple[str, str]]:
324
+ """Query string members: unset ones dropped, booleans as `true` and `false`."""
325
+ out: list[tuple[str, str]] = []
326
+ for name, value in _flatten(params):
327
+ if value is None:
328
+ continue
329
+ out.append((name, "true" if value is True else "false" if value is False else str(value)))
330
+ return out