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.
Files changed (44) hide show
  1. {destiny_sdk-0.6.0 → destiny_sdk-0.7.2}/.gitignore +2 -0
  2. {destiny_sdk-0.6.0 → destiny_sdk-0.7.2}/PKG-INFO +2 -1
  3. {destiny_sdk-0.6.0 → destiny_sdk-0.7.2}/pyproject.toml +2 -1
  4. destiny_sdk-0.7.2/src/destiny_sdk/client.py +601 -0
  5. {destiny_sdk-0.6.0 → destiny_sdk-0.7.2}/src/destiny_sdk/core.py +7 -0
  6. {destiny_sdk-0.6.0 → destiny_sdk-0.7.2}/src/destiny_sdk/enhancements.py +58 -19
  7. {destiny_sdk-0.6.0 → destiny_sdk-0.7.2}/src/destiny_sdk/identifiers.py +83 -5
  8. destiny_sdk-0.7.2/src/destiny_sdk/parsers/eppi_parser.py +284 -0
  9. destiny_sdk-0.7.2/src/destiny_sdk/parsers/exceptions.py +17 -0
  10. {destiny_sdk-0.6.0 → destiny_sdk-0.7.2}/src/destiny_sdk/search.py +6 -1
  11. destiny_sdk-0.7.2/tests/unit/parsers/test_eppi_parser.py +228 -0
  12. destiny_sdk-0.7.2/tests/unit/test_client.py +426 -0
  13. destiny_sdk-0.7.2/tests/unit/test_data/eppi_import.jsonl +4 -0
  14. destiny_sdk-0.7.2/tests/unit/test_data/eppi_import_with_annotations.jsonl +4 -0
  15. destiny_sdk-0.7.2/tests/unit/test_data/eppi_import_with_raw.jsonl +4 -0
  16. {destiny_sdk-0.6.0 → destiny_sdk-0.7.2}/tests/unit/test_data/eppi_report.json +6 -1
  17. {destiny_sdk-0.6.0 → destiny_sdk-0.7.2}/tests/unit/test_enhancements.py +48 -0
  18. {destiny_sdk-0.6.0 → destiny_sdk-0.7.2}/tests/unit/test_identifiers.py +27 -0
  19. {destiny_sdk-0.6.0 → destiny_sdk-0.7.2}/tests/unit/test_references.py +2 -1
  20. {destiny_sdk-0.6.0 → destiny_sdk-0.7.2}/uv.lock +233 -0
  21. destiny_sdk-0.6.0/src/destiny_sdk/client.py +0 -142
  22. destiny_sdk-0.6.0/src/destiny_sdk/parsers/eppi_parser.py +0 -172
  23. destiny_sdk-0.6.0/tests/unit/parsers/test_eppi_parser.py +0 -47
  24. destiny_sdk-0.6.0/tests/unit/test_client.py +0 -73
  25. destiny_sdk-0.6.0/tests/unit/test_data/eppi_import.jsonl +0 -4
  26. destiny_sdk-0.6.0/tests/unit/test_data/eppi_import_with_annotations.jsonl +0 -4
  27. {destiny_sdk-0.6.0 → destiny_sdk-0.7.2}/LICENSE +0 -0
  28. {destiny_sdk-0.6.0 → destiny_sdk-0.7.2}/README.md +0 -0
  29. {destiny_sdk-0.6.0 → destiny_sdk-0.7.2}/src/destiny_sdk/__init__.py +0 -0
  30. {destiny_sdk-0.6.0 → destiny_sdk-0.7.2}/src/destiny_sdk/auth.py +0 -0
  31. {destiny_sdk-0.6.0 → destiny_sdk-0.7.2}/src/destiny_sdk/imports.py +0 -0
  32. {destiny_sdk-0.6.0 → destiny_sdk-0.7.2}/src/destiny_sdk/labs/__init__.py +0 -0
  33. {destiny_sdk-0.6.0 → destiny_sdk-0.7.2}/src/destiny_sdk/labs/references.py +0 -0
  34. {destiny_sdk-0.6.0 → destiny_sdk-0.7.2}/src/destiny_sdk/parsers/__init__.py +0 -0
  35. {destiny_sdk-0.6.0 → destiny_sdk-0.7.2}/src/destiny_sdk/py.typed +0 -0
  36. {destiny_sdk-0.6.0 → destiny_sdk-0.7.2}/src/destiny_sdk/references.py +0 -0
  37. {destiny_sdk-0.6.0 → destiny_sdk-0.7.2}/src/destiny_sdk/robots.py +0 -0
  38. {destiny_sdk-0.6.0 → destiny_sdk-0.7.2}/src/destiny_sdk/visibility.py +0 -0
  39. {destiny_sdk-0.6.0 → destiny_sdk-0.7.2}/tests/unit/__init__.py +0 -0
  40. {destiny_sdk-0.6.0 → destiny_sdk-0.7.2}/tests/unit/conftest.py +0 -0
  41. {destiny_sdk-0.6.0 → destiny_sdk-0.7.2}/tests/unit/labs/test_references.py +0 -0
  42. {destiny_sdk-0.6.0 → destiny_sdk-0.7.2}/tests/unit/test_auth.py +0 -0
  43. {destiny_sdk-0.6.0 → destiny_sdk-0.7.2}/tests/unit/test_data/destiny_references.jsonl +0 -0
  44. {destiny_sdk-0.6.0 → destiny_sdk-0.7.2}/tests/unit/test_robots.py +0 -0
@@ -203,3 +203,5 @@ libs/fake_data/*.jsonl
203
203
  .env.*
204
204
  !.env.example
205
205
  .idea/
206
+
207
+ .test.tmp
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: destiny_sdk
3
- Version: 0.6.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.6.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.