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.
Files changed (33) hide show
  1. {destiny_sdk-0.5.0 → destiny_sdk-0.5.1}/.gitignore +4 -0
  2. {destiny_sdk-0.5.0 → destiny_sdk-0.5.1}/PKG-INFO +1 -1
  3. {destiny_sdk-0.5.0 → destiny_sdk-0.5.1}/pyproject.toml +1 -1
  4. destiny_sdk-0.5.1/src/destiny_sdk/identifiers.py +192 -0
  5. destiny_sdk-0.5.1/tests/unit/test_identifiers.py +320 -0
  6. {destiny_sdk-0.5.0 → destiny_sdk-0.5.1}/uv.lock +1 -1
  7. destiny_sdk-0.5.0/src/destiny_sdk/identifiers.py +0 -107
  8. destiny_sdk-0.5.0/tests/unit/test_identifiers.py +0 -104
  9. {destiny_sdk-0.5.0 → destiny_sdk-0.5.1}/LICENSE +0 -0
  10. {destiny_sdk-0.5.0 → destiny_sdk-0.5.1}/README.md +0 -0
  11. {destiny_sdk-0.5.0 → destiny_sdk-0.5.1}/src/destiny_sdk/__init__.py +0 -0
  12. {destiny_sdk-0.5.0 → destiny_sdk-0.5.1}/src/destiny_sdk/auth.py +0 -0
  13. {destiny_sdk-0.5.0 → destiny_sdk-0.5.1}/src/destiny_sdk/client.py +0 -0
  14. {destiny_sdk-0.5.0 → destiny_sdk-0.5.1}/src/destiny_sdk/core.py +0 -0
  15. {destiny_sdk-0.5.0 → destiny_sdk-0.5.1}/src/destiny_sdk/enhancements.py +0 -0
  16. {destiny_sdk-0.5.0 → destiny_sdk-0.5.1}/src/destiny_sdk/imports.py +0 -0
  17. {destiny_sdk-0.5.0 → destiny_sdk-0.5.1}/src/destiny_sdk/parsers/__init__.py +0 -0
  18. {destiny_sdk-0.5.0 → destiny_sdk-0.5.1}/src/destiny_sdk/parsers/eppi_parser.py +0 -0
  19. {destiny_sdk-0.5.0 → destiny_sdk-0.5.1}/src/destiny_sdk/py.typed +0 -0
  20. {destiny_sdk-0.5.0 → destiny_sdk-0.5.1}/src/destiny_sdk/references.py +0 -0
  21. {destiny_sdk-0.5.0 → destiny_sdk-0.5.1}/src/destiny_sdk/robots.py +0 -0
  22. {destiny_sdk-0.5.0 → destiny_sdk-0.5.1}/src/destiny_sdk/visibility.py +0 -0
  23. {destiny_sdk-0.5.0 → destiny_sdk-0.5.1}/tests/unit/__init__.py +0 -0
  24. {destiny_sdk-0.5.0 → destiny_sdk-0.5.1}/tests/unit/conftest.py +0 -0
  25. {destiny_sdk-0.5.0 → destiny_sdk-0.5.1}/tests/unit/parsers/test_eppi_parser.py +0 -0
  26. {destiny_sdk-0.5.0 → destiny_sdk-0.5.1}/tests/unit/test_auth.py +0 -0
  27. {destiny_sdk-0.5.0 → destiny_sdk-0.5.1}/tests/unit/test_client.py +0 -0
  28. {destiny_sdk-0.5.0 → destiny_sdk-0.5.1}/tests/unit/test_data/eppi_import.jsonl +0 -0
  29. {destiny_sdk-0.5.0 → destiny_sdk-0.5.1}/tests/unit/test_data/eppi_import_with_annotations.jsonl +0 -0
  30. {destiny_sdk-0.5.0 → destiny_sdk-0.5.1}/tests/unit/test_data/eppi_report.json +0 -0
  31. {destiny_sdk-0.5.0 → destiny_sdk-0.5.1}/tests/unit/test_enhancements.py +0 -0
  32. {destiny_sdk-0.5.0 → destiny_sdk-0.5.1}/tests/unit/test_references.py +0 -0
  33. {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.0
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
@@ -33,7 +33,7 @@ license = "Apache-2.0"
33
33
  name = "destiny_sdk"
34
34
  readme = "README.md"
35
35
  requires-python = "~=3.12"
36
- version = "0.5.0"
36
+ version = "0.5.1"
37
37
 
38
38
  [tool.pytest.ini_options]
39
39
  addopts = ["--color=yes", "--import-mode=importlib", "--verbose"]
@@ -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"
@@ -63,7 +63,7 @@ wheels = [
63
63
 
64
64
  [[package]]
65
65
  name = "destiny-sdk"
66
- version = "0.5.0"
66
+ version = "0.5.1"
67
67
  source = { editable = "." }
68
68
  dependencies = [
69
69
  { name = "cachetools" },
@@ -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