redblack-python-sdk 0.1.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.
redblack/_version.py ADDED
@@ -0,0 +1,4 @@
1
+ """Package and contract version constants."""
2
+
3
+ __version__ = "0.1.0"
4
+ SUPPORTED_API_VERSION = "2026.3.0"
redblack/auth.py ADDED
@@ -0,0 +1,352 @@
1
+ """Thread-safe in-memory authentication for the RedBlack API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import threading
7
+ import time
8
+ from collections.abc import Callable, Mapping
9
+ from dataclasses import dataclass, field
10
+ from math import isfinite
11
+ from typing import Protocol, runtime_checkable
12
+
13
+ import httpx
14
+
15
+ from ._version import __version__
16
+ from .config import PasswordCredentials, RedBlackConfig
17
+ from .errors import (
18
+ AuthenticationError,
19
+ ConfigurationError,
20
+ extract_request_id,
21
+ )
22
+
23
+ _DOCUMENTED_TOKEN_LIFETIME = 8 * 60 * 60
24
+
25
+
26
+ @runtime_checkable
27
+ class TokenProvider(Protocol):
28
+ """Minimal custom token-provider contract.
29
+
30
+ Built-in providers additionally expose ``invalidate`` and
31
+ ``is_token_expired``. The transport checks those capabilities without
32
+ requiring custom providers to implement them.
33
+ """
34
+
35
+ def get_token(self, *, force_refresh: bool = False) -> str:
36
+ """Return a usable bearer token."""
37
+ ...
38
+
39
+
40
+ @dataclass(frozen=True, slots=True, repr=False)
41
+ class StaticTokenProvider:
42
+ """Return a caller-managed token without persisting or refreshing it."""
43
+
44
+ access_token: str
45
+ client_id: str | None = None
46
+
47
+ def __post_init__(self) -> None:
48
+ if not isinstance(self.access_token, str) or not self.access_token.strip():
49
+ raise ConfigurationError("access_token must be a non-empty string")
50
+ if "\r" in self.access_token or "\n" in self.access_token:
51
+ raise ConfigurationError("access_token must not contain newlines")
52
+ if self.client_id is not None and (
53
+ not isinstance(self.client_id, str) or not self.client_id.strip()
54
+ ):
55
+ raise ConfigurationError("client_id must be a non-empty string when provided")
56
+
57
+ def get_token(self, *, force_refresh: bool = False) -> str:
58
+ del force_refresh
59
+ return self.access_token
60
+
61
+ def invalidate(self, token: str | None = None) -> None:
62
+ """Do nothing: lifecycle of a static token belongs to the caller."""
63
+
64
+ del token
65
+
66
+ def is_token_expired(self, token: str | None = None) -> bool:
67
+ del token
68
+ return False
69
+
70
+ def __repr__(self) -> str:
71
+ return (
72
+ "StaticTokenProvider(access_token=<redacted>, "
73
+ f"client_id={'<redacted>' if self.client_id is not None else None})"
74
+ )
75
+
76
+
77
+ @dataclass(frozen=True, slots=True, repr=False)
78
+ class _CachedToken:
79
+ value: str = field(repr=False)
80
+ expires_at: float
81
+ refresh_at: float
82
+
83
+
84
+ class PasswordGrantTokenProvider:
85
+ """Acquire and cache RedBlack password-grant tokens in memory.
86
+
87
+ The acquisition lock is deliberately held for the HTTP exchange. That
88
+ makes refresh single-flight: concurrent callers wait for and reuse the one
89
+ newly acquired token instead of consuming RedBlack's five-token allowance.
90
+ """
91
+
92
+ def __init__(
93
+ self,
94
+ config: RedBlackConfig,
95
+ credentials: PasswordCredentials | None = None,
96
+ *,
97
+ client: httpx.Client | None = None,
98
+ clock: Callable[[], float] = time.monotonic,
99
+ refresh_margin: float = 300.0,
100
+ fallback_lifetime: float = _DOCUMENTED_TOKEN_LIFETIME,
101
+ ) -> None:
102
+ resolved_credentials = credentials or config.credentials
103
+ if resolved_credentials is None:
104
+ raise ConfigurationError("PasswordGrantTokenProvider requires PasswordCredentials")
105
+ if not isinstance(resolved_credentials, PasswordCredentials):
106
+ raise ConfigurationError("credentials must be PasswordCredentials")
107
+ if (
108
+ isinstance(refresh_margin, bool)
109
+ or not isinstance(refresh_margin, int | float)
110
+ or not isfinite(refresh_margin)
111
+ or refresh_margin < 0
112
+ ):
113
+ raise ConfigurationError("refresh_margin must be non-negative")
114
+ if (
115
+ isinstance(fallback_lifetime, bool)
116
+ or not isinstance(fallback_lifetime, int | float)
117
+ or not isfinite(fallback_lifetime)
118
+ or fallback_lifetime <= 0
119
+ ):
120
+ raise ConfigurationError("fallback_lifetime must be positive")
121
+
122
+ self._config = config
123
+ self._credentials = resolved_credentials
124
+ self._clock = clock
125
+ self._refresh_margin = float(refresh_margin)
126
+ self._fallback_lifetime = float(fallback_lifetime)
127
+ self._lock = threading.Lock()
128
+ self._cached: _CachedToken | None = None
129
+ self._closed = False
130
+ self._owns_client = client is None
131
+ self._timeout = httpx.Timeout(
132
+ connect=config.timeout.connect,
133
+ read=config.timeout.read,
134
+ write=config.timeout.write,
135
+ pool=config.timeout.pool,
136
+ )
137
+ if client is None:
138
+ client = httpx.Client(
139
+ timeout=self._timeout,
140
+ verify=config.verify,
141
+ proxy=config.proxy,
142
+ trust_env=config.trust_env,
143
+ follow_redirects=False,
144
+ headers={"User-Agent": f"redblack-python-sdk/{__version__}"},
145
+ )
146
+ self._client = client
147
+
148
+ @property
149
+ def client_id(self) -> str:
150
+ """The same authoritative client ID used by resource headers."""
151
+
152
+ return self._config.client_id
153
+
154
+ def get_token(self, *, force_refresh: bool = False) -> str:
155
+ """Return a cached token or acquire exactly one replacement."""
156
+
157
+ now = self._clock()
158
+ cached = self._cached
159
+ if not force_refresh and cached is not None and now < cached.refresh_at:
160
+ return cached.value
161
+
162
+ with self._lock:
163
+ if self._closed:
164
+ raise AuthenticationError("token provider is closed")
165
+ now = self._clock()
166
+ cached = self._cached
167
+ if not force_refresh and cached is not None and now < cached.refresh_at:
168
+ return cached.value
169
+ replacement = self._request_token(now)
170
+ self._cached = replacement
171
+ return replacement.value
172
+
173
+ def invalidate(self, token: str | None = None) -> None:
174
+ """Invalidate only the token that failed, preserving a newer refresh."""
175
+
176
+ with self._lock:
177
+ if self._cached is not None and (token is None or self._cached.value == token):
178
+ self._cached = None
179
+
180
+ def refresh_after_failure(self, failed_token: str | None) -> str:
181
+ """Refresh once unless another caller already replaced the failed token."""
182
+
183
+ with self._lock:
184
+ if self._closed:
185
+ raise AuthenticationError("token provider is closed")
186
+ now = self._clock()
187
+ cached = self._cached
188
+ if (
189
+ failed_token is not None
190
+ and cached is not None
191
+ and cached.value != failed_token
192
+ and now < cached.refresh_at
193
+ ):
194
+ return cached.value
195
+ replacement = self._request_token(now)
196
+ self._cached = replacement
197
+ return replacement.value
198
+
199
+ def is_token_expired(self, token: str | None = None) -> bool:
200
+ """Return known local expiry, not a guess based only on HTTP 403."""
201
+
202
+ cached = self._cached
203
+ if cached is None or (token is not None and cached.value != token):
204
+ return False
205
+ return self._clock() >= cached.expires_at
206
+
207
+ def close(self) -> None:
208
+ with self._lock:
209
+ if self._closed:
210
+ return
211
+ self._closed = True
212
+ self._cached = None
213
+ if self._owns_client:
214
+ self._client.close()
215
+
216
+ def __enter__(self) -> PasswordGrantTokenProvider:
217
+ return self
218
+
219
+ def __exit__(self, *exc_info: object) -> None:
220
+ self.close()
221
+
222
+ def __repr__(self) -> str:
223
+ return (
224
+ "PasswordGrantTokenProvider("
225
+ "client_id=<redacted>, credentials=<redacted>, "
226
+ f"has_cached_token={self._cached is not None}, closed={self._closed})"
227
+ )
228
+
229
+ def _request_token(self, now: float) -> _CachedToken:
230
+ url = f"{self._config.base_url}/auth/v1/token"
231
+ headers = {
232
+ "Accept-Version": self._config.api_version,
233
+ "X-Api-Key": self._config.api_key,
234
+ "Accept": "application/json",
235
+ }
236
+ form = {
237
+ "client_id": self._config.client_id,
238
+ "client_secret": self._credentials.client_secret,
239
+ "username": self._credentials.username,
240
+ "password": self._credentials.password,
241
+ "grant_type": "password",
242
+ "scope": "openid",
243
+ }
244
+ secrets = (
245
+ self._config.api_key,
246
+ self._config.client_id,
247
+ self._credentials.client_secret,
248
+ self._credentials.username,
249
+ self._credentials.password,
250
+ )
251
+ try:
252
+ response = self._client.post(
253
+ url,
254
+ headers=headers,
255
+ data=form,
256
+ timeout=self._timeout,
257
+ follow_redirects=False,
258
+ )
259
+ except httpx.TimeoutException as exc:
260
+ raise AuthenticationError("RedBlack token request timed out", secrets=secrets) from exc
261
+ except httpx.TransportError as exc:
262
+ raise AuthenticationError(
263
+ "RedBlack token request failed before receiving a response",
264
+ secrets=secrets,
265
+ ) from exc
266
+
267
+ if response.status_code != 200:
268
+ error, description = _oauth_error_fields(response)
269
+ raise AuthenticationError(
270
+ "RedBlack rejected the token request",
271
+ error=error,
272
+ error_description=description,
273
+ status_code=response.status_code,
274
+ request_id=extract_request_id(response.headers),
275
+ secrets=secrets,
276
+ )
277
+
278
+ try:
279
+ payload = response.json()
280
+ except (json.JSONDecodeError, UnicodeDecodeError) as exc:
281
+ raise AuthenticationError(
282
+ "RedBlack token response was not valid JSON",
283
+ status_code=response.status_code,
284
+ request_id=extract_request_id(response.headers),
285
+ ) from exc
286
+ if not isinstance(payload, Mapping):
287
+ raise AuthenticationError(
288
+ "RedBlack token response must be a JSON object",
289
+ status_code=response.status_code,
290
+ request_id=extract_request_id(response.headers),
291
+ )
292
+ token = payload.get("access_token")
293
+ if not isinstance(token, str) or not token.strip():
294
+ raise AuthenticationError(
295
+ "RedBlack token response did not contain a usable access_token",
296
+ status_code=response.status_code,
297
+ request_id=extract_request_id(response.headers),
298
+ )
299
+
300
+ lifetime = _parse_expires_in(payload.get("expires_in"), self._fallback_lifetime)
301
+ # A short-lived test token should not become instantly stale merely
302
+ # because the normal eight-hour safety margin is larger than its life.
303
+ effective_margin = min(self._refresh_margin, lifetime * 0.5)
304
+ expires_at = now + lifetime
305
+ return _CachedToken(
306
+ value=token,
307
+ expires_at=expires_at,
308
+ refresh_at=expires_at - effective_margin,
309
+ )
310
+
311
+
312
+ def _oauth_error_fields(response: httpx.Response) -> tuple[str | None, str | None]:
313
+ try:
314
+ payload = response.json()
315
+ except (json.JSONDecodeError, UnicodeDecodeError):
316
+ return None, None
317
+ if not isinstance(payload, Mapping):
318
+ return None, None
319
+ error = payload.get("error")
320
+ description = payload.get("error_description")
321
+ return (
322
+ error if isinstance(error, str) else None,
323
+ description if isinstance(description, str) else None,
324
+ )
325
+
326
+
327
+ def _parse_expires_in(value: object, fallback: float) -> float:
328
+ if isinstance(value, bool):
329
+ return fallback
330
+ if (
331
+ isinstance(value, int | float)
332
+ and not isinstance(value, bool)
333
+ and isfinite(value)
334
+ and value > 0
335
+ ):
336
+ return float(value)
337
+ if isinstance(value, str):
338
+ try:
339
+ parsed = float(value)
340
+ except ValueError:
341
+ return fallback
342
+ if isfinite(parsed) and parsed > 0:
343
+ return parsed
344
+ return fallback
345
+
346
+
347
+ __all__ = [
348
+ "PasswordCredentials",
349
+ "PasswordGrantTokenProvider",
350
+ "StaticTokenProvider",
351
+ "TokenProvider",
352
+ ]