qtsurfer-sdk 0.2.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.
- qtsurfer_sdk/__init__.py +42 -0
- qtsurfer_sdk/_errors.py +71 -0
- qtsurfer_sdk/_session.py +369 -0
- qtsurfer_sdk/_tokens.py +51 -0
- qtsurfer_sdk/_workflows.py +477 -0
- qtsurfer_sdk-0.2.0.dist-info/METADATA +285 -0
- qtsurfer_sdk-0.2.0.dist-info/RECORD +9 -0
- qtsurfer_sdk-0.2.0.dist-info/WHEEL +4 -0
- qtsurfer_sdk-0.2.0.dist-info/licenses/LICENSE +201 -0
qtsurfer_sdk/__init__.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""QTSurfer Python SDK.
|
|
2
|
+
|
|
3
|
+
Public surface::
|
|
4
|
+
|
|
5
|
+
from qtsurfer_sdk import auth, AuthenticatedSession, TokenStore, InMemoryTokenStore
|
|
6
|
+
from qtsurfer_sdk import QTSAuthError, QTSError
|
|
7
|
+
|
|
8
|
+
The :func:`auth` helper is the recommended entry point: one call exchanges
|
|
9
|
+
a long-lived API key for a short-lived JWT and returns an
|
|
10
|
+
:class:`AuthenticatedSession` that transparently refreshes the JWT on 401.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from qtsurfer_sdk._errors import (
|
|
14
|
+
QTSAuthError,
|
|
15
|
+
QTSCanceledError,
|
|
16
|
+
QTSCompileError,
|
|
17
|
+
QTSDownloadError,
|
|
18
|
+
QTSError,
|
|
19
|
+
QTSExecutionError,
|
|
20
|
+
QTSPreparationError,
|
|
21
|
+
QTSTimeoutError,
|
|
22
|
+
QTSUploadError,
|
|
23
|
+
)
|
|
24
|
+
from qtsurfer_sdk._session import APIKEY_ENV_VAR, AuthenticatedSession, auth
|
|
25
|
+
from qtsurfer_sdk._tokens import InMemoryTokenStore, TokenStore
|
|
26
|
+
|
|
27
|
+
__all__ = [
|
|
28
|
+
"APIKEY_ENV_VAR",
|
|
29
|
+
"AuthenticatedSession",
|
|
30
|
+
"InMemoryTokenStore",
|
|
31
|
+
"QTSAuthError",
|
|
32
|
+
"QTSCanceledError",
|
|
33
|
+
"QTSCompileError",
|
|
34
|
+
"QTSDownloadError",
|
|
35
|
+
"QTSError",
|
|
36
|
+
"QTSExecutionError",
|
|
37
|
+
"QTSPreparationError",
|
|
38
|
+
"QTSTimeoutError",
|
|
39
|
+
"QTSUploadError",
|
|
40
|
+
"TokenStore",
|
|
41
|
+
"auth",
|
|
42
|
+
]
|
qtsurfer_sdk/_errors.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""SDK error hierarchy.
|
|
2
|
+
|
|
3
|
+
Mirrors the conventions used by the Java / TypeScript SDKs so error
|
|
4
|
+
handling looks idiomatic across the three languages:
|
|
5
|
+
|
|
6
|
+
* ``QTSError`` is the catch-all base.
|
|
7
|
+
* ``QTSAuthError`` covers missing-apikey and JWT-exchange failures.
|
|
8
|
+
* ``QTSUploadError`` covers credential-free dataset uploads.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class QTSError(Exception):
|
|
15
|
+
"""Root of the QTSurfer SDK exception hierarchy.
|
|
16
|
+
|
|
17
|
+
Carries an optional HTTP status code (when the underlying transport
|
|
18
|
+
surfaced one) to make refresh-on-401 and custom error handling easy.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
def __init__(
|
|
22
|
+
self,
|
|
23
|
+
message: str,
|
|
24
|
+
*,
|
|
25
|
+
status: int | None = None,
|
|
26
|
+
cause: BaseException | None = None,
|
|
27
|
+
) -> None:
|
|
28
|
+
super().__init__(message)
|
|
29
|
+
self.status = status
|
|
30
|
+
if cause is not None:
|
|
31
|
+
self.__cause__ = cause
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class QTSAuthError(QTSError):
|
|
35
|
+
"""Raised when ``auth()`` cannot obtain a JWT.
|
|
36
|
+
|
|
37
|
+
Triggered by:
|
|
38
|
+
|
|
39
|
+
* No API key supplied (neither argument nor ``QTSURFER_APIKEY`` env var).
|
|
40
|
+
* ``POST /v1/auth/token`` returned a 4xx (typically 401 — invalid /
|
|
41
|
+
revoked / expired apikey).
|
|
42
|
+
* The endpoint returned a 2xx but the body was empty or undecodeable.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class QTSCompileError(QTSError):
|
|
47
|
+
"""Raised when ``POST /strategy`` cannot compile/register a strategy."""
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class QTSPreparationError(QTSError):
|
|
51
|
+
"""Raised when preparing market data (``prepare_backtest``) fails."""
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class QTSExecutionError(QTSError):
|
|
55
|
+
"""Raised when a backtest/sweep execution fails or errors mid-run."""
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class QTSTimeoutError(QTSError):
|
|
59
|
+
"""Raised when a poll loop reaches its deadline before a job resolves."""
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class QTSCanceledError(QTSError):
|
|
63
|
+
"""Raised when an in-flight run is canceled (or the caller aborts)."""
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class QTSDownloadError(QTSError):
|
|
67
|
+
"""Raised when downloading tickers/klines fails."""
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class QTSUploadError(QTSError):
|
|
71
|
+
"""Raised when a direct dataset upload fails without exposing its presigned URL."""
|
qtsurfer_sdk/_session.py
ADDED
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
"""``auth(apikey)`` helper and :class:`AuthenticatedSession`.
|
|
2
|
+
|
|
3
|
+
One-call setup: exchange a long-lived API key for a short-lived JWT,
|
|
4
|
+
returning a session that transparently refreshes the JWT on 401 and
|
|
5
|
+
lets callers plug in a custom :class:`TokenStore`.
|
|
6
|
+
|
|
7
|
+
Mirrors the surface of the Java and TypeScript SDKs so adopters get the
|
|
8
|
+
same ergonomics across languages.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import os
|
|
14
|
+
import threading
|
|
15
|
+
from collections.abc import Callable
|
|
16
|
+
from typing import TypeVar
|
|
17
|
+
|
|
18
|
+
from qtsurfer.api.client._generated import AuthenticatedClient as _GenAuthClient
|
|
19
|
+
from qtsurfer.api.client._generated.api.auth.authenticate import (
|
|
20
|
+
sync_detailed as _auth_sync_detailed,
|
|
21
|
+
)
|
|
22
|
+
from qtsurfer.api.client._generated.models import AuthTokenError, AuthTokenResponse
|
|
23
|
+
from qtsurfer.api.client._generated.types import Response
|
|
24
|
+
|
|
25
|
+
from qtsurfer_sdk._errors import QTSAuthError
|
|
26
|
+
from qtsurfer_sdk._tokens import InMemoryTokenStore, TokenStore
|
|
27
|
+
|
|
28
|
+
APIKEY_ENV_VAR = "QTSURFER_APIKEY"
|
|
29
|
+
DEFAULT_BASE_URL = "https://api.qtsurfer.com/v1"
|
|
30
|
+
|
|
31
|
+
T = TypeVar("T")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class AuthenticatedSession:
|
|
35
|
+
"""Authenticated SDK session.
|
|
36
|
+
|
|
37
|
+
Returned by :func:`auth`. Owns a JWT (in memory by default, or in
|
|
38
|
+
the provided :class:`TokenStore`), exposes the underlying
|
|
39
|
+
``qtsurfer-api-client`` ``AuthenticatedClient`` for typed API calls,
|
|
40
|
+
and transparently re-exchanges the API key for a fresh JWT when a
|
|
41
|
+
call returns 401.
|
|
42
|
+
|
|
43
|
+
Refresh policy: one shot — refresh once, retry the wrapped call
|
|
44
|
+
once, surface the error if the retry also 401s. No loops.
|
|
45
|
+
|
|
46
|
+
Thread-safety: :meth:`refresh` is guarded by an internal lock so
|
|
47
|
+
concurrent callers piggy-back on a single in-flight mint.
|
|
48
|
+
|
|
49
|
+
The returned :attr:`client` is the *same* object whose ``token``
|
|
50
|
+
attribute is mutated on refresh, so any in-flight code holding a
|
|
51
|
+
reference to it continues to see the latest JWT.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
def __init__(
|
|
55
|
+
self,
|
|
56
|
+
apikey: str,
|
|
57
|
+
*,
|
|
58
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
59
|
+
store: TokenStore | None = None,
|
|
60
|
+
) -> None:
|
|
61
|
+
self._apikey = apikey
|
|
62
|
+
self.base_url = base_url
|
|
63
|
+
self.store: TokenStore = store if store is not None else InMemoryTokenStore()
|
|
64
|
+
self._lock = threading.RLock()
|
|
65
|
+
self._cached: AuthTokenResponse | None = None
|
|
66
|
+
# The mint client carries the apikey in the X-API-Key header (the
|
|
67
|
+
# generated client treats `token` as the value of `auth_header_name`,
|
|
68
|
+
# with an empty prefix giving us a raw `X-API-Key: <apikey>` header).
|
|
69
|
+
self._mint_client = _GenAuthClient(
|
|
70
|
+
base_url=base_url,
|
|
71
|
+
token=apikey,
|
|
72
|
+
prefix="",
|
|
73
|
+
auth_header_name="X-API-Key",
|
|
74
|
+
)
|
|
75
|
+
# The session client carries the JWT — populated on first auth.
|
|
76
|
+
# Use an empty placeholder token until `ensure_token` runs; the
|
|
77
|
+
# auth_header_name + prefix defaults give `Authorization: Bearer ...`.
|
|
78
|
+
self.client = _GenAuthClient(base_url=base_url, token="")
|
|
79
|
+
|
|
80
|
+
# ---- Token lifecycle ----
|
|
81
|
+
|
|
82
|
+
@property
|
|
83
|
+
def token(self) -> AuthTokenResponse | None:
|
|
84
|
+
"""Currently cached token, or ``None`` if no exchange has happened."""
|
|
85
|
+
return self._cached
|
|
86
|
+
|
|
87
|
+
def refresh(self) -> AuthTokenResponse:
|
|
88
|
+
"""Force a fresh JWT exchange. Bypasses the cache; writes the new
|
|
89
|
+
token to :attr:`store` and updates :attr:`client` so subsequent
|
|
90
|
+
calls carry the new Bearer header.
|
|
91
|
+
"""
|
|
92
|
+
with self._lock:
|
|
93
|
+
resp: Response[AuthTokenResponse | AuthTokenError | None] = _auth_sync_detailed(
|
|
94
|
+
client=self._mint_client
|
|
95
|
+
)
|
|
96
|
+
if resp.status_code != 200 or not isinstance(resp.parsed, AuthTokenResponse):
|
|
97
|
+
raise QTSAuthError(
|
|
98
|
+
f"auth() failed: HTTP {resp.status_code}",
|
|
99
|
+
status=int(resp.status_code),
|
|
100
|
+
)
|
|
101
|
+
self._cached = resp.parsed
|
|
102
|
+
self.store.save(resp.parsed)
|
|
103
|
+
self._apply_bearer(resp.parsed.access_token)
|
|
104
|
+
return resp.parsed
|
|
105
|
+
|
|
106
|
+
def ensure_token(self) -> AuthTokenResponse:
|
|
107
|
+
"""Return the cached token, seeding from :attr:`store` on first
|
|
108
|
+
use and minting a new one if neither cache nor store hold one.
|
|
109
|
+
"""
|
|
110
|
+
if self._cached is not None:
|
|
111
|
+
return self._cached
|
|
112
|
+
stored = self.store.load()
|
|
113
|
+
if stored is not None:
|
|
114
|
+
self._cached = stored
|
|
115
|
+
self._apply_bearer(stored.access_token)
|
|
116
|
+
return stored
|
|
117
|
+
return self.refresh()
|
|
118
|
+
|
|
119
|
+
def clear(self) -> None:
|
|
120
|
+
"""Drop the cached token (in memory and in the store)."""
|
|
121
|
+
with self._lock:
|
|
122
|
+
self._cached = None
|
|
123
|
+
self.store.clear()
|
|
124
|
+
self._apply_bearer("")
|
|
125
|
+
|
|
126
|
+
def _apply_bearer(self, token: str) -> None:
|
|
127
|
+
"""Update both the api-client's ``token`` attribute *and* the live
|
|
128
|
+
httpx.Client's headers, so a refresh swaps the Bearer header even
|
|
129
|
+
after the client has been used (the api-client builds the header
|
|
130
|
+
lazily on first ``get_httpx_client()`` and caches the dict — without
|
|
131
|
+
this, refresh would only land on a brand-new client).
|
|
132
|
+
"""
|
|
133
|
+
self.client.token = token
|
|
134
|
+
# Use the private accessor (no public one in the generated client).
|
|
135
|
+
httpx_client = getattr(self.client, "_client", None)
|
|
136
|
+
if httpx_client is not None:
|
|
137
|
+
if token:
|
|
138
|
+
httpx_client.headers["Authorization"] = f"Bearer {token}"
|
|
139
|
+
else:
|
|
140
|
+
httpx_client.headers.pop("Authorization", None)
|
|
141
|
+
|
|
142
|
+
# ---- Refresh-on-401 plumbing ----
|
|
143
|
+
|
|
144
|
+
def call(self, fn: Callable[[_GenAuthClient], T]) -> T:
|
|
145
|
+
"""Run ``fn(self.client)`` once, refresh-on-401 once on failure.
|
|
146
|
+
|
|
147
|
+
``fn`` receives the session's underlying ``AuthenticatedClient``
|
|
148
|
+
(its ``token`` attribute is kept in sync on every refresh). If
|
|
149
|
+
the call returns a ``Response`` whose ``status_code`` is 401, or
|
|
150
|
+
raises an exception that surfaces a 401, the SDK refreshes the
|
|
151
|
+
JWT once and re-invokes ``fn`` exactly once.
|
|
152
|
+
|
|
153
|
+
A second 401 is surfaced to the caller as-is — no retry loops.
|
|
154
|
+
"""
|
|
155
|
+
self.ensure_token()
|
|
156
|
+
try:
|
|
157
|
+
result = fn(self.client)
|
|
158
|
+
except Exception as exc:
|
|
159
|
+
if not _is_unauthorized(exc):
|
|
160
|
+
raise
|
|
161
|
+
self._cached = None
|
|
162
|
+
self.refresh()
|
|
163
|
+
return fn(self.client)
|
|
164
|
+
if _result_is_unauthorized(result):
|
|
165
|
+
self._cached = None
|
|
166
|
+
self.refresh()
|
|
167
|
+
return fn(self.client)
|
|
168
|
+
return result
|
|
169
|
+
|
|
170
|
+
# ---- High-level workflows (parity with sdk-java / sdk-ts) ----
|
|
171
|
+
|
|
172
|
+
def list_exchanges(self):
|
|
173
|
+
"""List the exchanges the platform serves."""
|
|
174
|
+
from qtsurfer_sdk import _workflows as wf
|
|
175
|
+
|
|
176
|
+
return wf.list_exchanges(self)
|
|
177
|
+
|
|
178
|
+
def list_instruments(self, exchange_id: str, segment: str | None = None):
|
|
179
|
+
"""List an exchange's instruments, optionally for a segment."""
|
|
180
|
+
from qtsurfer_sdk import _workflows as wf
|
|
181
|
+
|
|
182
|
+
return wf.list_instruments(self, exchange_id, segment)
|
|
183
|
+
|
|
184
|
+
def compile_strategy(self, source: str):
|
|
185
|
+
"""Compile and register a Java strategy."""
|
|
186
|
+
from qtsurfer_sdk import _workflows as wf
|
|
187
|
+
|
|
188
|
+
return wf.compile_strategy(self, source)
|
|
189
|
+
|
|
190
|
+
def validate_strategy(self, strategy_id: str):
|
|
191
|
+
from qtsurfer_sdk import _workflows as wf
|
|
192
|
+
|
|
193
|
+
return wf.validate_strategy(self, strategy_id)
|
|
194
|
+
|
|
195
|
+
def get_strategy(self, strategy_id: str):
|
|
196
|
+
from qtsurfer_sdk import _workflows as wf
|
|
197
|
+
|
|
198
|
+
return wf.get_strategy(self, strategy_id)
|
|
199
|
+
|
|
200
|
+
def list_strategies(self):
|
|
201
|
+
from qtsurfer_sdk import _workflows as wf
|
|
202
|
+
|
|
203
|
+
return wf.list_strategies(self)
|
|
204
|
+
|
|
205
|
+
def delete_strategy(self, strategy_id: str):
|
|
206
|
+
from qtsurfer_sdk import _workflows as wf
|
|
207
|
+
|
|
208
|
+
return wf.delete_strategy(self, strategy_id)
|
|
209
|
+
|
|
210
|
+
def get_strategy_code(self, strategy_id: str) -> str:
|
|
211
|
+
from qtsurfer_sdk import _workflows as wf
|
|
212
|
+
|
|
213
|
+
return wf.get_strategy_code(self, strategy_id)
|
|
214
|
+
|
|
215
|
+
def prepare(self, **kwargs):
|
|
216
|
+
from qtsurfer_sdk import _workflows as wf
|
|
217
|
+
|
|
218
|
+
return wf.prepare(self, **kwargs)
|
|
219
|
+
|
|
220
|
+
def get_prepare_status(self, **kwargs):
|
|
221
|
+
from qtsurfer_sdk import _workflows as wf
|
|
222
|
+
|
|
223
|
+
return wf.get_prepare_status(self, **kwargs)
|
|
224
|
+
|
|
225
|
+
def execute(self, **kwargs):
|
|
226
|
+
from qtsurfer_sdk import _workflows as wf
|
|
227
|
+
|
|
228
|
+
return wf.execute(self, **kwargs)
|
|
229
|
+
|
|
230
|
+
def get_backtest_result(self, **kwargs):
|
|
231
|
+
from qtsurfer_sdk import _workflows as wf
|
|
232
|
+
|
|
233
|
+
return wf.get_backtest_result(self, **kwargs)
|
|
234
|
+
|
|
235
|
+
def cancel_backtest(self, **kwargs):
|
|
236
|
+
from qtsurfer_sdk import _workflows as wf
|
|
237
|
+
|
|
238
|
+
return wf.cancel_backtest(self, **kwargs)
|
|
239
|
+
|
|
240
|
+
def sweep(self, **kwargs):
|
|
241
|
+
from qtsurfer_sdk import _workflows as wf
|
|
242
|
+
|
|
243
|
+
return wf.sweep(self, **kwargs)
|
|
244
|
+
|
|
245
|
+
def get_sweep_result(self, **kwargs):
|
|
246
|
+
from qtsurfer_sdk import _workflows as wf
|
|
247
|
+
|
|
248
|
+
return wf.get_sweep_result(self, **kwargs)
|
|
249
|
+
|
|
250
|
+
def get_sweep_sensitivity(self, **kwargs):
|
|
251
|
+
from qtsurfer_sdk import _workflows as wf
|
|
252
|
+
|
|
253
|
+
return wf.get_sweep_sensitivity(self, **kwargs)
|
|
254
|
+
|
|
255
|
+
def get_sweep_run_equity_curve(self, **kwargs):
|
|
256
|
+
from qtsurfer_sdk import _workflows as wf
|
|
257
|
+
|
|
258
|
+
return wf.get_sweep_run_equity_curve(self, **kwargs)
|
|
259
|
+
|
|
260
|
+
def cancel_sweep(self, **kwargs):
|
|
261
|
+
from qtsurfer_sdk import _workflows as wf
|
|
262
|
+
|
|
263
|
+
return wf.cancel_sweep(self, **kwargs)
|
|
264
|
+
|
|
265
|
+
def create_dataset(self, **kwargs):
|
|
266
|
+
from qtsurfer_sdk import _workflows as wf
|
|
267
|
+
|
|
268
|
+
return wf.create_dataset(self, **kwargs)
|
|
269
|
+
|
|
270
|
+
def list_datasets(self):
|
|
271
|
+
from qtsurfer_sdk import _workflows as wf
|
|
272
|
+
|
|
273
|
+
return wf.list_datasets(self)
|
|
274
|
+
|
|
275
|
+
def get_dataset(self, dataset_id: str):
|
|
276
|
+
from qtsurfer_sdk import _workflows as wf
|
|
277
|
+
|
|
278
|
+
return wf.get_dataset(self, dataset_id)
|
|
279
|
+
|
|
280
|
+
def delete_dataset(self, dataset_id: str):
|
|
281
|
+
from qtsurfer_sdk import _workflows as wf
|
|
282
|
+
|
|
283
|
+
return wf.delete_dataset(self, dataset_id)
|
|
284
|
+
|
|
285
|
+
def open_dataset_upload(self, dataset_id: str):
|
|
286
|
+
"""Open the dataset's pending upload session or its next version's session."""
|
|
287
|
+
from qtsurfer_sdk import _workflows as wf
|
|
288
|
+
|
|
289
|
+
return wf.open_dataset_upload(self, dataset_id)
|
|
290
|
+
|
|
291
|
+
def upload_dataset_file(self, upload, source):
|
|
292
|
+
"""PUT a local binary source to a dataset upload session's presigned URL."""
|
|
293
|
+
from qtsurfer_sdk import _workflows as wf
|
|
294
|
+
|
|
295
|
+
return wf.upload_dataset_file(upload, source)
|
|
296
|
+
|
|
297
|
+
def finalize_dataset_upload(self, **kwargs):
|
|
298
|
+
from qtsurfer_sdk import _workflows as wf
|
|
299
|
+
|
|
300
|
+
return wf.finalize_dataset_upload(self, **kwargs)
|
|
301
|
+
|
|
302
|
+
def get_dataset_upload(self, **kwargs):
|
|
303
|
+
from qtsurfer_sdk import _workflows as wf
|
|
304
|
+
|
|
305
|
+
return wf.get_dataset_upload(self, **kwargs)
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def auth(
|
|
309
|
+
apikey: str | None = None,
|
|
310
|
+
*,
|
|
311
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
312
|
+
store: TokenStore | None = None,
|
|
313
|
+
) -> AuthenticatedSession:
|
|
314
|
+
"""Exchange a long-lived API key for an authenticated session.
|
|
315
|
+
|
|
316
|
+
If ``apikey`` is ``None`` or empty, the SDK reads
|
|
317
|
+
``QTSURFER_APIKEY`` from the environment. The returned
|
|
318
|
+
:class:`AuthenticatedSession` caches the JWT (in memory by default,
|
|
319
|
+
or in the provided :class:`TokenStore`), exposes the underlying
|
|
320
|
+
``qtsurfer-api-client`` ``AuthenticatedClient`` via
|
|
321
|
+
:attr:`AuthenticatedSession.client`, and transparently refreshes
|
|
322
|
+
the JWT on 401 via :meth:`AuthenticatedSession.call`.
|
|
323
|
+
|
|
324
|
+
:param apikey: Long-lived API key. When omitted, read from
|
|
325
|
+
``QTSURFER_APIKEY``.
|
|
326
|
+
:param base_url: API base URL. Defaults to
|
|
327
|
+
``https://api.qtsurfer.com/v1``.
|
|
328
|
+
:param store: Custom :class:`TokenStore`. Defaults to
|
|
329
|
+
:class:`InMemoryTokenStore`.
|
|
330
|
+
:raises QTSAuthError: when no apikey is available, or the initial JWT
|
|
331
|
+
exchange fails.
|
|
332
|
+
"""
|
|
333
|
+
resolved = _resolve_apikey(apikey)
|
|
334
|
+
session = AuthenticatedSession(resolved, base_url=base_url, store=store)
|
|
335
|
+
session.ensure_token()
|
|
336
|
+
return session
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def _resolve_apikey(explicit: str | None) -> str:
|
|
340
|
+
if explicit and explicit.strip():
|
|
341
|
+
return explicit
|
|
342
|
+
env = os.environ.get(APIKEY_ENV_VAR)
|
|
343
|
+
if env and env.strip():
|
|
344
|
+
return env
|
|
345
|
+
raise QTSAuthError(f"auth() requires an apikey (argument or {APIKEY_ENV_VAR} env var)")
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def _result_is_unauthorized(result: object) -> bool:
|
|
349
|
+
"""Detect a 401 across the response types callers commonly return.
|
|
350
|
+
|
|
351
|
+
* The generated ``qtsurfer-api-client`` ``Response`` (``*_detailed``).
|
|
352
|
+
* Raw ``httpx.Response`` (when callers reach into ``client.get_httpx_client()``).
|
|
353
|
+
* Any other object exposing a numeric ``status_code`` attribute.
|
|
354
|
+
"""
|
|
355
|
+
status = getattr(result, "status_code", None)
|
|
356
|
+
return status == 401
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def _is_unauthorized(exc: BaseException) -> bool:
|
|
360
|
+
status = getattr(exc, "status_code", None)
|
|
361
|
+
if status == 401:
|
|
362
|
+
return True
|
|
363
|
+
status = getattr(exc, "status", None)
|
|
364
|
+
if status == 401:
|
|
365
|
+
return True
|
|
366
|
+
cause = exc.__cause__
|
|
367
|
+
if cause is not None and getattr(cause, "status_code", None) == 401:
|
|
368
|
+
return True
|
|
369
|
+
return False
|
qtsurfer_sdk/_tokens.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Pluggable token persistence."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Protocol, runtime_checkable
|
|
6
|
+
|
|
7
|
+
from qtsurfer.api.client._generated.models import AuthTokenResponse
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@runtime_checkable
|
|
11
|
+
class TokenStore(Protocol):
|
|
12
|
+
"""Pluggable token persistence strategy for :class:`AuthenticatedSession`.
|
|
13
|
+
|
|
14
|
+
The SDK ships an :class:`InMemoryTokenStore` as the default. Adopters
|
|
15
|
+
implement this Protocol (or subclass it) to back tokens by an on-disk
|
|
16
|
+
file, a secret manager, etc. The SDK calls :meth:`load` once per
|
|
17
|
+
session-startup to seed any previously cached token, :meth:`save`
|
|
18
|
+
after every successful ``auth()`` / refresh, and :meth:`clear` when
|
|
19
|
+
the session is explicitly invalidated.
|
|
20
|
+
|
|
21
|
+
Implementations are expected to be thread-safe if used from multiple
|
|
22
|
+
threads.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
def load(self) -> AuthTokenResponse | None:
|
|
26
|
+
"""Return the persisted token, or :data:`None` if none."""
|
|
27
|
+
|
|
28
|
+
def save(self, token: AuthTokenResponse) -> None:
|
|
29
|
+
"""Persist the token returned by ``POST /v1/auth/token``."""
|
|
30
|
+
|
|
31
|
+
def clear(self) -> None:
|
|
32
|
+
"""Drop any persisted token."""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class InMemoryTokenStore:
|
|
36
|
+
"""Default :class:`TokenStore` — holds the most recent token in a
|
|
37
|
+
single in-memory slot. Lost on process exit. Sufficient for
|
|
38
|
+
short-lived scripts and for tests.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
def __init__(self) -> None:
|
|
42
|
+
self._token: AuthTokenResponse | None = None
|
|
43
|
+
|
|
44
|
+
def load(self) -> AuthTokenResponse | None:
|
|
45
|
+
return self._token
|
|
46
|
+
|
|
47
|
+
def save(self, token: AuthTokenResponse) -> None:
|
|
48
|
+
self._token = token
|
|
49
|
+
|
|
50
|
+
def clear(self) -> None:
|
|
51
|
+
self._token = None
|