destiny_sdk 0.6.0__tar.gz → 0.7.2__tar.gz
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.
- {destiny_sdk-0.6.0 → destiny_sdk-0.7.2}/.gitignore +2 -0
- {destiny_sdk-0.6.0 → destiny_sdk-0.7.2}/PKG-INFO +2 -1
- {destiny_sdk-0.6.0 → destiny_sdk-0.7.2}/pyproject.toml +2 -1
- destiny_sdk-0.7.2/src/destiny_sdk/client.py +601 -0
- {destiny_sdk-0.6.0 → destiny_sdk-0.7.2}/src/destiny_sdk/core.py +7 -0
- {destiny_sdk-0.6.0 → destiny_sdk-0.7.2}/src/destiny_sdk/enhancements.py +58 -19
- {destiny_sdk-0.6.0 → destiny_sdk-0.7.2}/src/destiny_sdk/identifiers.py +83 -5
- destiny_sdk-0.7.2/src/destiny_sdk/parsers/eppi_parser.py +284 -0
- destiny_sdk-0.7.2/src/destiny_sdk/parsers/exceptions.py +17 -0
- {destiny_sdk-0.6.0 → destiny_sdk-0.7.2}/src/destiny_sdk/search.py +6 -1
- destiny_sdk-0.7.2/tests/unit/parsers/test_eppi_parser.py +228 -0
- destiny_sdk-0.7.2/tests/unit/test_client.py +426 -0
- destiny_sdk-0.7.2/tests/unit/test_data/eppi_import.jsonl +4 -0
- destiny_sdk-0.7.2/tests/unit/test_data/eppi_import_with_annotations.jsonl +4 -0
- destiny_sdk-0.7.2/tests/unit/test_data/eppi_import_with_raw.jsonl +4 -0
- {destiny_sdk-0.6.0 → destiny_sdk-0.7.2}/tests/unit/test_data/eppi_report.json +6 -1
- {destiny_sdk-0.6.0 → destiny_sdk-0.7.2}/tests/unit/test_enhancements.py +48 -0
- {destiny_sdk-0.6.0 → destiny_sdk-0.7.2}/tests/unit/test_identifiers.py +27 -0
- {destiny_sdk-0.6.0 → destiny_sdk-0.7.2}/tests/unit/test_references.py +2 -1
- {destiny_sdk-0.6.0 → destiny_sdk-0.7.2}/uv.lock +233 -0
- destiny_sdk-0.6.0/src/destiny_sdk/client.py +0 -142
- destiny_sdk-0.6.0/src/destiny_sdk/parsers/eppi_parser.py +0 -172
- destiny_sdk-0.6.0/tests/unit/parsers/test_eppi_parser.py +0 -47
- destiny_sdk-0.6.0/tests/unit/test_client.py +0 -73
- destiny_sdk-0.6.0/tests/unit/test_data/eppi_import.jsonl +0 -4
- destiny_sdk-0.6.0/tests/unit/test_data/eppi_import_with_annotations.jsonl +0 -4
- {destiny_sdk-0.6.0 → destiny_sdk-0.7.2}/LICENSE +0 -0
- {destiny_sdk-0.6.0 → destiny_sdk-0.7.2}/README.md +0 -0
- {destiny_sdk-0.6.0 → destiny_sdk-0.7.2}/src/destiny_sdk/__init__.py +0 -0
- {destiny_sdk-0.6.0 → destiny_sdk-0.7.2}/src/destiny_sdk/auth.py +0 -0
- {destiny_sdk-0.6.0 → destiny_sdk-0.7.2}/src/destiny_sdk/imports.py +0 -0
- {destiny_sdk-0.6.0 → destiny_sdk-0.7.2}/src/destiny_sdk/labs/__init__.py +0 -0
- {destiny_sdk-0.6.0 → destiny_sdk-0.7.2}/src/destiny_sdk/labs/references.py +0 -0
- {destiny_sdk-0.6.0 → destiny_sdk-0.7.2}/src/destiny_sdk/parsers/__init__.py +0 -0
- {destiny_sdk-0.6.0 → destiny_sdk-0.7.2}/src/destiny_sdk/py.typed +0 -0
- {destiny_sdk-0.6.0 → destiny_sdk-0.7.2}/src/destiny_sdk/references.py +0 -0
- {destiny_sdk-0.6.0 → destiny_sdk-0.7.2}/src/destiny_sdk/robots.py +0 -0
- {destiny_sdk-0.6.0 → destiny_sdk-0.7.2}/src/destiny_sdk/visibility.py +0 -0
- {destiny_sdk-0.6.0 → destiny_sdk-0.7.2}/tests/unit/__init__.py +0 -0
- {destiny_sdk-0.6.0 → destiny_sdk-0.7.2}/tests/unit/conftest.py +0 -0
- {destiny_sdk-0.6.0 → destiny_sdk-0.7.2}/tests/unit/labs/test_references.py +0 -0
- {destiny_sdk-0.6.0 → destiny_sdk-0.7.2}/tests/unit/test_auth.py +0 -0
- {destiny_sdk-0.6.0 → destiny_sdk-0.7.2}/tests/unit/test_data/destiny_references.jsonl +0 -0
- {destiny_sdk-0.6.0 → destiny_sdk-0.7.2}/tests/unit/test_robots.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: destiny_sdk
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.7.2
|
|
4
4
|
Summary: A software development kit (sdk) to support interaction with the DESTINY repository
|
|
5
5
|
Author-email: Adam Hamilton <adam@futureevidence.org>, Andrew Harvey <andrew@futureevidence.org>, Daniel Breves <daniel@futureevidence.org>, Jack Walmisley <jack@futureevidence.org>, Tim Repke <tim.repke@pik-potsdam.de>
|
|
6
6
|
License-Expression: Apache-2.0
|
|
@@ -9,6 +9,7 @@ Requires-Python: ~=3.12
|
|
|
9
9
|
Requires-Dist: cachetools<6,>=5.5.2
|
|
10
10
|
Requires-Dist: fastapi<0.116,>=0.115.12
|
|
11
11
|
Requires-Dist: httpx<0.29,>=0.28.1
|
|
12
|
+
Requires-Dist: msal>=1.34.0
|
|
12
13
|
Requires-Dist: pydantic<3,>=2.11.3
|
|
13
14
|
Requires-Dist: pytest-asyncio<2,>=1.0.0
|
|
14
15
|
Requires-Dist: pytest-httpx<0.36,>=0.35.0
|
|
@@ -23,6 +23,7 @@ dependencies = [
|
|
|
23
23
|
"cachetools>=5.5.2,<6",
|
|
24
24
|
"fastapi>=0.115.12,<0.116",
|
|
25
25
|
"httpx>=0.28.1,<0.29",
|
|
26
|
+
"msal>=1.34.0",
|
|
26
27
|
"pydantic>=2.11.3,<3",
|
|
27
28
|
"pytest-asyncio>=1.0.0,<2",
|
|
28
29
|
"pytest-httpx>=0.35.0,<0.36",
|
|
@@ -34,7 +35,7 @@ license = "Apache-2.0"
|
|
|
34
35
|
name = "destiny_sdk"
|
|
35
36
|
readme = "README.md"
|
|
36
37
|
requires-python = "~=3.12"
|
|
37
|
-
version = "0.
|
|
38
|
+
version = "0.7.2"
|
|
38
39
|
|
|
39
40
|
[project.optional-dependencies]
|
|
40
41
|
labs = []
|
|
@@ -0,0 +1,601 @@
|
|
|
1
|
+
"""Send authenticated requests to Destiny Repository."""
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
import time
|
|
5
|
+
from collections.abc import Generator
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
from msal import (
|
|
9
|
+
ConfidentialClientApplication,
|
|
10
|
+
ManagedIdentityClient,
|
|
11
|
+
PublicClientApplication,
|
|
12
|
+
UserAssignedManagedIdentity,
|
|
13
|
+
)
|
|
14
|
+
from pydantic import UUID4, HttpUrl, TypeAdapter
|
|
15
|
+
|
|
16
|
+
from destiny_sdk.auth import create_signature
|
|
17
|
+
from destiny_sdk.core import sdk_version
|
|
18
|
+
from destiny_sdk.identifiers import IdentifierLookup
|
|
19
|
+
from destiny_sdk.references import Reference, ReferenceSearchResult
|
|
20
|
+
from destiny_sdk.robots import (
|
|
21
|
+
EnhancementRequestRead,
|
|
22
|
+
RobotEnhancementBatch,
|
|
23
|
+
RobotEnhancementBatchRead,
|
|
24
|
+
RobotEnhancementBatchResult,
|
|
25
|
+
RobotResult,
|
|
26
|
+
)
|
|
27
|
+
from destiny_sdk.search import AnnotationFilter
|
|
28
|
+
|
|
29
|
+
python_version = ".".join(map(str, sys.version_info[:3]))
|
|
30
|
+
user_agent = f"python@{python_version}/destiny-sdk@{sdk_version}"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class HMACSigningAuth(httpx.Auth):
|
|
34
|
+
"""Client that adds an HMAC signature to a request."""
|
|
35
|
+
|
|
36
|
+
requires_request_body = True
|
|
37
|
+
|
|
38
|
+
def __init__(self, secret_key: str, client_id: UUID4) -> None:
|
|
39
|
+
"""
|
|
40
|
+
Initialize the client.
|
|
41
|
+
|
|
42
|
+
:param secret_key: the key to use when signing the request
|
|
43
|
+
:type secret_key: str
|
|
44
|
+
"""
|
|
45
|
+
self.secret_key = secret_key
|
|
46
|
+
self.client_id = client_id
|
|
47
|
+
|
|
48
|
+
def auth_flow(
|
|
49
|
+
self, request: httpx.Request
|
|
50
|
+
) -> Generator[httpx.Request, httpx.Response]:
|
|
51
|
+
"""
|
|
52
|
+
Add a signature to the given request.
|
|
53
|
+
|
|
54
|
+
:param request: request to be sent with signature
|
|
55
|
+
:type request: httpx.Request
|
|
56
|
+
:yield: Generator for Request with signature headers set
|
|
57
|
+
:rtype: Generator[httpx.Request, httpx.Response]
|
|
58
|
+
"""
|
|
59
|
+
timestamp = time.time()
|
|
60
|
+
signature = create_signature(
|
|
61
|
+
self.secret_key, request.content, self.client_id, timestamp
|
|
62
|
+
)
|
|
63
|
+
request.headers["Authorization"] = f"Signature {signature}"
|
|
64
|
+
request.headers["X-Client-Id"] = f"{self.client_id}"
|
|
65
|
+
request.headers["X-Request-Timestamp"] = f"{timestamp}"
|
|
66
|
+
yield request
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class RobotClient:
|
|
70
|
+
"""
|
|
71
|
+
Client for interaction with the Destiny API.
|
|
72
|
+
|
|
73
|
+
Current implementation only supports robot results.
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
def __init__(self, base_url: HttpUrl, secret_key: str, client_id: UUID4) -> None:
|
|
77
|
+
"""
|
|
78
|
+
Initialize the client.
|
|
79
|
+
|
|
80
|
+
:param base_url: The base URL for the Destiny Repository API.
|
|
81
|
+
:type base_url: HttpUrl
|
|
82
|
+
:param secret_key: The secret key for signing requests
|
|
83
|
+
:type auth_method: str
|
|
84
|
+
"""
|
|
85
|
+
self.session = httpx.Client(
|
|
86
|
+
base_url=str(base_url).removesuffix("/").removesuffix("/v1") + "/v1",
|
|
87
|
+
headers={
|
|
88
|
+
"Content-Type": "application/json",
|
|
89
|
+
"User-Agent": user_agent,
|
|
90
|
+
},
|
|
91
|
+
auth=HMACSigningAuth(secret_key=secret_key, client_id=client_id),
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
def send_robot_result(self, robot_result: RobotResult) -> EnhancementRequestRead:
|
|
95
|
+
"""
|
|
96
|
+
Send a RobotResult to destiny repository.
|
|
97
|
+
|
|
98
|
+
Signs the request with the client's secret key.
|
|
99
|
+
|
|
100
|
+
:param robot_result: The RobotResult to send
|
|
101
|
+
:type robot_result: RobotResult
|
|
102
|
+
:return: The EnhancementRequestRead object from the response.
|
|
103
|
+
:rtype: EnhancementRequestRead
|
|
104
|
+
"""
|
|
105
|
+
response = self.session.post(
|
|
106
|
+
f"/enhancement-requests/{robot_result.request_id}/results/",
|
|
107
|
+
json=robot_result.model_dump(mode="json"),
|
|
108
|
+
)
|
|
109
|
+
response.raise_for_status()
|
|
110
|
+
return EnhancementRequestRead.model_validate(response.json())
|
|
111
|
+
|
|
112
|
+
def send_robot_enhancement_batch_result(
|
|
113
|
+
self, robot_enhancement_batch_result: RobotEnhancementBatchResult
|
|
114
|
+
) -> RobotEnhancementBatchRead:
|
|
115
|
+
"""
|
|
116
|
+
Send a RobotEnhancementBatchResult to destiny repository.
|
|
117
|
+
|
|
118
|
+
Signs the request with the client's secret key.
|
|
119
|
+
|
|
120
|
+
:param robot_enhancement_batch_result: The RobotEnhancementBatchResult to send
|
|
121
|
+
:type robot_enhancement_batch_result: RobotEnhancementBatchResult
|
|
122
|
+
:return: The RobotEnhancementBatchRead object from the response.
|
|
123
|
+
:rtype: RobotEnhancementBatchRead
|
|
124
|
+
"""
|
|
125
|
+
response = self.session.post(
|
|
126
|
+
f"/robot-enhancement-batches/{robot_enhancement_batch_result.request_id}/results/",
|
|
127
|
+
json=robot_enhancement_batch_result.model_dump(mode="json"),
|
|
128
|
+
)
|
|
129
|
+
response.raise_for_status()
|
|
130
|
+
return RobotEnhancementBatchRead.model_validate(response.json())
|
|
131
|
+
|
|
132
|
+
def poll_robot_enhancement_batch(
|
|
133
|
+
self,
|
|
134
|
+
robot_id: UUID4,
|
|
135
|
+
limit: int = 10,
|
|
136
|
+
lease: str | None = None,
|
|
137
|
+
timeout: int = 60,
|
|
138
|
+
) -> RobotEnhancementBatch | None:
|
|
139
|
+
"""
|
|
140
|
+
Poll for a robot enhancement batch.
|
|
141
|
+
|
|
142
|
+
Signs the request with the client's secret key.
|
|
143
|
+
|
|
144
|
+
:param robot_id: The ID of the robot to poll for
|
|
145
|
+
:type robot_id: UUID4
|
|
146
|
+
:param limit: The maximum number of pending enhancements to return
|
|
147
|
+
:type limit: int
|
|
148
|
+
:param lease: The duration to lease the pending enhancements for,
|
|
149
|
+
in ISO 8601 duration format eg PT10M. If not provided the repository will
|
|
150
|
+
use a default lease duration.
|
|
151
|
+
:type lease: str | None
|
|
152
|
+
:return: The RobotEnhancementBatch object from the response, or None if no
|
|
153
|
+
batches available
|
|
154
|
+
:rtype: destiny_sdk.robots.RobotEnhancementBatch | None
|
|
155
|
+
"""
|
|
156
|
+
params = {"robot_id": str(robot_id), "limit": limit}
|
|
157
|
+
if lease:
|
|
158
|
+
params["lease"] = lease
|
|
159
|
+
response = self.session.post(
|
|
160
|
+
"/robot-enhancement-batches/",
|
|
161
|
+
params=params,
|
|
162
|
+
timeout=timeout,
|
|
163
|
+
)
|
|
164
|
+
# HTTP 204 No Content indicates no batches available
|
|
165
|
+
if response.status_code == httpx.codes.NO_CONTENT:
|
|
166
|
+
return None
|
|
167
|
+
|
|
168
|
+
response.raise_for_status()
|
|
169
|
+
return RobotEnhancementBatch.model_validate(response.json())
|
|
170
|
+
|
|
171
|
+
def renew_robot_enhancement_batch_lease(
|
|
172
|
+
self, robot_enhancement_batch_id: UUID4, lease_duration: str | None = None
|
|
173
|
+
) -> None:
|
|
174
|
+
"""
|
|
175
|
+
Renew the lease for a robot enhancement batch.
|
|
176
|
+
|
|
177
|
+
Signs the request with the client's secret key.
|
|
178
|
+
|
|
179
|
+
:param robot_enhancement_batch_id: The ID of the robot enhancement batch
|
|
180
|
+
:type robot_enhancement_batch_id: UUID4
|
|
181
|
+
:param lease_duration: The duration to lease the pending enhancements for,
|
|
182
|
+
in ISO 8601 duration format eg PT10M. If not provided the repository will
|
|
183
|
+
use a default lease duration.
|
|
184
|
+
:type lease_duration: str | None
|
|
185
|
+
"""
|
|
186
|
+
response = self.session.post(
|
|
187
|
+
f"/robot-enhancement-batches/{robot_enhancement_batch_id}/renew-lease/",
|
|
188
|
+
params={"lease": lease_duration} if lease_duration else None,
|
|
189
|
+
)
|
|
190
|
+
response.raise_for_status()
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
# Backward compatibility
|
|
194
|
+
Client = RobotClient
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
class OAuthMiddleware(httpx.Auth):
|
|
198
|
+
"""
|
|
199
|
+
Auth middleware that handles OAuth2 token retrieval and refresh.
|
|
200
|
+
|
|
201
|
+
This is generally used in conjunction with
|
|
202
|
+
:class:`OAuthClient <libs.sdk.src.destiny_sdk.client.OAuthClient>`.
|
|
203
|
+
|
|
204
|
+
Supports three authentication flows:
|
|
205
|
+
|
|
206
|
+
**Public Client Application (human login)**
|
|
207
|
+
|
|
208
|
+
Initial login will be interactive through a browser window. Subsequent token
|
|
209
|
+
retrievals will use cached tokens and refreshes where possible, and only prompt
|
|
210
|
+
for login again if necessary.
|
|
211
|
+
|
|
212
|
+
.. code-block:: python
|
|
213
|
+
|
|
214
|
+
auth = OAuthMiddleware(
|
|
215
|
+
azure_client_id="client-id",
|
|
216
|
+
azure_application_id="login-url",
|
|
217
|
+
azure_tenant_id="tenant-id",
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
**Confidential Client Application (client credentials)**
|
|
221
|
+
|
|
222
|
+
Suitable for service-to-service authentication where no user interaction is
|
|
223
|
+
possible or desired. Reach out if you need help setting up a confidential client
|
|
224
|
+
application. The secret must be stored securely.
|
|
225
|
+
|
|
226
|
+
.. code-block:: python
|
|
227
|
+
|
|
228
|
+
auth = OAuthMiddleware(
|
|
229
|
+
azure_client_id="client-id",
|
|
230
|
+
azure_application_id="application-id",
|
|
231
|
+
azure_login_url="login-url",
|
|
232
|
+
azure_client_secret="your-azure-client-secret",
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
**Azure Managed Identity**
|
|
236
|
+
|
|
237
|
+
Suitable for Azure environments that have had API permissions provisioned for
|
|
238
|
+
their managed identity. Note that the ``azure_client_id`` here is the client ID of
|
|
239
|
+
the managed identity, not the repository.
|
|
240
|
+
|
|
241
|
+
.. code-block:: python
|
|
242
|
+
|
|
243
|
+
auth = OAuthMiddleware(
|
|
244
|
+
azure_client_id="your-managed-identity-client-id",
|
|
245
|
+
azure_application_id="application-id",
|
|
246
|
+
use_managed_identity=True,
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
"""
|
|
250
|
+
|
|
251
|
+
def __init__(
|
|
252
|
+
self,
|
|
253
|
+
azure_client_id: str,
|
|
254
|
+
azure_application_id: str,
|
|
255
|
+
azure_login_url: HttpUrl | str | None = None,
|
|
256
|
+
azure_client_secret: str | None = None,
|
|
257
|
+
*,
|
|
258
|
+
use_managed_identity: bool = False,
|
|
259
|
+
) -> None:
|
|
260
|
+
"""
|
|
261
|
+
Initialize the auth middleware.
|
|
262
|
+
|
|
263
|
+
:param tenant_id: The OAuth2 tenant ID.
|
|
264
|
+
:type tenant_id: str
|
|
265
|
+
:param client_id: The OAuth2 client ID.
|
|
266
|
+
:type client_id: str
|
|
267
|
+
:param application_id: The application ID for the Destiny API.
|
|
268
|
+
:type application_id: str
|
|
269
|
+
:param azure_login_url: The Azure login URL.
|
|
270
|
+
:type azure_login_url: str
|
|
271
|
+
:param azure_client_secret: The Azure client secret.
|
|
272
|
+
:type azure_client_secret: str | None
|
|
273
|
+
:param use_managed_identity: Whether to use managed identity for authentication
|
|
274
|
+
:type use_managed_identity: bool
|
|
275
|
+
"""
|
|
276
|
+
if use_managed_identity:
|
|
277
|
+
if (
|
|
278
|
+
any(
|
|
279
|
+
[
|
|
280
|
+
azure_login_url,
|
|
281
|
+
azure_client_secret,
|
|
282
|
+
]
|
|
283
|
+
)
|
|
284
|
+
or not azure_client_id
|
|
285
|
+
):
|
|
286
|
+
msg = (
|
|
287
|
+
"azure_login_url and azure_client_secret must not be provided "
|
|
288
|
+
"when using managed identity authentication"
|
|
289
|
+
)
|
|
290
|
+
raise ValueError(msg)
|
|
291
|
+
self._oauth_app = ManagedIdentityClient(
|
|
292
|
+
UserAssignedManagedIdentity(client_id=azure_client_id),
|
|
293
|
+
http_client=httpx.Client(),
|
|
294
|
+
)
|
|
295
|
+
self._get_token = self._get_token_from_managed_identity
|
|
296
|
+
elif azure_client_secret:
|
|
297
|
+
if not azure_login_url:
|
|
298
|
+
msg = (
|
|
299
|
+
"azure_login_url must be provided "
|
|
300
|
+
"when not using managed identity authentication"
|
|
301
|
+
)
|
|
302
|
+
raise ValueError(msg)
|
|
303
|
+
self._oauth_app = ConfidentialClientApplication(
|
|
304
|
+
client_id=azure_client_id,
|
|
305
|
+
authority=str(azure_login_url),
|
|
306
|
+
client_credential=azure_client_secret,
|
|
307
|
+
)
|
|
308
|
+
self._get_token = self._get_token_from_confidential_client
|
|
309
|
+
else:
|
|
310
|
+
if not azure_login_url:
|
|
311
|
+
msg = (
|
|
312
|
+
"azure_login_url must be provided "
|
|
313
|
+
"when not using managed identity authentication"
|
|
314
|
+
)
|
|
315
|
+
raise ValueError(msg)
|
|
316
|
+
self._oauth_app = PublicClientApplication(
|
|
317
|
+
azure_client_id,
|
|
318
|
+
authority=str(azure_login_url),
|
|
319
|
+
client_credential=None,
|
|
320
|
+
)
|
|
321
|
+
self._get_token = self._get_token_from_public_client
|
|
322
|
+
|
|
323
|
+
self._scope = f"api://{azure_application_id}/.default"
|
|
324
|
+
self._account = None
|
|
325
|
+
|
|
326
|
+
def _parse_token(self, msal_response: dict) -> str:
|
|
327
|
+
"""
|
|
328
|
+
Parse the OAuth2 token from an MSAL response.
|
|
329
|
+
|
|
330
|
+
:param msal_response: The MSAL response containing the token.
|
|
331
|
+
:type msal_response: dict
|
|
332
|
+
:return: The OAuth2 token.
|
|
333
|
+
:rtype: str
|
|
334
|
+
"""
|
|
335
|
+
if not msal_response.get("access_token"):
|
|
336
|
+
msg = (
|
|
337
|
+
"Failed to acquire access token: "
|
|
338
|
+
f"{msal_response.get('error', 'Unknown error')}"
|
|
339
|
+
)
|
|
340
|
+
raise RuntimeError(msg)
|
|
341
|
+
|
|
342
|
+
return msal_response["access_token"]
|
|
343
|
+
|
|
344
|
+
def _get_token_from_public_client(self, *, force_refresh: bool = False) -> str:
|
|
345
|
+
"""
|
|
346
|
+
Get an OAuth2 token from a PublicClientApplication.
|
|
347
|
+
|
|
348
|
+
:param force_refresh: Whether to force a token refresh.
|
|
349
|
+
:type force_refresh: bool
|
|
350
|
+
:return: The OAuth2 token.
|
|
351
|
+
:rtype: str
|
|
352
|
+
"""
|
|
353
|
+
if not isinstance(self._oauth_app, PublicClientApplication):
|
|
354
|
+
msg = "oauth_app must be a PublicClientApplication for this method"
|
|
355
|
+
raise TypeError(msg)
|
|
356
|
+
|
|
357
|
+
# Uses msal cache if possible, else interactive login
|
|
358
|
+
result = self._oauth_app.acquire_token_silent(
|
|
359
|
+
scopes=[self._scope],
|
|
360
|
+
account=self._account,
|
|
361
|
+
force_refresh=force_refresh,
|
|
362
|
+
)
|
|
363
|
+
if not result:
|
|
364
|
+
result = self._oauth_app.acquire_token_interactive(scopes=[self._scope])
|
|
365
|
+
|
|
366
|
+
access_token = self._parse_token(result)
|
|
367
|
+
|
|
368
|
+
# After first login, cache the account for silent token acquisition
|
|
369
|
+
if not self._account and (accounts := self._oauth_app.get_accounts()):
|
|
370
|
+
self._account = accounts[0]
|
|
371
|
+
|
|
372
|
+
return access_token
|
|
373
|
+
|
|
374
|
+
def _get_token_from_confidential_client(
|
|
375
|
+
self,
|
|
376
|
+
*,
|
|
377
|
+
force_refresh: bool = False, # noqa: ARG002 MSAL will handle refreshing
|
|
378
|
+
) -> str:
|
|
379
|
+
"""
|
|
380
|
+
Get an OAuth2 token from a ConfidentialClientApplication.
|
|
381
|
+
|
|
382
|
+
:param force_refresh: Whether to force a token refresh.
|
|
383
|
+
:type force_refresh: bool
|
|
384
|
+
:return: The OAuth2 token.
|
|
385
|
+
:rtype: str
|
|
386
|
+
"""
|
|
387
|
+
if not isinstance(self._oauth_app, ConfidentialClientApplication):
|
|
388
|
+
msg = "oauth_app must be a ConfidentialClientApplication for this method"
|
|
389
|
+
raise TypeError(msg)
|
|
390
|
+
|
|
391
|
+
# Uses msal cache if possible, else client credentials flow
|
|
392
|
+
result = self._oauth_app.acquire_token_for_client(scopes=[self._scope])
|
|
393
|
+
|
|
394
|
+
return self._parse_token(result)
|
|
395
|
+
|
|
396
|
+
def _get_token_from_managed_identity(
|
|
397
|
+
self,
|
|
398
|
+
*,
|
|
399
|
+
force_refresh: bool = False, # noqa: ARG002 MSAL will handle refreshing
|
|
400
|
+
) -> str:
|
|
401
|
+
"""
|
|
402
|
+
Get an OAuth2 token from a ManagedIdentityClient.
|
|
403
|
+
|
|
404
|
+
:param force_refresh: Whether to force a token refresh.
|
|
405
|
+
:type force_refresh: bool
|
|
406
|
+
:return: The OAuth2 token.
|
|
407
|
+
:rtype: str
|
|
408
|
+
"""
|
|
409
|
+
if not isinstance(self._oauth_app, ManagedIdentityClient):
|
|
410
|
+
msg = "oauth_app must be a ManagedIdentityClient for this method"
|
|
411
|
+
raise TypeError(msg)
|
|
412
|
+
|
|
413
|
+
result = self._oauth_app.acquire_token_for_client(
|
|
414
|
+
resource=self._scope.removesuffix("/.default")
|
|
415
|
+
)
|
|
416
|
+
|
|
417
|
+
return self._parse_token(result)
|
|
418
|
+
|
|
419
|
+
def auth_flow(
|
|
420
|
+
self, request: httpx.Request
|
|
421
|
+
) -> Generator[httpx.Request, httpx.Response]:
|
|
422
|
+
"""
|
|
423
|
+
Add OAuth2 token to request and handle token refresh on expiration.
|
|
424
|
+
|
|
425
|
+
:param request: The request to authenticate.
|
|
426
|
+
:type request: httpx.Request
|
|
427
|
+
:yield: Authenticated request with token refresh handling.
|
|
428
|
+
:rtype: Generator[httpx.Request, httpx.Response]
|
|
429
|
+
"""
|
|
430
|
+
# Add initial token
|
|
431
|
+
token = self._get_token()
|
|
432
|
+
request.headers["Authorization"] = f"Bearer {token}"
|
|
433
|
+
|
|
434
|
+
response = yield request
|
|
435
|
+
|
|
436
|
+
# Check if token expired and retry once with fresh token
|
|
437
|
+
if response.status_code == httpx.codes.UNAUTHORIZED:
|
|
438
|
+
try:
|
|
439
|
+
json_response: dict = response.json()
|
|
440
|
+
error_detail: str = json_response.get("detail", {})
|
|
441
|
+
except ValueError:
|
|
442
|
+
error_detail = ""
|
|
443
|
+
|
|
444
|
+
if error_detail == "Token has expired.":
|
|
445
|
+
# Force refresh token and retry
|
|
446
|
+
token = self._get_token(force_refresh=True)
|
|
447
|
+
request.headers["Authorization"] = f"Bearer {token}"
|
|
448
|
+
yield request
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
class OAuthClient:
|
|
452
|
+
"""
|
|
453
|
+
Client for interaction with the Destiny API using OAuth2.
|
|
454
|
+
|
|
455
|
+
This will apply the provided authentication, usually
|
|
456
|
+
:class:`OAuthMiddleware <libs.sdk.src.destiny_sdk.client.OAuthMiddleware>`,
|
|
457
|
+
to all requests. Some API endpoints are supported directly through methods on this
|
|
458
|
+
class, while others can be accessed through the underlying ``httpx`` client.
|
|
459
|
+
|
|
460
|
+
Example usage:
|
|
461
|
+
|
|
462
|
+
.. code-block:: python
|
|
463
|
+
|
|
464
|
+
from destiny_sdk.client import OAuthClient, OAuthMiddleware
|
|
465
|
+
|
|
466
|
+
client = OAuthClient(
|
|
467
|
+
base_url="https://destiny-repository.example.com",
|
|
468
|
+
auth=OAuthMiddleware(...),
|
|
469
|
+
)
|
|
470
|
+
|
|
471
|
+
# Supported method
|
|
472
|
+
response = client.search(query="example")
|
|
473
|
+
|
|
474
|
+
# Unsupported method, use underlying httpx client
|
|
475
|
+
response = client.get_client().get("/system/healthcheck/")
|
|
476
|
+
"""
|
|
477
|
+
|
|
478
|
+
def __init__(
|
|
479
|
+
self,
|
|
480
|
+
base_url: HttpUrl | str,
|
|
481
|
+
auth: httpx.Auth | None = None,
|
|
482
|
+
) -> None:
|
|
483
|
+
"""
|
|
484
|
+
Initialize the client.
|
|
485
|
+
|
|
486
|
+
:param base_url: The base URL for the Destiny Repository API.
|
|
487
|
+
:type base_url: HttpUrl
|
|
488
|
+
:param auth: The middleware for authentication. If not provided, only
|
|
489
|
+
unauthenticated requests can be made. This should almost always be an
|
|
490
|
+
instance of ``OAuthMiddleware``, unless you need to create a custom auth
|
|
491
|
+
class.
|
|
492
|
+
:type auth: httpx.Auth | None
|
|
493
|
+
"""
|
|
494
|
+
self._client = httpx.Client(
|
|
495
|
+
base_url=str(base_url).removesuffix("/").removesuffix("/v1") + "/v1",
|
|
496
|
+
headers={
|
|
497
|
+
"Content-Type": "application/json",
|
|
498
|
+
"User-Agent": user_agent,
|
|
499
|
+
},
|
|
500
|
+
)
|
|
501
|
+
|
|
502
|
+
if auth:
|
|
503
|
+
self._client.auth = auth
|
|
504
|
+
|
|
505
|
+
def _raise_for_status(self, response: httpx.Response) -> None:
|
|
506
|
+
"""
|
|
507
|
+
Raise an error if the response status is not successful.
|
|
508
|
+
|
|
509
|
+
:param response: The HTTP response to check.
|
|
510
|
+
:type response: httpx.Response
|
|
511
|
+
:raises httpx.HTTPStatusError: If the response status is not successful.
|
|
512
|
+
"""
|
|
513
|
+
try:
|
|
514
|
+
response.raise_for_status()
|
|
515
|
+
except httpx.HTTPStatusError as exc:
|
|
516
|
+
msg = (
|
|
517
|
+
f"Error response {exc.response.status_code} from "
|
|
518
|
+
f"{exc.request.url}: {exc.response.text}"
|
|
519
|
+
)
|
|
520
|
+
raise httpx.HTTPStatusError(
|
|
521
|
+
msg, request=exc.request, response=exc.response
|
|
522
|
+
) from exc
|
|
523
|
+
|
|
524
|
+
def search( # noqa: PLR0913
|
|
525
|
+
self,
|
|
526
|
+
query: str,
|
|
527
|
+
start_year: int | None = None,
|
|
528
|
+
end_year: int | None = None,
|
|
529
|
+
annotations: list[str | AnnotationFilter] | None = None,
|
|
530
|
+
sort: str | None = None,
|
|
531
|
+
page: int = 1,
|
|
532
|
+
) -> ReferenceSearchResult:
|
|
533
|
+
"""
|
|
534
|
+
Send a search request to the Destiny Repository API.
|
|
535
|
+
|
|
536
|
+
See also: :ref:`search-procedure`.
|
|
537
|
+
|
|
538
|
+
:param query: The search query string.
|
|
539
|
+
:type query: str
|
|
540
|
+
:param start_year: The start year for filtering results.
|
|
541
|
+
:type start_year: int | None
|
|
542
|
+
:param end_year: The end year for filtering results.
|
|
543
|
+
:type end_year: int | None
|
|
544
|
+
:param annotations: A list of annotation filters to apply.
|
|
545
|
+
:type annotations: list[str | libs.sdk.src.destiny_sdk.search.AnnotationFilter] | None
|
|
546
|
+
:param sort: The sort order for the results.
|
|
547
|
+
:type sort: str | None
|
|
548
|
+
:param page: The page number of results to retrieve.
|
|
549
|
+
:type page: int
|
|
550
|
+
:return: The response from the API.
|
|
551
|
+
:rtype: libs.sdk.src.destiny_sdk.references.ReferenceSearchResult
|
|
552
|
+
""" # noqa: E501
|
|
553
|
+
params = {"q": query, "page": page}
|
|
554
|
+
if start_year:
|
|
555
|
+
params["start_year"] = start_year
|
|
556
|
+
if end_year:
|
|
557
|
+
params["end_year"] = end_year
|
|
558
|
+
if annotations:
|
|
559
|
+
params["annotation"] = [str(annotation) for annotation in annotations]
|
|
560
|
+
if sort:
|
|
561
|
+
params["sort"] = sort
|
|
562
|
+
response = self._client.get(
|
|
563
|
+
"/references/search/",
|
|
564
|
+
params=params,
|
|
565
|
+
)
|
|
566
|
+
self._raise_for_status(response)
|
|
567
|
+
return ReferenceSearchResult.model_validate(response.json())
|
|
568
|
+
|
|
569
|
+
def lookup(
|
|
570
|
+
self,
|
|
571
|
+
identifiers: list[str | IdentifierLookup],
|
|
572
|
+
) -> list[Reference]:
|
|
573
|
+
"""
|
|
574
|
+
Lookup references by identifiers.
|
|
575
|
+
|
|
576
|
+
See also: :ref:`lookup-procedure`.
|
|
577
|
+
|
|
578
|
+
:param identifiers: The identifiers to look up.
|
|
579
|
+
:type identifiers: list[str | libs.sdk.src.destiny_sdk.identifiers.IdentifierLookup]
|
|
580
|
+
:return: The list of references matching the identifiers.
|
|
581
|
+
:rtype: list[libs.sdk.src.destiny_sdk.references.Reference]
|
|
582
|
+
""" # noqa: E501
|
|
583
|
+
response = self._client.get(
|
|
584
|
+
"/references/",
|
|
585
|
+
params={
|
|
586
|
+
"identifier": ",".join([str(identifier) for identifier in identifiers])
|
|
587
|
+
},
|
|
588
|
+
)
|
|
589
|
+
self._raise_for_status(response)
|
|
590
|
+
return TypeAdapter(list[Reference]).validate_python(response.json())
|
|
591
|
+
|
|
592
|
+
def get_client(self) -> httpx.Client:
|
|
593
|
+
"""
|
|
594
|
+
Get the underlying ``httpx`` client.
|
|
595
|
+
|
|
596
|
+
This can be used to make custom requests not covered by the SDK methods.
|
|
597
|
+
|
|
598
|
+
:return: The underlying ``httpx`` client with authentication attached.
|
|
599
|
+
:rtype: `httpx.Client <https://www.python-httpx.org/advanced/clients/>`_
|
|
600
|
+
"""
|
|
601
|
+
return self._client
|
|
@@ -1,11 +1,18 @@
|
|
|
1
1
|
"""Core classes for the Destiny SDK, not exposed to package users."""
|
|
2
2
|
|
|
3
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
3
4
|
from typing import Self
|
|
4
5
|
|
|
5
6
|
from pydantic import BaseModel, Field
|
|
6
7
|
|
|
7
8
|
from destiny_sdk.search import SearchResultPage, SearchResultTotal
|
|
8
9
|
|
|
10
|
+
try:
|
|
11
|
+
sdk_version = version("destiny-sdk")
|
|
12
|
+
except PackageNotFoundError:
|
|
13
|
+
sdk_version = "unknown"
|
|
14
|
+
|
|
15
|
+
|
|
9
16
|
# These are non-standard newline characters that are not escaped by model_dump_json().
|
|
10
17
|
# We want jsonl files to have empirical new lines so they can be streamed line by line.
|
|
11
18
|
# Hence we replace each occurrence with standard new lines.
|