destiny_sdk 0.5.0__tar.gz → 0.5.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.0 → destiny_sdk-0.5.1}/.gitignore +4 -0
- {destiny_sdk-0.5.0 → destiny_sdk-0.5.1}/PKG-INFO +1 -1
- {destiny_sdk-0.5.0 → destiny_sdk-0.5.1}/pyproject.toml +1 -1
- destiny_sdk-0.5.1/src/destiny_sdk/identifiers.py +192 -0
- destiny_sdk-0.5.1/tests/unit/test_identifiers.py +320 -0
- {destiny_sdk-0.5.0 → destiny_sdk-0.5.1}/uv.lock +1 -1
- destiny_sdk-0.5.0/src/destiny_sdk/identifiers.py +0 -107
- destiny_sdk-0.5.0/tests/unit/test_identifiers.py +0 -104
- {destiny_sdk-0.5.0 → destiny_sdk-0.5.1}/LICENSE +0 -0
- {destiny_sdk-0.5.0 → destiny_sdk-0.5.1}/README.md +0 -0
- {destiny_sdk-0.5.0 → destiny_sdk-0.5.1}/src/destiny_sdk/__init__.py +0 -0
- {destiny_sdk-0.5.0 → destiny_sdk-0.5.1}/src/destiny_sdk/auth.py +0 -0
- {destiny_sdk-0.5.0 → destiny_sdk-0.5.1}/src/destiny_sdk/client.py +0 -0
- {destiny_sdk-0.5.0 → destiny_sdk-0.5.1}/src/destiny_sdk/core.py +0 -0
- {destiny_sdk-0.5.0 → destiny_sdk-0.5.1}/src/destiny_sdk/enhancements.py +0 -0
- {destiny_sdk-0.5.0 → destiny_sdk-0.5.1}/src/destiny_sdk/imports.py +0 -0
- {destiny_sdk-0.5.0 → destiny_sdk-0.5.1}/src/destiny_sdk/parsers/__init__.py +0 -0
- {destiny_sdk-0.5.0 → destiny_sdk-0.5.1}/src/destiny_sdk/parsers/eppi_parser.py +0 -0
- {destiny_sdk-0.5.0 → destiny_sdk-0.5.1}/src/destiny_sdk/py.typed +0 -0
- {destiny_sdk-0.5.0 → destiny_sdk-0.5.1}/src/destiny_sdk/references.py +0 -0
- {destiny_sdk-0.5.0 → destiny_sdk-0.5.1}/src/destiny_sdk/robots.py +0 -0
- {destiny_sdk-0.5.0 → destiny_sdk-0.5.1}/src/destiny_sdk/visibility.py +0 -0
- {destiny_sdk-0.5.0 → destiny_sdk-0.5.1}/tests/unit/__init__.py +0 -0
- {destiny_sdk-0.5.0 → destiny_sdk-0.5.1}/tests/unit/conftest.py +0 -0
- {destiny_sdk-0.5.0 → destiny_sdk-0.5.1}/tests/unit/parsers/test_eppi_parser.py +0 -0
- {destiny_sdk-0.5.0 → destiny_sdk-0.5.1}/tests/unit/test_auth.py +0 -0
- {destiny_sdk-0.5.0 → destiny_sdk-0.5.1}/tests/unit/test_client.py +0 -0
- {destiny_sdk-0.5.0 → destiny_sdk-0.5.1}/tests/unit/test_data/eppi_import.jsonl +0 -0
- {destiny_sdk-0.5.0 → destiny_sdk-0.5.1}/tests/unit/test_data/eppi_import_with_annotations.jsonl +0 -0
- {destiny_sdk-0.5.0 → destiny_sdk-0.5.1}/tests/unit/test_data/eppi_report.json +0 -0
- {destiny_sdk-0.5.0 → destiny_sdk-0.5.1}/tests/unit/test_enhancements.py +0 -0
- {destiny_sdk-0.5.0 → destiny_sdk-0.5.1}/tests/unit/test_references.py +0 -0
- {destiny_sdk-0.5.0 → destiny_sdk-0.5.1}/tests/unit/test_robots.py +0 -0
|
@@ -15,6 +15,7 @@ downloads/
|
|
|
15
15
|
eggs/
|
|
16
16
|
.eggs/
|
|
17
17
|
lib/
|
|
18
|
+
!/ui/lib/
|
|
18
19
|
lib64/
|
|
19
20
|
parts/
|
|
20
21
|
sdist/
|
|
@@ -186,6 +187,9 @@ cython_debug/
|
|
|
186
187
|
# Mac Finder config
|
|
187
188
|
.DS_Store
|
|
188
189
|
|
|
190
|
+
# Include everything in ui (has its own .gitignore)
|
|
191
|
+
!/ui/
|
|
192
|
+
|
|
189
193
|
# delete-me working files
|
|
190
194
|
tmp-scripts.sh
|
|
191
195
|
infra/app/*.py
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: destiny_sdk
|
|
3
|
-
Version: 0.5.
|
|
3
|
+
Version: 0.5.1
|
|
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>
|
|
6
6
|
License-Expression: Apache-2.0
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
"""Identifier classes for the Destiny SDK."""
|
|
2
|
+
|
|
3
|
+
import uuid
|
|
4
|
+
from enum import StrEnum, auto
|
|
5
|
+
from typing import Annotated, Literal, Self
|
|
6
|
+
|
|
7
|
+
from pydantic import UUID4, BaseModel, Field, TypeAdapter, field_validator
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ExternalIdentifierType(StrEnum):
|
|
11
|
+
"""
|
|
12
|
+
The type of identifier used to identify a reference.
|
|
13
|
+
|
|
14
|
+
This is used to identify the type of identifier used in the `ExternalIdentifier`
|
|
15
|
+
class.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
DOI = auto()
|
|
19
|
+
"""A DOI (Digital Object Identifier) which is a unique identifier for a document."""
|
|
20
|
+
PM_ID = auto()
|
|
21
|
+
"""A PubMed ID which is a unique identifier for a document in PubMed."""
|
|
22
|
+
OPEN_ALEX = auto()
|
|
23
|
+
"""An OpenAlex ID which is a unique identifier for a document in OpenAlex."""
|
|
24
|
+
OTHER = auto()
|
|
25
|
+
"""Any other identifier not defined. This should be used sparingly."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class DOIIdentifier(BaseModel):
|
|
29
|
+
"""An external identifier representing a DOI."""
|
|
30
|
+
|
|
31
|
+
identifier: str = Field(
|
|
32
|
+
description="The DOI of the reference.",
|
|
33
|
+
pattern=r"^10\.\d{4,9}/[-._;()/:a-zA-Z0-9%<>\[\]+&]+$",
|
|
34
|
+
)
|
|
35
|
+
identifier_type: Literal[ExternalIdentifierType.DOI] = Field(
|
|
36
|
+
ExternalIdentifierType.DOI, description="The type of identifier used."
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
@field_validator("identifier", mode="before")
|
|
40
|
+
@classmethod
|
|
41
|
+
def remove_doi_url(cls, value: str) -> str:
|
|
42
|
+
"""Remove the URL part of the DOI if it exists."""
|
|
43
|
+
return (
|
|
44
|
+
value.removeprefix("http://doi.org/")
|
|
45
|
+
.removeprefix("https://doi.org/")
|
|
46
|
+
.strip()
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class PubMedIdentifier(BaseModel):
|
|
51
|
+
"""An external identifier representing a PubMed ID."""
|
|
52
|
+
|
|
53
|
+
identifier: int = Field(description="The PubMed ID of the reference.")
|
|
54
|
+
identifier_type: Literal[ExternalIdentifierType.PM_ID] = Field(
|
|
55
|
+
ExternalIdentifierType.PM_ID, description="The type of identifier used."
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class OpenAlexIdentifier(BaseModel):
|
|
60
|
+
"""An external identifier representing an OpenAlex ID."""
|
|
61
|
+
|
|
62
|
+
identifier: str = Field(
|
|
63
|
+
description="The OpenAlex ID of the reference.", pattern=r"^W\d+$"
|
|
64
|
+
)
|
|
65
|
+
identifier_type: Literal[ExternalIdentifierType.OPEN_ALEX] = Field(
|
|
66
|
+
ExternalIdentifierType.OPEN_ALEX, description="The type of identifier used."
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
@field_validator("identifier", mode="before")
|
|
70
|
+
@classmethod
|
|
71
|
+
def remove_open_alex_url(cls, value: str) -> str:
|
|
72
|
+
"""Remove the OpenAlex URL if it exists."""
|
|
73
|
+
return (
|
|
74
|
+
value.removeprefix("http://openalex.org/")
|
|
75
|
+
.removeprefix("https://openalex.org/")
|
|
76
|
+
.strip()
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class OtherIdentifier(BaseModel):
|
|
81
|
+
"""An external identifier not otherwise defined by the repository."""
|
|
82
|
+
|
|
83
|
+
identifier: str = Field(description="The identifier of the reference.")
|
|
84
|
+
identifier_type: Literal[ExternalIdentifierType.OTHER] = Field(
|
|
85
|
+
ExternalIdentifierType.OTHER, description="The type of identifier used."
|
|
86
|
+
)
|
|
87
|
+
other_identifier_name: str = Field(
|
|
88
|
+
description="The name of the undocumented identifier type."
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
#: Union type for all external identifiers.
|
|
93
|
+
ExternalIdentifier = Annotated[
|
|
94
|
+
DOIIdentifier | PubMedIdentifier | OpenAlexIdentifier | OtherIdentifier,
|
|
95
|
+
Field(discriminator="identifier_type"),
|
|
96
|
+
]
|
|
97
|
+
|
|
98
|
+
ExternalIdentifierAdapter: TypeAdapter[ExternalIdentifier] = TypeAdapter(
|
|
99
|
+
ExternalIdentifier
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class LinkedExternalIdentifier(BaseModel):
|
|
104
|
+
"""An external identifier which identifies a reference."""
|
|
105
|
+
|
|
106
|
+
identifier: ExternalIdentifier = Field(
|
|
107
|
+
description="The identifier of the reference.",
|
|
108
|
+
discriminator="identifier_type",
|
|
109
|
+
)
|
|
110
|
+
reference_id: UUID4 = Field(
|
|
111
|
+
description="The ID of the reference this identifier identifies."
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class IdentifierLookup(BaseModel):
|
|
116
|
+
"""An external identifier lookup."""
|
|
117
|
+
|
|
118
|
+
identifier: str = Field(description="The identifier value.")
|
|
119
|
+
identifier_type: ExternalIdentifierType | None = Field(
|
|
120
|
+
description="The type of identifier used. If not provided, it is assumed to"
|
|
121
|
+
" be a DESTINY identifier.",
|
|
122
|
+
)
|
|
123
|
+
other_identifier_name: str | None = Field(
|
|
124
|
+
default=None,
|
|
125
|
+
description="The name of the undocumented identifier type.",
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
def serialize(self) -> str:
|
|
129
|
+
"""Serialize the identifier lookup to a string."""
|
|
130
|
+
if self.identifier_type is None:
|
|
131
|
+
return self.identifier
|
|
132
|
+
if self.identifier_type == ExternalIdentifierType.OTHER:
|
|
133
|
+
return f"other:{self.other_identifier_name}:{self.identifier}"
|
|
134
|
+
return f"{self.identifier_type.value.lower()}:{self.identifier}"
|
|
135
|
+
|
|
136
|
+
@classmethod
|
|
137
|
+
def parse(cls, identifier_lookup_string: str, delimiter: str = ":") -> Self:
|
|
138
|
+
"""Parse an identifier string into an IdentifierLookup."""
|
|
139
|
+
if delimiter not in identifier_lookup_string:
|
|
140
|
+
try:
|
|
141
|
+
UUID4(identifier_lookup_string)
|
|
142
|
+
except ValueError as exc:
|
|
143
|
+
msg = (
|
|
144
|
+
f"Invalid identifier lookup string: {identifier_lookup_string}. "
|
|
145
|
+
"Must be UUIDv4 if no identifier type is specified."
|
|
146
|
+
)
|
|
147
|
+
raise ValueError(msg) from exc
|
|
148
|
+
return cls(
|
|
149
|
+
identifier=identifier_lookup_string,
|
|
150
|
+
identifier_type=None,
|
|
151
|
+
)
|
|
152
|
+
identifier_type, identifier = identifier_lookup_string.split(delimiter, 1)
|
|
153
|
+
if identifier_type == ExternalIdentifierType.OTHER:
|
|
154
|
+
if delimiter not in identifier:
|
|
155
|
+
msg = (
|
|
156
|
+
f"Invalid identifier lookup string: {identifier_lookup_string}. "
|
|
157
|
+
"Other identifier type must include other identifier name."
|
|
158
|
+
)
|
|
159
|
+
raise ValueError(msg)
|
|
160
|
+
other_identifier_type, identifier = identifier.split(delimiter, 1)
|
|
161
|
+
return cls(
|
|
162
|
+
identifier=identifier,
|
|
163
|
+
identifier_type=ExternalIdentifierType.OTHER,
|
|
164
|
+
other_identifier_name=other_identifier_type,
|
|
165
|
+
)
|
|
166
|
+
if identifier_type not in ExternalIdentifierType:
|
|
167
|
+
msg = (
|
|
168
|
+
f"Invalid identifier lookup string: {identifier_lookup_string}. "
|
|
169
|
+
f"Unknown identifier type: {identifier_type}."
|
|
170
|
+
)
|
|
171
|
+
raise ValueError(msg)
|
|
172
|
+
return cls(
|
|
173
|
+
identifier=identifier,
|
|
174
|
+
identifier_type=ExternalIdentifierType(identifier_type),
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
@classmethod
|
|
178
|
+
def from_identifier(cls, identifier: ExternalIdentifier | UUID4) -> Self:
|
|
179
|
+
"""Create an IdentifierLookup from an ExternalIdentifier or UUID4."""
|
|
180
|
+
if isinstance(identifier, uuid.UUID):
|
|
181
|
+
return cls(identifier=str(identifier), identifier_type=None)
|
|
182
|
+
return cls(
|
|
183
|
+
identifier=str(identifier.identifier),
|
|
184
|
+
identifier_type=identifier.identifier_type,
|
|
185
|
+
other_identifier_name=getattr(identifier, "other_identifier_name", None),
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
def to_identifier(self) -> ExternalIdentifier | UUID4:
|
|
189
|
+
"""Convert into an ExternalIdentifier or UUID4 if it has no identifier_type."""
|
|
190
|
+
if self.identifier_type is None:
|
|
191
|
+
return UUID4(self.identifier)
|
|
192
|
+
return ExternalIdentifierAdapter.validate_python(self.model_dump())
|
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
import uuid
|
|
2
|
+
|
|
3
|
+
import destiny_sdk
|
|
4
|
+
import pytest
|
|
5
|
+
from pydantic import ValidationError
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def test_valid_doi():
|
|
9
|
+
obj = destiny_sdk.identifiers.DOIIdentifier(
|
|
10
|
+
identifier_type=destiny_sdk.identifiers.ExternalIdentifierType.DOI,
|
|
11
|
+
identifier="10.1000/xyz123",
|
|
12
|
+
)
|
|
13
|
+
assert obj.identifier == "10.1000/xyz123"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def test_invalid_doi():
|
|
17
|
+
with pytest.raises(ValidationError, match="String should match pattern"):
|
|
18
|
+
destiny_sdk.identifiers.DOIIdentifier(
|
|
19
|
+
identifier_type=destiny_sdk.identifiers.ExternalIdentifierType.DOI,
|
|
20
|
+
identifier="invalid_doi",
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def test_doi_url_removed():
|
|
25
|
+
"""Test that a DOI with a URL is fixed to just the DOI part."""
|
|
26
|
+
obj = destiny_sdk.identifiers.DOIIdentifier(
|
|
27
|
+
identifier_type=destiny_sdk.identifiers.ExternalIdentifierType.DOI,
|
|
28
|
+
identifier="http://doi.org/10.1000/xyz123",
|
|
29
|
+
)
|
|
30
|
+
assert obj.identifier == "10.1000/xyz123"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_valid_pmid():
|
|
34
|
+
identifier = 123456
|
|
35
|
+
|
|
36
|
+
obj = destiny_sdk.identifiers.PubMedIdentifier(
|
|
37
|
+
identifier_type=destiny_sdk.identifiers.ExternalIdentifierType.PM_ID,
|
|
38
|
+
identifier=identifier,
|
|
39
|
+
)
|
|
40
|
+
assert obj.identifier == identifier
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def test_invalid_pmid():
|
|
44
|
+
with pytest.raises(ValidationError, match="Input should be a valid integer"):
|
|
45
|
+
destiny_sdk.identifiers.PubMedIdentifier(
|
|
46
|
+
identifier_type=destiny_sdk.identifiers.ExternalIdentifierType.PM_ID,
|
|
47
|
+
identifier="abc123",
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def test_valid_open_alex():
|
|
52
|
+
valid_open_alex = "W123456789"
|
|
53
|
+
obj = destiny_sdk.identifiers.OpenAlexIdentifier(
|
|
54
|
+
identifier_type=destiny_sdk.identifiers.ExternalIdentifierType.OPEN_ALEX,
|
|
55
|
+
identifier=valid_open_alex,
|
|
56
|
+
)
|
|
57
|
+
assert obj.identifier == valid_open_alex
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def test_open_alex_url_removed():
|
|
61
|
+
identitier = "W123456789"
|
|
62
|
+
valid_openalex_with_url_https = f"https://openalex.org/{identitier}"
|
|
63
|
+
|
|
64
|
+
obj = destiny_sdk.identifiers.OpenAlexIdentifier(
|
|
65
|
+
identifier_type=destiny_sdk.identifiers.ExternalIdentifierType.OPEN_ALEX,
|
|
66
|
+
identifier=valid_openalex_with_url_https,
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
assert obj.identifier == identitier
|
|
70
|
+
|
|
71
|
+
valid_openalex_with_url_http = f"http://openalex.org/{identitier}"
|
|
72
|
+
|
|
73
|
+
obj = destiny_sdk.identifiers.OpenAlexIdentifier(
|
|
74
|
+
identifier_type=destiny_sdk.identifiers.ExternalIdentifierType.OPEN_ALEX,
|
|
75
|
+
identifier=valid_openalex_with_url_http,
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
assert obj.identifier == identitier
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def test_invalid_open_alex():
|
|
82
|
+
with pytest.raises(ValidationError, match="String should match pattern"):
|
|
83
|
+
destiny_sdk.identifiers.OpenAlexIdentifier(
|
|
84
|
+
identifier_type=destiny_sdk.identifiers.ExternalIdentifierType.OPEN_ALEX,
|
|
85
|
+
identifier="invalid-openalex",
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def test_valid_other_identifier():
|
|
90
|
+
obj = destiny_sdk.identifiers.OtherIdentifier(
|
|
91
|
+
identifier_type=destiny_sdk.identifiers.ExternalIdentifierType.OTHER,
|
|
92
|
+
identifier="custom_identifier",
|
|
93
|
+
other_identifier_name="custom_type",
|
|
94
|
+
)
|
|
95
|
+
assert obj.other_identifier_name == "custom_type"
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def test_invalid_other_identifier_missing_name():
|
|
99
|
+
with pytest.raises(
|
|
100
|
+
ValidationError,
|
|
101
|
+
match="Field required",
|
|
102
|
+
):
|
|
103
|
+
destiny_sdk.identifiers.OtherIdentifier(
|
|
104
|
+
identifier_type=destiny_sdk.identifiers.ExternalIdentifierType.OTHER,
|
|
105
|
+
identifier="custom_identifier",
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
# IdentifierLookup Tests
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class TestIdentifierLookupSerialization:
|
|
113
|
+
"""Test serialization of IdentifierLookup objects."""
|
|
114
|
+
|
|
115
|
+
@pytest.mark.parametrize(
|
|
116
|
+
("identifier_type", "identifier", "other_name", "expected"),
|
|
117
|
+
[
|
|
118
|
+
# UUID (no type)
|
|
119
|
+
(
|
|
120
|
+
None,
|
|
121
|
+
"550e8400-e29b-41d4-a716-446655440000",
|
|
122
|
+
None,
|
|
123
|
+
"550e8400-e29b-41d4-a716-446655440000",
|
|
124
|
+
),
|
|
125
|
+
# Standard identifier types
|
|
126
|
+
(
|
|
127
|
+
destiny_sdk.identifiers.ExternalIdentifierType.DOI,
|
|
128
|
+
"10.1000/xyz123",
|
|
129
|
+
None,
|
|
130
|
+
"doi:10.1000/xyz123",
|
|
131
|
+
),
|
|
132
|
+
(
|
|
133
|
+
destiny_sdk.identifiers.ExternalIdentifierType.PM_ID,
|
|
134
|
+
"12345",
|
|
135
|
+
None,
|
|
136
|
+
"pm_id:12345",
|
|
137
|
+
),
|
|
138
|
+
(
|
|
139
|
+
destiny_sdk.identifiers.ExternalIdentifierType.OPEN_ALEX,
|
|
140
|
+
"W123456789",
|
|
141
|
+
None,
|
|
142
|
+
"open_alex:W123456789",
|
|
143
|
+
),
|
|
144
|
+
# Other identifier type
|
|
145
|
+
(
|
|
146
|
+
destiny_sdk.identifiers.ExternalIdentifierType.OTHER,
|
|
147
|
+
"custom123",
|
|
148
|
+
"arxiv",
|
|
149
|
+
"other:arxiv:custom123",
|
|
150
|
+
),
|
|
151
|
+
],
|
|
152
|
+
)
|
|
153
|
+
def test_serialize(self, identifier_type, identifier, other_name, expected):
|
|
154
|
+
lookup = destiny_sdk.identifiers.IdentifierLookup(
|
|
155
|
+
identifier=identifier,
|
|
156
|
+
identifier_type=identifier_type,
|
|
157
|
+
other_identifier_name=other_name,
|
|
158
|
+
)
|
|
159
|
+
assert lookup.serialize() == expected
|
|
160
|
+
|
|
161
|
+
def test_serialize_custom_delimiter(self):
|
|
162
|
+
lookup = destiny_sdk.identifiers.IdentifierLookup(
|
|
163
|
+
identifier="10.1000/xyz123",
|
|
164
|
+
identifier_type=destiny_sdk.identifiers.ExternalIdentifierType.DOI,
|
|
165
|
+
)
|
|
166
|
+
# Default delimiter should still work
|
|
167
|
+
assert lookup.serialize() == "doi:10.1000/xyz123"
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
class TestIdentifierLookup:
|
|
171
|
+
"""Test parsing of identifier lookup strings."""
|
|
172
|
+
|
|
173
|
+
@pytest.mark.parametrize(
|
|
174
|
+
(
|
|
175
|
+
"input_identifier",
|
|
176
|
+
"expected_lookup",
|
|
177
|
+
"expected_serialization",
|
|
178
|
+
),
|
|
179
|
+
[
|
|
180
|
+
# UUID (no type)
|
|
181
|
+
(
|
|
182
|
+
uuid.UUID("550e8400-e29b-41d4-a716-446655440000"),
|
|
183
|
+
destiny_sdk.identifiers.IdentifierLookup(
|
|
184
|
+
identifier="550e8400-e29b-41d4-a716-446655440000",
|
|
185
|
+
identifier_type=None,
|
|
186
|
+
),
|
|
187
|
+
"550e8400-e29b-41d4-a716-446655440000",
|
|
188
|
+
),
|
|
189
|
+
# DOI identifier
|
|
190
|
+
(
|
|
191
|
+
destiny_sdk.identifiers.DOIIdentifier(
|
|
192
|
+
identifier="10.1000/xyz123",
|
|
193
|
+
identifier_type=destiny_sdk.identifiers.ExternalIdentifierType.DOI,
|
|
194
|
+
),
|
|
195
|
+
destiny_sdk.identifiers.IdentifierLookup(
|
|
196
|
+
identifier="10.1000/xyz123",
|
|
197
|
+
identifier_type=destiny_sdk.identifiers.ExternalIdentifierType.DOI,
|
|
198
|
+
),
|
|
199
|
+
"doi:10.1000/xyz123",
|
|
200
|
+
),
|
|
201
|
+
# PubMed identifier
|
|
202
|
+
(
|
|
203
|
+
destiny_sdk.identifiers.PubMedIdentifier(
|
|
204
|
+
identifier=12345,
|
|
205
|
+
identifier_type=destiny_sdk.identifiers.ExternalIdentifierType.PM_ID,
|
|
206
|
+
),
|
|
207
|
+
destiny_sdk.identifiers.IdentifierLookup(
|
|
208
|
+
identifier="12345",
|
|
209
|
+
identifier_type=destiny_sdk.identifiers.ExternalIdentifierType.PM_ID,
|
|
210
|
+
),
|
|
211
|
+
"pm_id:12345",
|
|
212
|
+
),
|
|
213
|
+
# OpenAlex identifier
|
|
214
|
+
(
|
|
215
|
+
destiny_sdk.identifiers.OpenAlexIdentifier(
|
|
216
|
+
identifier="W123456789",
|
|
217
|
+
identifier_type=destiny_sdk.identifiers.ExternalIdentifierType.OPEN_ALEX,
|
|
218
|
+
),
|
|
219
|
+
destiny_sdk.identifiers.IdentifierLookup(
|
|
220
|
+
identifier="W123456789",
|
|
221
|
+
identifier_type=destiny_sdk.identifiers.ExternalIdentifierType.OPEN_ALEX,
|
|
222
|
+
),
|
|
223
|
+
"open_alex:W123456789",
|
|
224
|
+
),
|
|
225
|
+
# Other identifier type
|
|
226
|
+
(
|
|
227
|
+
destiny_sdk.identifiers.OtherIdentifier(
|
|
228
|
+
identifier="custom123",
|
|
229
|
+
identifier_type=destiny_sdk.identifiers.ExternalIdentifierType.OTHER,
|
|
230
|
+
other_identifier_name="arxiv",
|
|
231
|
+
),
|
|
232
|
+
destiny_sdk.identifiers.IdentifierLookup(
|
|
233
|
+
identifier="custom123",
|
|
234
|
+
identifier_type=destiny_sdk.identifiers.ExternalIdentifierType.OTHER,
|
|
235
|
+
other_identifier_name="arxiv",
|
|
236
|
+
),
|
|
237
|
+
"other:arxiv:custom123",
|
|
238
|
+
),
|
|
239
|
+
],
|
|
240
|
+
)
|
|
241
|
+
def test_full_round_trip(
|
|
242
|
+
self, input_identifier, expected_lookup, expected_serialization
|
|
243
|
+
):
|
|
244
|
+
# Step 1: Convert input identifier to IdentifierLookup using from_identifier
|
|
245
|
+
lookup = destiny_sdk.identifiers.IdentifierLookup.from_identifier(
|
|
246
|
+
input_identifier
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
# Step 2: Compare to expected IdentifierLookup
|
|
250
|
+
assert lookup.identifier == expected_lookup.identifier
|
|
251
|
+
assert lookup.identifier_type == expected_lookup.identifier_type
|
|
252
|
+
assert lookup.other_identifier_name == expected_lookup.other_identifier_name
|
|
253
|
+
|
|
254
|
+
# Step 3: Serialize the IdentifierLookup
|
|
255
|
+
serialized = lookup.serialize()
|
|
256
|
+
|
|
257
|
+
# Step 4: Check the serialization matches expected
|
|
258
|
+
assert serialized == expected_serialization
|
|
259
|
+
|
|
260
|
+
# Step 5: Parse it back
|
|
261
|
+
parsed = destiny_sdk.identifiers.IdentifierLookup.parse(serialized)
|
|
262
|
+
|
|
263
|
+
# Step 6: Assert parsed is the same as the lookup
|
|
264
|
+
assert parsed.identifier == lookup.identifier
|
|
265
|
+
assert parsed.identifier_type == lookup.identifier_type
|
|
266
|
+
assert parsed.other_identifier_name == lookup.other_identifier_name
|
|
267
|
+
|
|
268
|
+
# Step 7: Convert back to identifier using to_identifier
|
|
269
|
+
result_identifier = parsed.to_identifier()
|
|
270
|
+
|
|
271
|
+
# Step 8: Assert result is the same as input
|
|
272
|
+
if isinstance(input_identifier, uuid.UUID):
|
|
273
|
+
assert isinstance(result_identifier, uuid.UUID)
|
|
274
|
+
assert str(result_identifier) == str(input_identifier)
|
|
275
|
+
else:
|
|
276
|
+
assert isinstance(result_identifier, type(input_identifier))
|
|
277
|
+
assert result_identifier.identifier == input_identifier.identifier
|
|
278
|
+
assert result_identifier.identifier_type == input_identifier.identifier_type
|
|
279
|
+
if hasattr(input_identifier, "other_identifier_name"):
|
|
280
|
+
assert (
|
|
281
|
+
result_identifier.other_identifier_name
|
|
282
|
+
== input_identifier.other_identifier_name
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
def test_parse_invalid_uuid(self):
|
|
286
|
+
with pytest.raises(
|
|
287
|
+
ValueError, match="Must be UUIDv4 if no identifier type is specified"
|
|
288
|
+
):
|
|
289
|
+
destiny_sdk.identifiers.IdentifierLookup.parse("not-a-uuid")
|
|
290
|
+
|
|
291
|
+
def test_parse_unknown_identifier_type(self):
|
|
292
|
+
with pytest.raises(ValueError, match="Unknown identifier type: unknown"):
|
|
293
|
+
destiny_sdk.identifiers.IdentifierLookup.parse("unknown:12345")
|
|
294
|
+
|
|
295
|
+
def test_parse_other_missing_name(self):
|
|
296
|
+
with pytest.raises(
|
|
297
|
+
ValueError,
|
|
298
|
+
match="Other identifier type must include other identifier name",
|
|
299
|
+
):
|
|
300
|
+
destiny_sdk.identifiers.IdentifierLookup.parse("other:12345")
|
|
301
|
+
|
|
302
|
+
def test_parse_custom_delimiter(self):
|
|
303
|
+
lookup = destiny_sdk.identifiers.IdentifierLookup.parse(
|
|
304
|
+
"doi|10.1000/xyz123", delimiter="|"
|
|
305
|
+
)
|
|
306
|
+
assert lookup.identifier == "10.1000/xyz123"
|
|
307
|
+
assert (
|
|
308
|
+
lookup.identifier_type == destiny_sdk.identifiers.ExternalIdentifierType.DOI
|
|
309
|
+
)
|
|
310
|
+
|
|
311
|
+
def test_parse_with_colon(self):
|
|
312
|
+
lookup = destiny_sdk.identifiers.IdentifierLookup.parse(
|
|
313
|
+
"other:foobar:a:b:c", delimiter=":"
|
|
314
|
+
)
|
|
315
|
+
assert lookup.identifier == "a:b:c"
|
|
316
|
+
assert (
|
|
317
|
+
lookup.identifier_type
|
|
318
|
+
== destiny_sdk.identifiers.ExternalIdentifierType.OTHER
|
|
319
|
+
)
|
|
320
|
+
assert lookup.other_identifier_name == "foobar"
|
|
@@ -1,107 +0,0 @@
|
|
|
1
|
-
"""Identifier classes for the Destiny SDK."""
|
|
2
|
-
|
|
3
|
-
from enum import StrEnum, auto
|
|
4
|
-
from typing import Annotated, Literal
|
|
5
|
-
|
|
6
|
-
from pydantic import UUID4, BaseModel, Field, field_validator
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
class ExternalIdentifierType(StrEnum):
|
|
10
|
-
"""
|
|
11
|
-
The type of identifier used to identify a reference.
|
|
12
|
-
|
|
13
|
-
This is used to identify the type of identifier used in the `ExternalIdentifier`
|
|
14
|
-
class.
|
|
15
|
-
"""
|
|
16
|
-
|
|
17
|
-
DOI = auto()
|
|
18
|
-
"""A DOI (Digital Object Identifier) which is a unique identifier for a document."""
|
|
19
|
-
PM_ID = auto()
|
|
20
|
-
"""A PubMed ID which is a unique identifier for a document in PubMed."""
|
|
21
|
-
OPEN_ALEX = auto()
|
|
22
|
-
"""An OpenAlex ID which is a unique identifier for a document in OpenAlex."""
|
|
23
|
-
OTHER = auto()
|
|
24
|
-
"""Any other identifier not defined. This should be used sparingly."""
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
class DOIIdentifier(BaseModel):
|
|
28
|
-
"""An external identifier representing a DOI."""
|
|
29
|
-
|
|
30
|
-
identifier: str = Field(
|
|
31
|
-
description="The DOI of the reference.",
|
|
32
|
-
pattern=r"^10\.\d{4,9}/[-._;()/:a-zA-Z0-9%<>\[\]+&]+$",
|
|
33
|
-
)
|
|
34
|
-
identifier_type: Literal[ExternalIdentifierType.DOI] = Field(
|
|
35
|
-
ExternalIdentifierType.DOI, description="The type of identifier used."
|
|
36
|
-
)
|
|
37
|
-
|
|
38
|
-
@field_validator("identifier", mode="before")
|
|
39
|
-
@classmethod
|
|
40
|
-
def remove_doi_url(cls, value: str) -> str:
|
|
41
|
-
"""Remove the URL part of the DOI if it exists."""
|
|
42
|
-
return (
|
|
43
|
-
value.removeprefix("http://doi.org/")
|
|
44
|
-
.removeprefix("https://doi.org/")
|
|
45
|
-
.strip()
|
|
46
|
-
)
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
class PubMedIdentifier(BaseModel):
|
|
50
|
-
"""An external identifier representing a PubMed ID."""
|
|
51
|
-
|
|
52
|
-
identifier: int = Field(description="The PubMed ID of the reference.")
|
|
53
|
-
identifier_type: Literal[ExternalIdentifierType.PM_ID] = Field(
|
|
54
|
-
ExternalIdentifierType.PM_ID, description="The type of identifier used."
|
|
55
|
-
)
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
class OpenAlexIdentifier(BaseModel):
|
|
59
|
-
"""An external identifier representing an OpenAlex ID."""
|
|
60
|
-
|
|
61
|
-
identifier: str = Field(
|
|
62
|
-
description="The OpenAlex ID of the reference.", pattern=r"^W\d+$"
|
|
63
|
-
)
|
|
64
|
-
identifier_type: Literal[ExternalIdentifierType.OPEN_ALEX] = Field(
|
|
65
|
-
ExternalIdentifierType.OPEN_ALEX, description="The type of identifier used."
|
|
66
|
-
)
|
|
67
|
-
|
|
68
|
-
@field_validator("identifier", mode="before")
|
|
69
|
-
@classmethod
|
|
70
|
-
def remove_open_alex_url(cls, value: str) -> str:
|
|
71
|
-
"""Remove the OpenAlex URL if it exists."""
|
|
72
|
-
return (
|
|
73
|
-
value.removeprefix("http://openalex.org/")
|
|
74
|
-
.removeprefix("https://openalex.org/")
|
|
75
|
-
.strip()
|
|
76
|
-
)
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
class OtherIdentifier(BaseModel):
|
|
80
|
-
"""An external identifier not otherwise defined by the repository."""
|
|
81
|
-
|
|
82
|
-
identifier: str = Field(description="The identifier of the reference.")
|
|
83
|
-
identifier_type: Literal[ExternalIdentifierType.OTHER] = Field(
|
|
84
|
-
ExternalIdentifierType.OTHER, description="The type of identifier used."
|
|
85
|
-
)
|
|
86
|
-
other_identifier_name: str = Field(
|
|
87
|
-
description="The name of the undocumented identifier type."
|
|
88
|
-
)
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
#: Union type for all external identifiers.
|
|
92
|
-
ExternalIdentifier = Annotated[
|
|
93
|
-
DOIIdentifier | PubMedIdentifier | OpenAlexIdentifier | OtherIdentifier,
|
|
94
|
-
Field(discriminator="identifier_type"),
|
|
95
|
-
]
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
class LinkedExternalIdentifier(BaseModel):
|
|
99
|
-
"""An external identifier which identifies a reference."""
|
|
100
|
-
|
|
101
|
-
identifier: ExternalIdentifier = Field(
|
|
102
|
-
description="The identifier of the reference.",
|
|
103
|
-
discriminator="identifier_type",
|
|
104
|
-
)
|
|
105
|
-
reference_id: UUID4 = Field(
|
|
106
|
-
description="The ID of the reference this identifier identifies."
|
|
107
|
-
)
|
|
@@ -1,104 +0,0 @@
|
|
|
1
|
-
import destiny_sdk
|
|
2
|
-
import pytest
|
|
3
|
-
from pydantic import ValidationError
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
def test_valid_doi():
|
|
7
|
-
obj = destiny_sdk.identifiers.DOIIdentifier(
|
|
8
|
-
identifier_type=destiny_sdk.identifiers.ExternalIdentifierType.DOI,
|
|
9
|
-
identifier="10.1000/xyz123",
|
|
10
|
-
)
|
|
11
|
-
assert obj.identifier == "10.1000/xyz123"
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
def test_invalid_doi():
|
|
15
|
-
with pytest.raises(ValidationError, match="String should match pattern"):
|
|
16
|
-
destiny_sdk.identifiers.DOIIdentifier(
|
|
17
|
-
identifier_type=destiny_sdk.identifiers.ExternalIdentifierType.DOI,
|
|
18
|
-
identifier="invalid_doi",
|
|
19
|
-
)
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
def test_doi_url_removed():
|
|
23
|
-
"""Test that a DOI with a URL is fixed to just the DOI part."""
|
|
24
|
-
obj = destiny_sdk.identifiers.DOIIdentifier(
|
|
25
|
-
identifier_type=destiny_sdk.identifiers.ExternalIdentifierType.DOI,
|
|
26
|
-
identifier="http://doi.org/10.1000/xyz123",
|
|
27
|
-
)
|
|
28
|
-
assert obj.identifier == "10.1000/xyz123"
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
def test_valid_pmid():
|
|
32
|
-
identifier = 123456
|
|
33
|
-
|
|
34
|
-
obj = destiny_sdk.identifiers.PubMedIdentifier(
|
|
35
|
-
identifier_type=destiny_sdk.identifiers.ExternalIdentifierType.PM_ID,
|
|
36
|
-
identifier=identifier,
|
|
37
|
-
)
|
|
38
|
-
assert obj.identifier == identifier
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
def test_invalid_pmid():
|
|
42
|
-
with pytest.raises(ValidationError, match="Input should be a valid integer"):
|
|
43
|
-
destiny_sdk.identifiers.PubMedIdentifier(
|
|
44
|
-
identifier_type=destiny_sdk.identifiers.ExternalIdentifierType.PM_ID,
|
|
45
|
-
identifier="abc123",
|
|
46
|
-
)
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
def test_valid_open_alex():
|
|
50
|
-
valid_open_alex = "W123456789"
|
|
51
|
-
obj = destiny_sdk.identifiers.OpenAlexIdentifier(
|
|
52
|
-
identifier_type=destiny_sdk.identifiers.ExternalIdentifierType.OPEN_ALEX,
|
|
53
|
-
identifier=valid_open_alex,
|
|
54
|
-
)
|
|
55
|
-
assert obj.identifier == valid_open_alex
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
def test_open_alex_url_removed():
|
|
59
|
-
identitier = "W123456789"
|
|
60
|
-
valid_openalex_with_url_https = f"https://openalex.org/{identitier}"
|
|
61
|
-
|
|
62
|
-
obj = destiny_sdk.identifiers.OpenAlexIdentifier(
|
|
63
|
-
identifier_type=destiny_sdk.identifiers.ExternalIdentifierType.OPEN_ALEX,
|
|
64
|
-
identifier=valid_openalex_with_url_https,
|
|
65
|
-
)
|
|
66
|
-
|
|
67
|
-
assert obj.identifier == identitier
|
|
68
|
-
|
|
69
|
-
valid_openalex_with_url_http = f"http://openalex.org/{identitier}"
|
|
70
|
-
|
|
71
|
-
obj = destiny_sdk.identifiers.OpenAlexIdentifier(
|
|
72
|
-
identifier_type=destiny_sdk.identifiers.ExternalIdentifierType.OPEN_ALEX,
|
|
73
|
-
identifier=valid_openalex_with_url_http,
|
|
74
|
-
)
|
|
75
|
-
|
|
76
|
-
assert obj.identifier == identitier
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
def test_invalid_open_alex():
|
|
80
|
-
with pytest.raises(ValidationError, match="String should match pattern"):
|
|
81
|
-
destiny_sdk.identifiers.OpenAlexIdentifier(
|
|
82
|
-
identifier_type=destiny_sdk.identifiers.ExternalIdentifierType.OPEN_ALEX,
|
|
83
|
-
identifier="invalid-openalex",
|
|
84
|
-
)
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
def test_valid_other_identifier():
|
|
88
|
-
obj = destiny_sdk.identifiers.OtherIdentifier(
|
|
89
|
-
identifier_type=destiny_sdk.identifiers.ExternalIdentifierType.OTHER,
|
|
90
|
-
identifier="custom_identifier",
|
|
91
|
-
other_identifier_name="custom_type",
|
|
92
|
-
)
|
|
93
|
-
assert obj.other_identifier_name == "custom_type"
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
def test_invalid_other_identifier_missing_name():
|
|
97
|
-
with pytest.raises(
|
|
98
|
-
ValidationError,
|
|
99
|
-
match="Field required",
|
|
100
|
-
):
|
|
101
|
-
destiny_sdk.identifiers.OtherIdentifier(
|
|
102
|
-
identifier_type=destiny_sdk.identifiers.ExternalIdentifierType.OTHER,
|
|
103
|
-
identifier="custom_identifier",
|
|
104
|
-
)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{destiny_sdk-0.5.0 → destiny_sdk-0.5.1}/tests/unit/test_data/eppi_import_with_annotations.jsonl
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|