destiny_sdk 0.5.1__tar.gz → 0.7.1__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.5.1 → destiny_sdk-0.7.1}/.gitignore +7 -0
- {destiny_sdk-0.5.1 → destiny_sdk-0.7.1}/PKG-INFO +10 -2
- {destiny_sdk-0.5.1 → destiny_sdk-0.7.1}/README.md +7 -0
- {destiny_sdk-0.5.1 → destiny_sdk-0.7.1}/pyproject.toml +5 -1
- {destiny_sdk-0.5.1 → destiny_sdk-0.7.1}/src/destiny_sdk/__init__.py +2 -0
- {destiny_sdk-0.5.1 → destiny_sdk-0.7.1}/src/destiny_sdk/client.py +34 -2
- {destiny_sdk-0.5.1 → destiny_sdk-0.7.1}/src/destiny_sdk/core.py +14 -1
- {destiny_sdk-0.5.1 → destiny_sdk-0.7.1}/src/destiny_sdk/enhancements.py +58 -19
- {destiny_sdk-0.5.1 → destiny_sdk-0.7.1}/src/destiny_sdk/identifiers.py +75 -5
- destiny_sdk-0.7.1/src/destiny_sdk/labs/__init__.py +10 -0
- destiny_sdk-0.7.1/src/destiny_sdk/labs/references.py +154 -0
- destiny_sdk-0.7.1/src/destiny_sdk/parsers/eppi_parser.py +284 -0
- destiny_sdk-0.7.1/src/destiny_sdk/parsers/exceptions.py +17 -0
- {destiny_sdk-0.5.1 → destiny_sdk-0.7.1}/src/destiny_sdk/references.py +9 -1
- destiny_sdk-0.7.1/src/destiny_sdk/search.py +53 -0
- destiny_sdk-0.7.1/tests/unit/labs/test_references.py +144 -0
- destiny_sdk-0.7.1/tests/unit/parsers/test_eppi_parser.py +228 -0
- destiny_sdk-0.7.1/tests/unit/test_data/destiny_references.jsonl +10 -0
- destiny_sdk-0.7.1/tests/unit/test_data/eppi_import.jsonl +4 -0
- destiny_sdk-0.7.1/tests/unit/test_data/eppi_import_with_annotations.jsonl +4 -0
- destiny_sdk-0.7.1/tests/unit/test_data/eppi_import_with_raw.jsonl +4 -0
- {destiny_sdk-0.5.1 → destiny_sdk-0.7.1}/tests/unit/test_data/eppi_report.json +6 -1
- {destiny_sdk-0.5.1 → destiny_sdk-0.7.1}/tests/unit/test_enhancements.py +48 -0
- {destiny_sdk-0.5.1 → destiny_sdk-0.7.1}/tests/unit/test_identifiers.py +27 -0
- {destiny_sdk-0.5.1 → destiny_sdk-0.7.1}/tests/unit/test_references.py +2 -1
- {destiny_sdk-0.5.1 → destiny_sdk-0.7.1}/uv.lock +2 -1
- destiny_sdk-0.5.1/src/destiny_sdk/parsers/eppi_parser.py +0 -172
- destiny_sdk-0.5.1/tests/unit/parsers/test_eppi_parser.py +0 -47
- destiny_sdk-0.5.1/tests/unit/test_data/eppi_import.jsonl +0 -4
- destiny_sdk-0.5.1/tests/unit/test_data/eppi_import_with_annotations.jsonl +0 -4
- {destiny_sdk-0.5.1 → destiny_sdk-0.7.1}/LICENSE +0 -0
- {destiny_sdk-0.5.1 → destiny_sdk-0.7.1}/src/destiny_sdk/auth.py +0 -0
- {destiny_sdk-0.5.1 → destiny_sdk-0.7.1}/src/destiny_sdk/imports.py +0 -0
- {destiny_sdk-0.5.1 → destiny_sdk-0.7.1}/src/destiny_sdk/parsers/__init__.py +0 -0
- {destiny_sdk-0.5.1 → destiny_sdk-0.7.1}/src/destiny_sdk/py.typed +0 -0
- {destiny_sdk-0.5.1 → destiny_sdk-0.7.1}/src/destiny_sdk/robots.py +0 -0
- {destiny_sdk-0.5.1 → destiny_sdk-0.7.1}/src/destiny_sdk/visibility.py +0 -0
- {destiny_sdk-0.5.1 → destiny_sdk-0.7.1}/tests/unit/__init__.py +0 -0
- {destiny_sdk-0.5.1 → destiny_sdk-0.7.1}/tests/unit/conftest.py +0 -0
- {destiny_sdk-0.5.1 → destiny_sdk-0.7.1}/tests/unit/test_auth.py +0 -0
- {destiny_sdk-0.5.1 → destiny_sdk-0.7.1}/tests/unit/test_client.py +0 -0
- {destiny_sdk-0.5.1 → destiny_sdk-0.7.1}/tests/unit/test_robots.py +0 -0
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: destiny_sdk
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.7.1
|
|
4
4
|
Summary: A software development kit (sdk) to support interaction with the DESTINY repository
|
|
5
|
-
Author-email: Adam Hamilton <adam@futureevidence.org>, Andrew Harvey <andrew@futureevidence.org>, Daniel Breves <daniel@futureevidence.org>, Jack Walmisley <jack@futureevidence.org>
|
|
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
|
|
7
7
|
License-File: LICENSE
|
|
8
8
|
Requires-Python: ~=3.12
|
|
@@ -14,6 +14,7 @@ Requires-Dist: pytest-asyncio<2,>=1.0.0
|
|
|
14
14
|
Requires-Dist: pytest-httpx<0.36,>=0.35.0
|
|
15
15
|
Requires-Dist: pytest<9,>=8.4.0
|
|
16
16
|
Requires-Dist: python-jose<4,>=3.4.0
|
|
17
|
+
Provides-Extra: labs
|
|
17
18
|
Description-Content-Type: text/markdown
|
|
18
19
|
|
|
19
20
|
# DESTINY SDK
|
|
@@ -34,6 +35,13 @@ pip install destiny-sdk
|
|
|
34
35
|
uv add destiny-sdk
|
|
35
36
|
```
|
|
36
37
|
|
|
38
|
+
Some labs functionality may require extra dependencies - these can be installed by:
|
|
39
|
+
|
|
40
|
+
```sh
|
|
41
|
+
pip install destiny-sdk[labs]
|
|
42
|
+
uv add destiny-sdk --extra labs
|
|
43
|
+
```
|
|
44
|
+
|
|
37
45
|
## Development
|
|
38
46
|
|
|
39
47
|
### Dependencies
|
|
@@ -16,6 +16,13 @@ pip install destiny-sdk
|
|
|
16
16
|
uv add destiny-sdk
|
|
17
17
|
```
|
|
18
18
|
|
|
19
|
+
Some labs functionality may require extra dependencies - these can be installed by:
|
|
20
|
+
|
|
21
|
+
```sh
|
|
22
|
+
pip install destiny-sdk[labs]
|
|
23
|
+
uv add destiny-sdk --extra labs
|
|
24
|
+
```
|
|
25
|
+
|
|
19
26
|
## Development
|
|
20
27
|
|
|
21
28
|
### Dependencies
|
|
@@ -17,6 +17,7 @@ authors = [
|
|
|
17
17
|
{email = "andrew@futureevidence.org", name = "Andrew Harvey"},
|
|
18
18
|
{email = "daniel@futureevidence.org", name = "Daniel Breves"},
|
|
19
19
|
{email = "jack@futureevidence.org", name = "Jack Walmisley"},
|
|
20
|
+
{email = "tim.repke@pik-potsdam.de", name = "Tim Repke"},
|
|
20
21
|
]
|
|
21
22
|
dependencies = [
|
|
22
23
|
"cachetools>=5.5.2,<6",
|
|
@@ -33,7 +34,10 @@ license = "Apache-2.0"
|
|
|
33
34
|
name = "destiny_sdk"
|
|
34
35
|
readme = "README.md"
|
|
35
36
|
requires-python = "~=3.12"
|
|
36
|
-
version = "0.
|
|
37
|
+
version = "0.7.1"
|
|
38
|
+
|
|
39
|
+
[project.optional-dependencies]
|
|
40
|
+
labs = []
|
|
37
41
|
|
|
38
42
|
[tool.pytest.ini_options]
|
|
39
43
|
addopts = ["--color=yes", "--import-mode=importlib", "--verbose"]
|
|
@@ -114,7 +114,11 @@ class Client:
|
|
|
114
114
|
return RobotEnhancementBatchRead.model_validate(response.json())
|
|
115
115
|
|
|
116
116
|
def poll_robot_enhancement_batch(
|
|
117
|
-
self,
|
|
117
|
+
self,
|
|
118
|
+
robot_id: UUID4,
|
|
119
|
+
limit: int = 10,
|
|
120
|
+
lease: str | None = None,
|
|
121
|
+
timeout: int = 60,
|
|
118
122
|
) -> RobotEnhancementBatch | None:
|
|
119
123
|
"""
|
|
120
124
|
Poll for a robot enhancement batch.
|
|
@@ -125,13 +129,20 @@ class Client:
|
|
|
125
129
|
:type robot_id: UUID4
|
|
126
130
|
:param limit: The maximum number of pending enhancements to return
|
|
127
131
|
:type limit: int
|
|
132
|
+
:param lease: The duration to lease the pending enhancements for,
|
|
133
|
+
in ISO 8601 duration format eg PT10M. If not provided the repository will
|
|
134
|
+
use a default lease duration.
|
|
135
|
+
:type lease: str | None
|
|
128
136
|
:return: The RobotEnhancementBatch object from the response, or None if no
|
|
129
137
|
batches available
|
|
130
138
|
:rtype: destiny_sdk.robots.RobotEnhancementBatch | None
|
|
131
139
|
"""
|
|
140
|
+
params = {"robot_id": str(robot_id), "limit": limit}
|
|
141
|
+
if lease:
|
|
142
|
+
params["lease"] = lease
|
|
132
143
|
response = self.session.post(
|
|
133
144
|
"/robot-enhancement-batches/",
|
|
134
|
-
params=
|
|
145
|
+
params=params,
|
|
135
146
|
timeout=timeout,
|
|
136
147
|
)
|
|
137
148
|
# HTTP 204 No Content indicates no batches available
|
|
@@ -140,3 +151,24 @@ class Client:
|
|
|
140
151
|
|
|
141
152
|
response.raise_for_status()
|
|
142
153
|
return RobotEnhancementBatch.model_validate(response.json())
|
|
154
|
+
|
|
155
|
+
def renew_robot_enhancement_batch_lease(
|
|
156
|
+
self, robot_enhancement_batch_id: UUID4, lease_duration: str | None = None
|
|
157
|
+
) -> None:
|
|
158
|
+
"""
|
|
159
|
+
Renew the lease for a robot enhancement batch.
|
|
160
|
+
|
|
161
|
+
Signs the request with the client's secret key.
|
|
162
|
+
|
|
163
|
+
:param robot_enhancement_batch_id: The ID of the robot enhancement batch
|
|
164
|
+
:type robot_enhancement_batch_id: UUID4
|
|
165
|
+
:param lease_duration: The duration to lease the pending enhancements for,
|
|
166
|
+
in ISO 8601 duration format eg PT10M. If not provided the repository will
|
|
167
|
+
use a default lease duration.
|
|
168
|
+
:type lease_duration: str | None
|
|
169
|
+
"""
|
|
170
|
+
response = self.session.post(
|
|
171
|
+
f"/robot-enhancement-batches/{robot_enhancement_batch_id}/renew-lease/",
|
|
172
|
+
params={"lease": lease_duration} if lease_duration else None,
|
|
173
|
+
)
|
|
174
|
+
response.raise_for_status()
|
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
from typing import Self
|
|
4
4
|
|
|
5
|
-
from pydantic import BaseModel
|
|
5
|
+
from pydantic import BaseModel, Field
|
|
6
|
+
|
|
7
|
+
from destiny_sdk.search import SearchResultPage, SearchResultTotal
|
|
6
8
|
|
|
7
9
|
# These are non-standard newline characters that are not escaped by model_dump_json().
|
|
8
10
|
# We want jsonl files to have empirical new lines so they can be streamed line by line.
|
|
@@ -47,3 +49,14 @@ class _JsonlFileInputMixIn(BaseModel):
|
|
|
47
49
|
:rtype: Self
|
|
48
50
|
"""
|
|
49
51
|
return cls.model_validate_json(jsonl)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class SearchResultMixIn(BaseModel):
|
|
55
|
+
"""A mixin class for models that represent search results."""
|
|
56
|
+
|
|
57
|
+
total: SearchResultTotal = Field(
|
|
58
|
+
description="The total number of results matching the search criteria.",
|
|
59
|
+
)
|
|
60
|
+
page: SearchResultPage = Field(
|
|
61
|
+
description="Information about the page of results.",
|
|
62
|
+
)
|
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
import datetime
|
|
4
4
|
from enum import StrEnum, auto
|
|
5
|
-
from typing import Annotated, Literal
|
|
5
|
+
from typing import Annotated, Any, Literal, Self
|
|
6
6
|
|
|
7
|
-
from pydantic import UUID4, BaseModel, Field, HttpUrl
|
|
7
|
+
from pydantic import UUID4, BaseModel, Field, HttpUrl, model_validator
|
|
8
8
|
|
|
9
9
|
from destiny_sdk.core import _JsonlFileInputMixIn
|
|
10
10
|
from destiny_sdk.visibility import Visibility
|
|
@@ -25,6 +25,8 @@ class EnhancementType(StrEnum):
|
|
|
25
25
|
"""A free-form enhancement for tagging with labels."""
|
|
26
26
|
LOCATION = auto()
|
|
27
27
|
"""Locations where the reference can be found."""
|
|
28
|
+
RAW = auto()
|
|
29
|
+
"""A free form enhancement for arbitrary/unstructured data."""
|
|
28
30
|
FULL_TEXT = auto()
|
|
29
31
|
"""The full text of the reference. (To be implemented)"""
|
|
30
32
|
|
|
@@ -145,22 +147,33 @@ class AnnotationType(StrEnum):
|
|
|
145
147
|
"""
|
|
146
148
|
|
|
147
149
|
|
|
148
|
-
class
|
|
149
|
-
"""
|
|
150
|
-
An annotation which represents the score for a label.
|
|
150
|
+
class BaseAnnotation(BaseModel):
|
|
151
|
+
"""Base class for annotations, defining the minimal required fields."""
|
|
151
152
|
|
|
152
|
-
This is similar to a BooleanAnnotation, but lacks a boolean determination
|
|
153
|
-
as to the application of the label.
|
|
154
|
-
"""
|
|
155
|
-
|
|
156
|
-
annotation_type: Literal[AnnotationType.SCORE] = AnnotationType.SCORE
|
|
157
153
|
scheme: str = Field(
|
|
158
154
|
description="An identifier for the scheme of annotation",
|
|
159
155
|
examples=["openalex:topic", "pubmed:mesh"],
|
|
156
|
+
pattern=r"^[^/]+$", # No slashes allowed
|
|
160
157
|
)
|
|
161
158
|
label: str = Field(
|
|
162
159
|
description="A high level label for this annotation like the name of the topic",
|
|
163
160
|
)
|
|
161
|
+
|
|
162
|
+
@property
|
|
163
|
+
def qualified_label(self) -> str:
|
|
164
|
+
"""The qualified label for this annotation."""
|
|
165
|
+
return f"{self.scheme}/{self.label}"
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
class ScoreAnnotation(BaseAnnotation):
|
|
169
|
+
"""
|
|
170
|
+
An annotation which represents the score for a label.
|
|
171
|
+
|
|
172
|
+
This is similar to a BooleanAnnotation, but lacks a boolean determination
|
|
173
|
+
as to the application of the label.
|
|
174
|
+
"""
|
|
175
|
+
|
|
176
|
+
annotation_type: Literal[AnnotationType.SCORE] = AnnotationType.SCORE
|
|
164
177
|
score: float = Field(description="""Score for this annotation""")
|
|
165
178
|
data: dict = Field(
|
|
166
179
|
default_factory=dict,
|
|
@@ -171,7 +184,7 @@ class ScoreAnnotation(BaseModel):
|
|
|
171
184
|
)
|
|
172
185
|
|
|
173
186
|
|
|
174
|
-
class BooleanAnnotation(
|
|
187
|
+
class BooleanAnnotation(BaseAnnotation):
|
|
175
188
|
"""
|
|
176
189
|
An annotation is a way of tagging the content with a label of some kind.
|
|
177
190
|
|
|
@@ -180,13 +193,6 @@ class BooleanAnnotation(BaseModel):
|
|
|
180
193
|
"""
|
|
181
194
|
|
|
182
195
|
annotation_type: Literal[AnnotationType.BOOLEAN] = AnnotationType.BOOLEAN
|
|
183
|
-
scheme: str = Field(
|
|
184
|
-
description="An identifier for the scheme of the annotation",
|
|
185
|
-
examples=["openalex:topic", "pubmed:mesh"],
|
|
186
|
-
)
|
|
187
|
-
label: str = Field(
|
|
188
|
-
description="A high level label for this annotation like the name of the topic",
|
|
189
|
-
)
|
|
190
196
|
value: bool = Field(description="""Boolean flag for this annotation""")
|
|
191
197
|
score: float | None = Field(
|
|
192
198
|
None, description="A confidence score for this annotation"
|
|
@@ -295,12 +301,45 @@ class LocationEnhancement(BaseModel):
|
|
|
295
301
|
)
|
|
296
302
|
|
|
297
303
|
|
|
304
|
+
class RawEnhancement(BaseModel):
|
|
305
|
+
"""
|
|
306
|
+
An enhancement for storing raw/arbitrary/unstructured data.
|
|
307
|
+
|
|
308
|
+
Data in these enhancements is intended for future conversion into structured form.
|
|
309
|
+
|
|
310
|
+
This enhancement accepts any fields passed in to `data`. These enhancements cannot
|
|
311
|
+
be created by robots.
|
|
312
|
+
"""
|
|
313
|
+
|
|
314
|
+
enhancement_type: Literal[EnhancementType.RAW] = EnhancementType.RAW
|
|
315
|
+
source_export_date: datetime.datetime = Field(
|
|
316
|
+
description="Date the enhancement data was retrieved."
|
|
317
|
+
)
|
|
318
|
+
description: str = Field(
|
|
319
|
+
description="Description of the data to aid in future refinement."
|
|
320
|
+
)
|
|
321
|
+
metadata: dict[str, Any] = Field(
|
|
322
|
+
default_factory=dict,
|
|
323
|
+
description="Additional metadata to aid in future structuring of raw data",
|
|
324
|
+
)
|
|
325
|
+
data: Any = Field(description="Unstructured data for later processing.")
|
|
326
|
+
|
|
327
|
+
@model_validator(mode="after")
|
|
328
|
+
def forbid_no_data(self) -> Self:
|
|
329
|
+
"""Prevent a raw enhancement from being created with no data."""
|
|
330
|
+
if not self.data:
|
|
331
|
+
msg = "data must be populated on a raw enhancement."
|
|
332
|
+
raise ValueError(msg)
|
|
333
|
+
return self
|
|
334
|
+
|
|
335
|
+
|
|
298
336
|
#: Union type for all enhancement content types.
|
|
299
337
|
EnhancementContent = Annotated[
|
|
300
338
|
BibliographicMetadataEnhancement
|
|
301
339
|
| AbstractContentEnhancement
|
|
302
340
|
| AnnotationEnhancement
|
|
303
|
-
| LocationEnhancement
|
|
341
|
+
| LocationEnhancement
|
|
342
|
+
| RawEnhancement,
|
|
304
343
|
Field(discriminator="enhancement_type"),
|
|
305
344
|
]
|
|
306
345
|
|
|
@@ -17,8 +17,14 @@ class ExternalIdentifierType(StrEnum):
|
|
|
17
17
|
|
|
18
18
|
DOI = auto()
|
|
19
19
|
"""A DOI (Digital Object Identifier) which is a unique identifier for a document."""
|
|
20
|
+
ERIC = auto()
|
|
21
|
+
"""An ERIC (Education Resources Information Identifier) ID which is a unique
|
|
22
|
+
identifier for a document in ERIC.
|
|
23
|
+
"""
|
|
20
24
|
PM_ID = auto()
|
|
21
25
|
"""A PubMed ID which is a unique identifier for a document in PubMed."""
|
|
26
|
+
PRO_QUEST = auto()
|
|
27
|
+
"""A ProQuest ID which is a unqiue identifier for a document in ProQuest."""
|
|
22
28
|
OPEN_ALEX = auto()
|
|
23
29
|
"""An OpenAlex ID which is a unique identifier for a document in OpenAlex."""
|
|
24
30
|
OTHER = auto()
|
|
@@ -41,8 +47,64 @@ class DOIIdentifier(BaseModel):
|
|
|
41
47
|
def remove_doi_url(cls, value: str) -> str:
|
|
42
48
|
"""Remove the URL part of the DOI if it exists."""
|
|
43
49
|
return (
|
|
44
|
-
value.removeprefix("http://
|
|
45
|
-
.removeprefix("https://
|
|
50
|
+
value.removeprefix("http://")
|
|
51
|
+
.removeprefix("https://")
|
|
52
|
+
.removeprefix("doi.org/")
|
|
53
|
+
.removeprefix("dx.doi.org/")
|
|
54
|
+
.removeprefix("doi:")
|
|
55
|
+
.strip()
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class ProQuestIdentifier(BaseModel):
|
|
60
|
+
"""An external identifier representing a ProQuest ID."""
|
|
61
|
+
|
|
62
|
+
identifier: str = Field(
|
|
63
|
+
description="The ProQuest id of the reference", pattern=r"[0-9]+$"
|
|
64
|
+
)
|
|
65
|
+
identifier_type: Literal[ExternalIdentifierType.PRO_QUEST] = Field(
|
|
66
|
+
ExternalIdentifierType.PRO_QUEST, description="The type of identifier used."
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
@field_validator("identifier", mode="before")
|
|
70
|
+
@classmethod
|
|
71
|
+
def remove_proquest_url(cls, value: str) -> str:
|
|
72
|
+
"""Remove the URL part of the ProQuest id if it exists."""
|
|
73
|
+
return (
|
|
74
|
+
value.removeprefix("http://")
|
|
75
|
+
.removeprefix("https://")
|
|
76
|
+
.removeprefix("search.proquest.com/")
|
|
77
|
+
.removeprefix("www.proquest.com/")
|
|
78
|
+
.removeprefix("docview/")
|
|
79
|
+
.strip()
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class ERICIdentifier(BaseModel):
|
|
84
|
+
"""
|
|
85
|
+
An external identifier representing an ERIC Number.
|
|
86
|
+
|
|
87
|
+
An ERIC Number is defined as a unqiue identifiying number preceeded by
|
|
88
|
+
EJ (for a journal article) or ED (for a non-journal document).
|
|
89
|
+
"""
|
|
90
|
+
|
|
91
|
+
identifier: str = Field(
|
|
92
|
+
description="The ERIC Number of the reference.", pattern=r"E[D|J][0-9]+$"
|
|
93
|
+
)
|
|
94
|
+
identifier_type: Literal[ExternalIdentifierType.ERIC] = Field(
|
|
95
|
+
ExternalIdentifierType.ERIC, description="The type of identifier used."
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
@field_validator("identifier", mode="before")
|
|
99
|
+
@classmethod
|
|
100
|
+
def remove_eric_url(cls, value: str) -> str:
|
|
101
|
+
"""Remove the URL part of the ERIC ID if it exists."""
|
|
102
|
+
return (
|
|
103
|
+
value.removeprefix("http://")
|
|
104
|
+
.removeprefix("https://")
|
|
105
|
+
.removeprefix("eric.ed.gov/?id=")
|
|
106
|
+
.removeprefix("files.eric.ed.gov/fulltext/")
|
|
107
|
+
.removesuffix(".pdf")
|
|
46
108
|
.strip()
|
|
47
109
|
)
|
|
48
110
|
|
|
@@ -71,8 +133,11 @@ class OpenAlexIdentifier(BaseModel):
|
|
|
71
133
|
def remove_open_alex_url(cls, value: str) -> str:
|
|
72
134
|
"""Remove the OpenAlex URL if it exists."""
|
|
73
135
|
return (
|
|
74
|
-
value.removeprefix("http://
|
|
75
|
-
.removeprefix("https://
|
|
136
|
+
value.removeprefix("http://")
|
|
137
|
+
.removeprefix("https://")
|
|
138
|
+
.removeprefix("openalex.org/")
|
|
139
|
+
.removeprefix("explore.openalex.org/")
|
|
140
|
+
.removeprefix("works/")
|
|
76
141
|
.strip()
|
|
77
142
|
)
|
|
78
143
|
|
|
@@ -91,7 +156,12 @@ class OtherIdentifier(BaseModel):
|
|
|
91
156
|
|
|
92
157
|
#: Union type for all external identifiers.
|
|
93
158
|
ExternalIdentifier = Annotated[
|
|
94
|
-
DOIIdentifier
|
|
159
|
+
DOIIdentifier
|
|
160
|
+
| ERICIdentifier
|
|
161
|
+
| PubMedIdentifier
|
|
162
|
+
| ProQuestIdentifier
|
|
163
|
+
| OpenAlexIdentifier
|
|
164
|
+
| OtherIdentifier,
|
|
95
165
|
Field(discriminator="identifier_type"),
|
|
96
166
|
]
|
|
97
167
|
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Extended Reference SDK.
|
|
3
|
+
|
|
4
|
+
Extended Reference class for the Destiny SDK
|
|
5
|
+
with added experimental convenience methods and properties.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from collections.abc import Generator
|
|
9
|
+
from typing import cast
|
|
10
|
+
|
|
11
|
+
from pydantic import BaseModel, Field
|
|
12
|
+
|
|
13
|
+
from destiny_sdk.enhancements import (
|
|
14
|
+
Annotation,
|
|
15
|
+
AnnotationType,
|
|
16
|
+
BibliographicMetadataEnhancement,
|
|
17
|
+
EnhancementType,
|
|
18
|
+
)
|
|
19
|
+
from destiny_sdk.identifiers import ExternalIdentifierType
|
|
20
|
+
from destiny_sdk.references import Reference
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class LabsReference(BaseModel):
|
|
24
|
+
"""Experimental presenter class for Reference with added convenience methods."""
|
|
25
|
+
|
|
26
|
+
reference: Reference = Field(
|
|
27
|
+
...,
|
|
28
|
+
description="The core Reference object",
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
def _get_id(self, identifier_type: ExternalIdentifierType) -> str | int | None:
|
|
32
|
+
"""Fetch an identifier matching the given identifier_type."""
|
|
33
|
+
for identifier in self.reference.identifiers or []:
|
|
34
|
+
if identifier.identifier_type == identifier_type:
|
|
35
|
+
return identifier.identifier
|
|
36
|
+
return None
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def openalex_id(self) -> str | None:
|
|
40
|
+
"""Return an OpenAlex ID for the reference."""
|
|
41
|
+
return cast(
|
|
42
|
+
str | None, self._get_id(identifier_type=ExternalIdentifierType.OPEN_ALEX)
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def doi(self) -> str | None:
|
|
47
|
+
"""Return a DOI for the reference."""
|
|
48
|
+
return cast(
|
|
49
|
+
str | None, self._get_id(identifier_type=ExternalIdentifierType.DOI)
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def pubmed_id(self) -> int | None:
|
|
54
|
+
"""Return a pubmed ID for the reference."""
|
|
55
|
+
return cast(
|
|
56
|
+
int | None, self._get_id(identifier_type=ExternalIdentifierType.PM_ID)
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def abstract(self) -> str | None:
|
|
61
|
+
"""Return an abstract for the reference."""
|
|
62
|
+
for enhancement in self.reference.enhancements or []:
|
|
63
|
+
if enhancement.content.enhancement_type == EnhancementType.ABSTRACT:
|
|
64
|
+
return enhancement.content.abstract
|
|
65
|
+
return None
|
|
66
|
+
|
|
67
|
+
@property
|
|
68
|
+
def publication_year(self) -> int | None:
|
|
69
|
+
"""Return a publication year for the reference."""
|
|
70
|
+
for meta in self.it_bibliographics():
|
|
71
|
+
if meta.publication_year is not None:
|
|
72
|
+
return meta.publication_year
|
|
73
|
+
return None
|
|
74
|
+
|
|
75
|
+
@property
|
|
76
|
+
def title(self) -> str | None:
|
|
77
|
+
"""The title of the reference. If multiple are present, return first one."""
|
|
78
|
+
for meta in self.it_bibliographics():
|
|
79
|
+
if meta.title is not None:
|
|
80
|
+
return meta.title
|
|
81
|
+
return None
|
|
82
|
+
|
|
83
|
+
def it_bibliographics(
|
|
84
|
+
self,
|
|
85
|
+
) -> Generator[BibliographicMetadataEnhancement, None, None]:
|
|
86
|
+
"""Iterate bibliographic enhancements."""
|
|
87
|
+
for enhancement in self.reference.enhancements or []:
|
|
88
|
+
if enhancement.content.enhancement_type == EnhancementType.BIBLIOGRAPHIC:
|
|
89
|
+
yield enhancement.content
|
|
90
|
+
|
|
91
|
+
def it_annotations(
|
|
92
|
+
self,
|
|
93
|
+
source: str | None = None,
|
|
94
|
+
annotation_type: AnnotationType | None = None,
|
|
95
|
+
scheme: str | None = None,
|
|
96
|
+
label: str | None = None,
|
|
97
|
+
) -> Generator[Annotation, None, None]:
|
|
98
|
+
"""
|
|
99
|
+
Iterate annotation enhancements for the given filters.
|
|
100
|
+
|
|
101
|
+
:param source: Optional filter for Enhancement.source
|
|
102
|
+
:param annotation_type: Optional filter for
|
|
103
|
+
AnnotationEnhancement.annotation_type
|
|
104
|
+
:param scheme: Optional filter for Annotation.scheme
|
|
105
|
+
:param label: Optional filter for Annotation.label
|
|
106
|
+
"""
|
|
107
|
+
for enhancement in self.reference.enhancements or []:
|
|
108
|
+
if enhancement.content.enhancement_type == EnhancementType.ANNOTATION:
|
|
109
|
+
if source is not None and enhancement.source != source:
|
|
110
|
+
continue
|
|
111
|
+
for annotation in enhancement.content.annotations:
|
|
112
|
+
if (
|
|
113
|
+
annotation_type is not None
|
|
114
|
+
and annotation.annotation_type != annotation_type
|
|
115
|
+
):
|
|
116
|
+
continue
|
|
117
|
+
if scheme is not None and annotation.scheme != scheme:
|
|
118
|
+
continue
|
|
119
|
+
if label is not None and annotation.label != label:
|
|
120
|
+
continue
|
|
121
|
+
yield annotation
|
|
122
|
+
|
|
123
|
+
def has_bool_annotation(
|
|
124
|
+
self,
|
|
125
|
+
source: str | None = None,
|
|
126
|
+
scheme: str | None = None,
|
|
127
|
+
label: str | None = None,
|
|
128
|
+
expected_value: bool = True, # noqa: FBT001, FBT002
|
|
129
|
+
) -> bool | None:
|
|
130
|
+
"""
|
|
131
|
+
Check if a specific annotation exists and is true.
|
|
132
|
+
|
|
133
|
+
:param source: Optional filter for Enhancement.source
|
|
134
|
+
:param scheme: Optional filter for Annotation.scheme
|
|
135
|
+
:param label: Optional filter for Annotation.label
|
|
136
|
+
:param expected_value: Specify expected boolean annotation value
|
|
137
|
+
:return: Returns the boolean value for the first annotation matching
|
|
138
|
+
the filters or None if nothing is found.
|
|
139
|
+
"""
|
|
140
|
+
if scheme is None and label is None:
|
|
141
|
+
msg = "Please use at least one of the optional scheme or label filters."
|
|
142
|
+
raise AssertionError(msg)
|
|
143
|
+
|
|
144
|
+
found_annotation = False
|
|
145
|
+
for annotation in self.it_annotations(
|
|
146
|
+
source=source,
|
|
147
|
+
annotation_type=AnnotationType.BOOLEAN,
|
|
148
|
+
scheme=scheme,
|
|
149
|
+
label=label,
|
|
150
|
+
):
|
|
151
|
+
if annotation.value == expected_value:
|
|
152
|
+
return True
|
|
153
|
+
found_annotation = True
|
|
154
|
+
return False if found_annotation else None
|