destiny_sdk 0.7.1__py3-none-any.whl → 0.7.3__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.
- destiny_sdk/client.py +431 -4
- destiny_sdk/core.py +7 -0
- destiny_sdk/enhancements.py +38 -0
- destiny_sdk/identifiers.py +8 -0
- destiny_sdk/search.py +4 -0
- {destiny_sdk-0.7.1.dist-info → destiny_sdk-0.7.3.dist-info}/METADATA +2 -1
- {destiny_sdk-0.7.1.dist-info → destiny_sdk-0.7.3.dist-info}/RECORD +9 -9
- {destiny_sdk-0.7.1.dist-info → destiny_sdk-0.7.3.dist-info}/WHEEL +0 -0
- {destiny_sdk-0.7.1.dist-info → destiny_sdk-0.7.3.dist-info}/licenses/LICENSE +0 -0
destiny_sdk/client.py
CHANGED
|
@@ -1,11 +1,22 @@
|
|
|
1
1
|
"""Send authenticated requests to Destiny Repository."""
|
|
2
2
|
|
|
3
|
+
import sys
|
|
3
4
|
import time
|
|
4
5
|
from collections.abc import Generator
|
|
5
6
|
|
|
6
7
|
import httpx
|
|
7
|
-
from
|
|
8
|
+
from msal import (
|
|
9
|
+
ConfidentialClientApplication,
|
|
10
|
+
ManagedIdentityClient,
|
|
11
|
+
PublicClientApplication,
|
|
12
|
+
UserAssignedManagedIdentity,
|
|
13
|
+
)
|
|
14
|
+
from pydantic import UUID4, HttpUrl, TypeAdapter
|
|
8
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
|
|
9
20
|
from destiny_sdk.robots import (
|
|
10
21
|
EnhancementRequestRead,
|
|
11
22
|
RobotEnhancementBatch,
|
|
@@ -13,8 +24,10 @@ from destiny_sdk.robots import (
|
|
|
13
24
|
RobotEnhancementBatchResult,
|
|
14
25
|
RobotResult,
|
|
15
26
|
)
|
|
27
|
+
from destiny_sdk.search import AnnotationFilter
|
|
16
28
|
|
|
17
|
-
|
|
29
|
+
python_version = ".".join(map(str, sys.version_info[:3]))
|
|
30
|
+
user_agent = f"python@{python_version}/destiny-sdk@{sdk_version}"
|
|
18
31
|
|
|
19
32
|
|
|
20
33
|
class HMACSigningAuth(httpx.Auth):
|
|
@@ -53,7 +66,7 @@ class HMACSigningAuth(httpx.Auth):
|
|
|
53
66
|
yield request
|
|
54
67
|
|
|
55
68
|
|
|
56
|
-
class
|
|
69
|
+
class RobotClient:
|
|
57
70
|
"""
|
|
58
71
|
Client for interaction with the Destiny API.
|
|
59
72
|
|
|
@@ -71,7 +84,10 @@ class Client:
|
|
|
71
84
|
"""
|
|
72
85
|
self.session = httpx.Client(
|
|
73
86
|
base_url=str(base_url).removesuffix("/").removesuffix("/v1") + "/v1",
|
|
74
|
-
headers={
|
|
87
|
+
headers={
|
|
88
|
+
"Content-Type": "application/json",
|
|
89
|
+
"User-Agent": user_agent,
|
|
90
|
+
},
|
|
75
91
|
auth=HMACSigningAuth(secret_key=secret_key, client_id=client_id),
|
|
76
92
|
)
|
|
77
93
|
|
|
@@ -172,3 +188,414 @@ class Client:
|
|
|
172
188
|
params={"lease": lease_duration} if lease_duration else None,
|
|
173
189
|
)
|
|
174
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
|
destiny_sdk/core.py
CHANGED
|
@@ -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.
|
destiny_sdk/enhancements.py
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"""Enhancement classes for the Destiny Repository."""
|
|
2
2
|
|
|
3
3
|
import datetime
|
|
4
|
+
import json
|
|
4
5
|
from enum import StrEnum, auto
|
|
5
6
|
from typing import Annotated, Any, Literal, Self
|
|
6
7
|
|
|
@@ -87,6 +88,10 @@ other works have cited this work
|
|
|
87
88
|
created_date: datetime.date | None = Field(
|
|
88
89
|
default=None, description="The ISO8601 date this metadata record was created"
|
|
89
90
|
)
|
|
91
|
+
updated_date: datetime.date | None = Field(
|
|
92
|
+
default=None,
|
|
93
|
+
description="The ISO8601 date of the last OpenAlex update to this metadata",
|
|
94
|
+
)
|
|
90
95
|
publication_date: datetime.date | None = Field(
|
|
91
96
|
default=None, description="The date which the version of record was published."
|
|
92
97
|
)
|
|
@@ -100,6 +105,20 @@ other works have cited this work
|
|
|
100
105
|
)
|
|
101
106
|
title: str | None = Field(default=None, description="The title of the reference.")
|
|
102
107
|
|
|
108
|
+
@property
|
|
109
|
+
def fingerprint(self) -> str:
|
|
110
|
+
"""
|
|
111
|
+
The fingerprint of this bibliographic metadata enhancement.
|
|
112
|
+
|
|
113
|
+
Excludes updated_at from the fingerprint calculation, meaning
|
|
114
|
+
that two raw enhancements with identical data but different export dates
|
|
115
|
+
will be considered the same.
|
|
116
|
+
"""
|
|
117
|
+
return json.dumps(
|
|
118
|
+
self.model_dump(mode="json", exclude={"updated_date"}, exclude_none=True),
|
|
119
|
+
sort_keys=True,
|
|
120
|
+
)
|
|
121
|
+
|
|
103
122
|
|
|
104
123
|
class AbstractProcessType(StrEnum):
|
|
105
124
|
"""The process used to acquire the abstract."""
|
|
@@ -332,6 +351,25 @@ class RawEnhancement(BaseModel):
|
|
|
332
351
|
raise ValueError(msg)
|
|
333
352
|
return self
|
|
334
353
|
|
|
354
|
+
@property
|
|
355
|
+
def fingerprint(self) -> str:
|
|
356
|
+
"""
|
|
357
|
+
The unique fingerprint of this raw enhancement.
|
|
358
|
+
|
|
359
|
+
Excludes the source_export_date from the fingerprint calculation, meaning
|
|
360
|
+
that two raw enhancements with identical data but different export dates
|
|
361
|
+
will be considered the same.
|
|
362
|
+
|
|
363
|
+
Unstructured data in `data` and `metadata` is included in the fingerprint,
|
|
364
|
+
sorted by key.
|
|
365
|
+
"""
|
|
366
|
+
return json.dumps(
|
|
367
|
+
self.model_dump(
|
|
368
|
+
mode="json", exclude={"source_export_date"}, exclude_none=True
|
|
369
|
+
),
|
|
370
|
+
sort_keys=True,
|
|
371
|
+
)
|
|
372
|
+
|
|
335
373
|
|
|
336
374
|
#: Union type for all enhancement content types.
|
|
337
375
|
EnhancementContent = Annotated[
|
destiny_sdk/identifiers.py
CHANGED
|
@@ -260,3 +260,11 @@ class IdentifierLookup(BaseModel):
|
|
|
260
260
|
if self.identifier_type is None:
|
|
261
261
|
return UUID4(self.identifier)
|
|
262
262
|
return ExternalIdentifierAdapter.validate_python(self.model_dump())
|
|
263
|
+
|
|
264
|
+
def __repr__(self) -> str:
|
|
265
|
+
"""Serialize the identifier lookup to a string."""
|
|
266
|
+
return self.serialize()
|
|
267
|
+
|
|
268
|
+
def __str__(self) -> str:
|
|
269
|
+
"""Serialize the identifier lookup to a string."""
|
|
270
|
+
return self.serialize()
|
destiny_sdk/search.py
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: destiny_sdk
|
|
3
|
-
Version: 0.7.
|
|
3
|
+
Version: 0.7.3
|
|
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
|
|
@@ -1,21 +1,21 @@
|
|
|
1
1
|
destiny_sdk/__init__.py,sha256=NdSlsPQyDF3TW30_JzbvYMRBRA9iT677iTRWWCMdYOA,382
|
|
2
2
|
destiny_sdk/auth.py,sha256=bY72ywZEcG_67YBd9PrwgWTXkCf58rhLvVEXrtXbWtA,6247
|
|
3
|
-
destiny_sdk/client.py,sha256=
|
|
4
|
-
destiny_sdk/core.py,sha256=
|
|
5
|
-
destiny_sdk/enhancements.py,sha256
|
|
6
|
-
destiny_sdk/identifiers.py,sha256=
|
|
3
|
+
destiny_sdk/client.py,sha256=nKvS5rRkIpBqv8dVIB57Xsop0UvVz3i875RQxfVSMao,21306
|
|
4
|
+
destiny_sdk/core.py,sha256=E0Wotu9psggK1JRJxbvx3Jc7WEGE6zaz2R2awvRrLz8,2023
|
|
5
|
+
destiny_sdk/enhancements.py,sha256=P_kH59WoWuYq444xjV05HNXlTGfB-lE0ffKYwpAY7z8,14118
|
|
6
|
+
destiny_sdk/identifiers.py,sha256=I9Q2I35Lg8oyl3uytq1gCGOUu92F9sZSSwzLoCXBJi4,9727
|
|
7
7
|
destiny_sdk/imports.py,sha256=b-rh-dt3NsyLGxqmVzIzKaHiXhbw-3wtAaBN-ZW-i1E,5940
|
|
8
8
|
destiny_sdk/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
9
9
|
destiny_sdk/references.py,sha256=3Y8gBMTSyZY35S3pB1bnVHMai9RRiGeoGZysNvSo7kk,2553
|
|
10
10
|
destiny_sdk/robots.py,sha256=I_ZvMxwST52e8ovhv0-gPbOB3P9tptbRG0LrkNNOqKo,13463
|
|
11
|
-
destiny_sdk/search.py,sha256=
|
|
11
|
+
destiny_sdk/search.py,sha256=QWQBNEJJnH2o6CGapChKUp6kOZl2Uq3Pxqg1kcu9x-4,1590
|
|
12
12
|
destiny_sdk/visibility.py,sha256=8D44Q868YdScAt6eAFgXXrhonozXnv_Qa5w5yEGMPX8,577
|
|
13
13
|
destiny_sdk/labs/__init__.py,sha256=H4RFPyeelqZ56PagnWPX-JZeWlxPnCZoYHtr4c9SU9Q,180
|
|
14
14
|
destiny_sdk/labs/references.py,sha256=iZisRgGZ5c7X7uTFoe6Q0AwwFMa4yJbIoPUVv_hvOiU,5589
|
|
15
15
|
destiny_sdk/parsers/__init__.py,sha256=d5gS--bXla_0I7e_9wTBnGWMXt2U8b-_ndeprTPe1hk,149
|
|
16
16
|
destiny_sdk/parsers/eppi_parser.py,sha256=_1xnAT0F0o1HKpMWOGQbVS3VPOrhPqyzHDWR3CosWwk,9484
|
|
17
17
|
destiny_sdk/parsers/exceptions.py,sha256=0Sc_M4j560Nqh4SjeP_YrgOUVagdIwWwRz24E6YlZ1k,573
|
|
18
|
-
destiny_sdk-0.7.
|
|
19
|
-
destiny_sdk-0.7.
|
|
20
|
-
destiny_sdk-0.7.
|
|
21
|
-
destiny_sdk-0.7.
|
|
18
|
+
destiny_sdk-0.7.3.dist-info/METADATA,sha256=2z_JjoZa-UMvOaPefJoYAc1ILx39gT0IWIqfYljbS1g,2685
|
|
19
|
+
destiny_sdk-0.7.3.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
|
|
20
|
+
destiny_sdk-0.7.3.dist-info/licenses/LICENSE,sha256=6QURU4gvvTjVZ5rfp5amZ6FtFvcpPhAGUjxF5WSZAHI,9138
|
|
21
|
+
destiny_sdk-0.7.3.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|