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/__init__.py +105 -0
- redblack/_generated/__init__.py +39 -0
- redblack/_generated/contract.py +212 -0
- redblack/_generated/operation_specs.py +5115 -0
- redblack/_version.py +4 -0
- redblack/auth.py +352 -0
- redblack/client.py +512 -0
- redblack/config.py +576 -0
- redblack/downloads.py +410 -0
- redblack/errors.py +483 -0
- redblack/models/__init__.py +105 -0
- redblack/models/base.py +150 -0
- redblack/models/generated.py +11736 -0
- redblack/odata.py +75 -0
- redblack/pagination.py +184 -0
- redblack/polling.py +163 -0
- redblack/py.typed +1 -0
- redblack/raw.py +102 -0
- redblack/resources/__init__.py +65 -0
- redblack/resources/_base.py +212 -0
- redblack/resources/_collections.py +80 -0
- redblack/resources/_exports.py +215 -0
- redblack/resources/accounts.py +132 -0
- redblack/resources/cash_actions.py +260 -0
- redblack/resources/equivalents.py +144 -0
- redblack/resources/households.py +138 -0
- redblack/resources/orders.py +118 -0
- redblack/resources/restrictions.py +146 -0
- redblack/resources/review_sets.py +889 -0
- redblack/resources/securities.py +175 -0
- redblack/resources/tasks.py +291 -0
- redblack/resources/users.py +88 -0
- redblack/response.py +118 -0
- redblack/transport.py +606 -0
- redblack_python_sdk-0.1.0.dist-info/METADATA +151 -0
- redblack_python_sdk-0.1.0.dist-info/RECORD +38 -0
- redblack_python_sdk-0.1.0.dist-info/WHEEL +5 -0
- redblack_python_sdk-0.1.0.dist-info/top_level.txt +1 -0
redblack/_version.py
ADDED
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
|
+
]
|