destiny_sdk 0.7.5__tar.gz → 0.8.0__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 (38) hide show
  1. {destiny_sdk-0.7.5 → destiny_sdk-0.8.0}/PKG-INFO +2 -5
  2. {destiny_sdk-0.7.5 → destiny_sdk-0.8.0}/pyproject.toml +5 -5
  3. {destiny_sdk-0.7.5 → destiny_sdk-0.8.0}/src/destiny_sdk/client.py +5 -7
  4. {destiny_sdk-0.7.5 → destiny_sdk-0.8.0}/src/destiny_sdk/enhancements.py +95 -1
  5. {destiny_sdk-0.7.5 → destiny_sdk-0.8.0}/src/destiny_sdk/identifiers.py +59 -20
  6. {destiny_sdk-0.7.5 → destiny_sdk-0.8.0}/tests/unit/test_enhancements.py +75 -1
  7. {destiny_sdk-0.7.5 → destiny_sdk-0.8.0}/tests/unit/test_identifiers.py +53 -0
  8. {destiny_sdk-0.7.5 → destiny_sdk-0.8.0}/uv.lock +7 -7
  9. {destiny_sdk-0.7.5 → destiny_sdk-0.8.0}/.gitignore +0 -0
  10. {destiny_sdk-0.7.5 → destiny_sdk-0.8.0}/LICENSE +0 -0
  11. {destiny_sdk-0.7.5 → destiny_sdk-0.8.0}/README.md +0 -0
  12. {destiny_sdk-0.7.5 → destiny_sdk-0.8.0}/src/destiny_sdk/__init__.py +0 -0
  13. {destiny_sdk-0.7.5 → destiny_sdk-0.8.0}/src/destiny_sdk/auth.py +0 -0
  14. {destiny_sdk-0.7.5 → destiny_sdk-0.8.0}/src/destiny_sdk/core.py +0 -0
  15. {destiny_sdk-0.7.5 → destiny_sdk-0.8.0}/src/destiny_sdk/imports.py +0 -0
  16. {destiny_sdk-0.7.5 → destiny_sdk-0.8.0}/src/destiny_sdk/labs/__init__.py +0 -0
  17. {destiny_sdk-0.7.5 → destiny_sdk-0.8.0}/src/destiny_sdk/labs/references.py +0 -0
  18. {destiny_sdk-0.7.5 → destiny_sdk-0.8.0}/src/destiny_sdk/parsers/__init__.py +0 -0
  19. {destiny_sdk-0.7.5 → destiny_sdk-0.8.0}/src/destiny_sdk/parsers/eppi_parser.py +0 -0
  20. {destiny_sdk-0.7.5 → destiny_sdk-0.8.0}/src/destiny_sdk/parsers/exceptions.py +0 -0
  21. {destiny_sdk-0.7.5 → destiny_sdk-0.8.0}/src/destiny_sdk/py.typed +0 -0
  22. {destiny_sdk-0.7.5 → destiny_sdk-0.8.0}/src/destiny_sdk/references.py +0 -0
  23. {destiny_sdk-0.7.5 → destiny_sdk-0.8.0}/src/destiny_sdk/robots.py +0 -0
  24. {destiny_sdk-0.7.5 → destiny_sdk-0.8.0}/src/destiny_sdk/search.py +0 -0
  25. {destiny_sdk-0.7.5 → destiny_sdk-0.8.0}/src/destiny_sdk/visibility.py +0 -0
  26. {destiny_sdk-0.7.5 → destiny_sdk-0.8.0}/tests/unit/__init__.py +0 -0
  27. {destiny_sdk-0.7.5 → destiny_sdk-0.8.0}/tests/unit/conftest.py +0 -0
  28. {destiny_sdk-0.7.5 → destiny_sdk-0.8.0}/tests/unit/labs/test_references.py +0 -0
  29. {destiny_sdk-0.7.5 → destiny_sdk-0.8.0}/tests/unit/parsers/test_eppi_parser.py +0 -0
  30. {destiny_sdk-0.7.5 → destiny_sdk-0.8.0}/tests/unit/test_auth.py +0 -0
  31. {destiny_sdk-0.7.5 → destiny_sdk-0.8.0}/tests/unit/test_client.py +0 -0
  32. {destiny_sdk-0.7.5 → destiny_sdk-0.8.0}/tests/unit/test_data/destiny_references.jsonl +0 -0
  33. {destiny_sdk-0.7.5 → destiny_sdk-0.8.0}/tests/unit/test_data/eppi_import.jsonl +0 -0
  34. {destiny_sdk-0.7.5 → destiny_sdk-0.8.0}/tests/unit/test_data/eppi_import_with_annotations.jsonl +0 -0
  35. {destiny_sdk-0.7.5 → destiny_sdk-0.8.0}/tests/unit/test_data/eppi_import_with_raw.jsonl +0 -0
  36. {destiny_sdk-0.7.5 → destiny_sdk-0.8.0}/tests/unit/test_data/eppi_report.json +0 -0
  37. {destiny_sdk-0.7.5 → destiny_sdk-0.8.0}/tests/unit/test_references.py +0 -0
  38. {destiny_sdk-0.7.5 → destiny_sdk-0.8.0}/tests/unit/test_robots.py +0 -0
@@ -1,19 +1,16 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: destiny_sdk
3
- Version: 0.7.5
3
+ Version: 0.8.0
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
7
7
  License-File: LICENSE
8
- Requires-Python: ~=3.12
8
+ Requires-Python: <4,>=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
12
  Requires-Dist: msal>=1.34.0
13
13
  Requires-Dist: pydantic<3,>=2.11.3
14
- Requires-Dist: pytest-asyncio<2,>=1.0.0
15
- Requires-Dist: pytest-httpx<0.36,>=0.35.0
16
- Requires-Dist: pytest<9,>=8.4.0
17
14
  Requires-Dist: python-jose<4,>=3.4.0
18
15
  Provides-Extra: labs
19
16
  Description-Content-Type: text/markdown
@@ -6,7 +6,10 @@ requires = ["hatchling"]
6
6
  dev = [
7
7
  "mypy>=1.15.0,<2",
8
8
  "pre-commit>=4.2.0,<5",
9
+ "pytest-asyncio>=1.0.0,<2",
9
10
  "pytest-env>=1.1.5,<2",
11
+ "pytest-httpx>=0.35.0,<0.36",
12
+ "pytest>=8.4.0,<9",
10
13
  "ruff>=0.11.5,<0.12",
11
14
  "uvloop>=0.21.0,<0.22",
12
15
  ]
@@ -25,17 +28,14 @@ dependencies = [
25
28
  "httpx>=0.28.1,<0.29",
26
29
  "msal>=1.34.0",
27
30
  "pydantic>=2.11.3,<3",
28
- "pytest-asyncio>=1.0.0,<2",
29
- "pytest-httpx>=0.35.0,<0.36",
30
- "pytest>=8.4.0,<9",
31
31
  "python-jose>=3.4.0,<4",
32
32
  ]
33
33
  description = "A software development kit (sdk) to support interaction with the DESTINY repository"
34
34
  license = "Apache-2.0"
35
35
  name = "destiny_sdk"
36
36
  readme = "README.md"
37
- requires-python = "~=3.12"
38
- version = "0.7.5"
37
+ requires-python = ">=3.12, <4"
38
+ version = "0.8.0"
39
39
 
40
40
  [project.optional-dependencies]
41
41
  labs = []
@@ -213,8 +213,8 @@ class OAuthMiddleware(httpx.Auth):
213
213
 
214
214
  auth = OAuthMiddleware(
215
215
  azure_client_id="client-id",
216
- azure_application_id="login-url",
217
- azure_tenant_id="tenant-id",
216
+ azure_application_id="application-id",
217
+ azure_login_url="login-url",
218
218
  )
219
219
 
220
220
  **Confidential Client Application (client credentials)**
@@ -260,11 +260,9 @@ class OAuthMiddleware(httpx.Auth):
260
260
  """
261
261
  Initialize the auth middleware.
262
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.
263
+ :param azure_client_id: The OAuth2 client ID.
264
+ :type azure_client_id: str
265
+ :param azure_application_id: The application ID for the Destiny API.
268
266
  :type application_id: str
269
267
  :param azure_login_url: The Azure login URL.
270
268
  :type azure_login_url: str
@@ -5,7 +5,7 @@ import json
5
5
  from enum import StrEnum, auto
6
6
  from typing import Annotated, Any, Literal, Self
7
7
 
8
- from pydantic import UUID4, BaseModel, Field, HttpUrl, model_validator
8
+ from pydantic import UUID4, BaseModel, Field, HttpUrl, field_validator, model_validator
9
9
 
10
10
  from destiny_sdk.core import _JsonlFileInputMixIn
11
11
  from destiny_sdk.identifiers import Identifier
@@ -50,6 +50,27 @@ class AuthorPosition(StrEnum):
50
50
  """The last author."""
51
51
 
52
52
 
53
+ class PublicationVenueType(StrEnum):
54
+ """
55
+ Type of publication venue.
56
+
57
+ Aligns with OpenAlex source types.
58
+ """
59
+
60
+ JOURNAL = auto()
61
+ """A journal publication."""
62
+ REPOSITORY = auto()
63
+ """A repository (includes preprint servers like arXiv, bioRxiv)."""
64
+ CONFERENCE = auto()
65
+ """A conference proceeding."""
66
+ EBOOK_PLATFORM = auto()
67
+ """An ebook platform."""
68
+ BOOK_SERIES = auto()
69
+ """A book series."""
70
+ OTHER = auto()
71
+ """Other venue type."""
72
+
73
+
53
74
  class Authorship(BaseModel):
54
75
  """
55
76
  Represents a single author and their association with a reference.
@@ -70,6 +91,71 @@ class Authorship(BaseModel):
70
91
  )
71
92
 
72
93
 
94
+ class Pagination(BaseModel):
95
+ """
96
+ Pagination information for journal articles.
97
+
98
+ Maps to OpenAlex's work.biblio object. All fields are strings to match
99
+ OpenAlex's format, which may include non-numeric values like "Spring" or "A1".
100
+ """
101
+
102
+ volume: str | None = Field(
103
+ default=None,
104
+ description="The volume number of the journal/publication.",
105
+ )
106
+ issue: str | None = Field(
107
+ default=None,
108
+ description="The issue number of the journal/publication.",
109
+ )
110
+ first_page: str | None = Field(
111
+ default=None,
112
+ description="The first page number of the reference in the publication.",
113
+ )
114
+ last_page: str | None = Field(
115
+ default=None,
116
+ description="The last page number of the reference in the publication.",
117
+ )
118
+
119
+ @field_validator("volume", "issue", "first_page", "last_page", mode="before")
120
+ @classmethod
121
+ def normalize_pagination_string(cls, value: str | None) -> str | None:
122
+ """Normalize pagination strings: NBSP to space, strip, empty to None."""
123
+ if isinstance(value, str):
124
+ # Replace NBSP with space, then strip
125
+ value = value.replace("\u00a0", " ").strip()
126
+ return value if value else None
127
+ return value
128
+
129
+
130
+ class PublicationVenue(BaseModel):
131
+ """A publication venue (journal, repository, conference, etc.)."""
132
+
133
+ display_name: str | None = Field(
134
+ default=None,
135
+ description=(
136
+ "The display name of the venue (journal name, repository name, etc.)"
137
+ ),
138
+ )
139
+ venue_type: PublicationVenueType | None = Field(
140
+ default=None,
141
+ description="The type of venue: journal, repository, book, conference, etc.",
142
+ )
143
+ issn: list[str] | None = Field(
144
+ default=None,
145
+ description="List of ISSNs associated with this venue (print and electronic)",
146
+ )
147
+ issn_l: str | None = Field(
148
+ default=None,
149
+ description=(
150
+ "The linking ISSN - a canonical ISSN for the venue across format changes"
151
+ ),
152
+ )
153
+ host_organization_name: str | None = Field(
154
+ default=None,
155
+ description="Display name of the host organization (publisher)",
156
+ )
157
+
158
+
73
159
  class BibliographicMetadataEnhancement(BaseModel):
74
160
  """
75
161
  An enhancement which is made up of bibliographic metadata.
@@ -111,6 +197,14 @@ other works have cited this work
111
197
  description="The name of the entity which published the version of record.",
112
198
  )
113
199
  title: str | None = Field(default=None, description="The title of the reference.")
200
+ pagination: Pagination | None = Field(
201
+ default=None,
202
+ description="Pagination info (volume, issue, pages).",
203
+ )
204
+ publication_venue: PublicationVenue | None = Field(
205
+ default=None,
206
+ description="Publication venue information (journal, repository, etc.).",
207
+ )
114
208
 
115
209
  @property
116
210
  def fingerprint(self) -> str:
@@ -1,10 +1,24 @@
1
1
  """Identifier classes for the Destiny SDK."""
2
2
 
3
+ import re
4
+ import unicodedata
3
5
  import uuid
4
6
  from enum import StrEnum, auto
5
7
  from typing import Annotated, Literal, Self
6
8
 
7
- from pydantic import UUID4, BaseModel, Field, TypeAdapter, field_validator
9
+ from pydantic import UUID4, BaseModel, Field, PositiveInt, TypeAdapter, field_validator
10
+
11
+ # Case-insensitive patterns for DOI URL prefix stripping
12
+ _DOI_URL_PREFIX_RE = re.compile(r"^(?:https?://)?(?:dx\.)?doi\.org/", re.IGNORECASE)
13
+ _DOI_SCHEME_RE = re.compile(r"^doi:\s*", re.IGNORECASE)
14
+
15
+ # Translation table for DOI canonicalization (extensible for future characters)
16
+ _DOI_CHAR_TRANSLATION = str.maketrans(
17
+ {
18
+ "\u00a0": " ", # NBSP -> space
19
+ "\u2010": "-", # Unicode hyphen -> ASCII hyphen
20
+ }
21
+ )
8
22
 
9
23
 
10
24
  class ExternalIdentifierType(StrEnum):
@@ -24,7 +38,7 @@ class ExternalIdentifierType(StrEnum):
24
38
  PM_ID = auto()
25
39
  """A PubMed ID which is a unique identifier for a document in PubMed."""
26
40
  PRO_QUEST = auto()
27
- """A ProQuest ID which is a unqiue identifier for a document in ProQuest."""
41
+ """A ProQuest ID which is a unique identifier for a document in ProQuest."""
28
42
  OPEN_ALEX = auto()
29
43
  """An OpenAlex ID which is a unique identifier for a document in OpenAlex."""
30
44
  OTHER = auto()
@@ -35,8 +49,13 @@ class DOIIdentifier(BaseModel):
35
49
  """An external identifier representing a DOI."""
36
50
 
37
51
  identifier: str = Field(
38
- description="The DOI of the reference.",
39
- pattern=r"^10\.\d{4,9}/[-._;()/:a-zA-Z0-9%<>\[\]+&]+$",
52
+ description=(
53
+ "The DOI of the reference. "
54
+ "Format: '10.<registrant>/<suffix>' where registrant is 4-9 digits "
55
+ "and suffix is any non-whitespace characters. "
56
+ "Examples: 10.1000/journal.pone.0001, 10.18730/9WQ$D, 10.1000/édition"
57
+ ),
58
+ pattern=r"^10\.\d{4,9}/\S+$",
40
59
  )
41
60
  identifier_type: Literal[ExternalIdentifierType.DOI] = Field(
42
61
  ExternalIdentifierType.DOI, description="The type of identifier used."
@@ -44,23 +63,33 @@ class DOIIdentifier(BaseModel):
44
63
 
45
64
  @field_validator("identifier", mode="before")
46
65
  @classmethod
47
- def remove_doi_url(cls, value: str) -> str:
48
- """Remove the URL part of the DOI if it exists."""
49
- return (
50
- value.removeprefix("http://")
51
- .removeprefix("https://")
52
- .removeprefix("doi.org/")
53
- .removeprefix("dx.doi.org/")
54
- .removeprefix("doi:")
55
- .strip()
56
- )
66
+ def canonicalize_doi(cls, value: str) -> str:
67
+ """
68
+ Canonicalize DOI: strip URL prefixes and normalize Unicode.
69
+
70
+ - NFC Unicode normalization
71
+ - Translate special characters (NBSP, Unicode hyphen) via translation table
72
+ - Case-insensitive URL/scheme prefix stripping
73
+ """
74
+ # NFC normalization first
75
+ value = unicodedata.normalize("NFC", str(value))
76
+ # Translate special characters and strip
77
+ value = value.translate(_DOI_CHAR_TRANSLATION).strip()
78
+ # Strip URL prefixes (case-insensitive)
79
+ value = _DOI_URL_PREFIX_RE.sub("", value)
80
+ value = _DOI_SCHEME_RE.sub("", value)
81
+ return value.strip()
57
82
 
58
83
 
59
84
  class ProQuestIdentifier(BaseModel):
60
85
  """An external identifier representing a ProQuest ID."""
61
86
 
62
87
  identifier: str = Field(
63
- description="The ProQuest id of the reference", pattern=r"[0-9]+$"
88
+ description=(
89
+ "The ProQuest ID of the reference. "
90
+ "Format: numeric digits only. Example: 12345678"
91
+ ),
92
+ pattern=r"[0-9]+$",
64
93
  )
65
94
  identifier_type: Literal[ExternalIdentifierType.PRO_QUEST] = Field(
66
95
  ExternalIdentifierType.PRO_QUEST, description="The type of identifier used."
@@ -84,12 +113,16 @@ class ERICIdentifier(BaseModel):
84
113
  """
85
114
  An external identifier representing an ERIC Number.
86
115
 
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).
116
+ An ERIC Number is defined as a unique identifying number preceded by
117
+ ED (for a non-journal document) or EJ (for a journal article).
89
118
  """
90
119
 
91
120
  identifier: str = Field(
92
- description="The ERIC Number of the reference.", pattern=r"E[D|J][0-9]+$"
121
+ description=(
122
+ "The ERIC Number. "
123
+ "Format: 'ED' or 'EJ' followed by digits. Example: ED123456 or EJ789012"
124
+ ),
125
+ pattern=r"E[D|J][0-9]+$",
93
126
  )
94
127
  identifier_type: Literal[ExternalIdentifierType.ERIC] = Field(
95
128
  ExternalIdentifierType.ERIC, description="The type of identifier used."
@@ -112,7 +145,9 @@ class ERICIdentifier(BaseModel):
112
145
  class PubMedIdentifier(BaseModel):
113
146
  """An external identifier representing a PubMed ID."""
114
147
 
115
- identifier: int = Field(description="The PubMed ID of the reference.")
148
+ identifier: PositiveInt = Field(
149
+ description="The PubMed ID (PMID) of the reference. Example: 12345678",
150
+ )
116
151
  identifier_type: Literal[ExternalIdentifierType.PM_ID] = Field(
117
152
  ExternalIdentifierType.PM_ID, description="The type of identifier used."
118
153
  )
@@ -122,7 +157,11 @@ class OpenAlexIdentifier(BaseModel):
122
157
  """An external identifier representing an OpenAlex ID."""
123
158
 
124
159
  identifier: str = Field(
125
- description="The OpenAlex ID of the reference.", pattern=r"^W\d+$"
160
+ description=(
161
+ "The OpenAlex ID of the reference. "
162
+ "Format: 'W' followed by digits. Example: W2741809807"
163
+ ),
164
+ pattern=r"^W\d+$",
126
165
  )
127
166
  identifier_type: Literal[ExternalIdentifierType.OPEN_ALEX] = Field(
128
167
  ExternalIdentifierType.OPEN_ALEX, description="The type of identifier used."
@@ -8,7 +8,13 @@ from pydantic import ValidationError
8
8
 
9
9
 
10
10
  def test_bibliographic_metadata_enhancement_valid():
11
- # Create valid bibliographic content
11
+ # Create valid bibliographic content with pagination
12
+ pagination = destiny_sdk.enhancements.Pagination(
13
+ volume="42",
14
+ issue="7",
15
+ first_page="495",
16
+ last_page="512",
17
+ )
12
18
  bibliographic = destiny_sdk.enhancements.BibliographicMetadataEnhancement(
13
19
  enhancement_type=destiny_sdk.enhancements.EnhancementType.BIBLIOGRAPHIC,
14
20
  authorship=[],
@@ -19,6 +25,7 @@ def test_bibliographic_metadata_enhancement_valid():
19
25
  publication_year=2020,
20
26
  publisher="Test Publisher",
21
27
  title="Test Title",
28
+ pagination=pagination,
22
29
  )
23
30
  enhancement = destiny_sdk.enhancements.Enhancement(
24
31
  id=uuid.uuid4(),
@@ -33,6 +40,48 @@ def test_bibliographic_metadata_enhancement_valid():
33
40
  enhancement.content.enhancement_type
34
41
  == destiny_sdk.enhancements.EnhancementType.BIBLIOGRAPHIC
35
42
  )
43
+ assert enhancement.content.pagination.volume == "42"
44
+ assert enhancement.content.pagination.issue == "7"
45
+ assert enhancement.content.pagination.first_page == "495"
46
+ assert enhancement.content.pagination.last_page == "512"
47
+
48
+
49
+ def test_bibliographic_metadata_enhancement_non_numeric_pagination_fields():
50
+ """Test that non-numeric pagination fields are accepted (per OpenAlex spec)."""
51
+ bibliographic = destiny_sdk.enhancements.BibliographicMetadataEnhancement(
52
+ title="Test Title",
53
+ pagination=destiny_sdk.enhancements.Pagination(
54
+ volume="Spring",
55
+ issue="Special Issue",
56
+ first_page="A1",
57
+ last_page="A15",
58
+ ),
59
+ )
60
+ assert bibliographic.pagination.volume == "Spring"
61
+ assert bibliographic.pagination.issue == "Special Issue"
62
+ assert bibliographic.pagination.first_page == "A1"
63
+ assert bibliographic.pagination.last_page == "A15"
64
+
65
+
66
+ def test_bibliographic_metadata_enhancement_with_publication_venue():
67
+ """Test BibliographicMetadataEnhancement with publication_venue field."""
68
+ venue = destiny_sdk.enhancements.PublicationVenue(
69
+ display_name="Science",
70
+ venue_type=destiny_sdk.enhancements.PublicationVenueType.JOURNAL,
71
+ issn=["0036-8075"],
72
+ issn_l="0036-8075",
73
+ host_organization_name="AAAS",
74
+ )
75
+ bibliographic = destiny_sdk.enhancements.BibliographicMetadataEnhancement(
76
+ title="Test Article",
77
+ publication_venue=venue,
78
+ )
79
+ assert bibliographic.publication_venue is not None
80
+ assert bibliographic.publication_venue.display_name == "Science"
81
+ assert (
82
+ bibliographic.publication_venue.venue_type
83
+ == destiny_sdk.enhancements.PublicationVenueType.JOURNAL
84
+ )
36
85
 
37
86
 
38
87
  def test_abstract_content_enhancement_valid():
@@ -224,3 +273,28 @@ def test_association_enhancement_invalid_identifier_type_errors():
224
273
  ],
225
274
  association_type=destiny_sdk.enhancements.ReferenceAssociationType.CITES,
226
275
  )
276
+
277
+
278
+ def test_pagination_empty_string_to_none():
279
+ """Test that empty pagination strings are converted to None."""
280
+ pagination = destiny_sdk.enhancements.Pagination(
281
+ volume="", issue=" ", first_page=None, last_page="42"
282
+ )
283
+ assert pagination.volume is None
284
+ assert pagination.issue is None
285
+ assert pagination.first_page is None
286
+ assert pagination.last_page == "42"
287
+
288
+
289
+ def test_pagination_nbsp_normalized():
290
+ """Test that NBSP (U+00A0) is replaced with space and stripped."""
291
+ pagination = destiny_sdk.enhancements.Pagination(
292
+ volume="\u00a042\u00a0", # NBSP padding
293
+ issue="7\u00a0", # Trailing NBSP
294
+ first_page="\u00a0", # Only NBSP -> should become None
295
+ last_page="100",
296
+ )
297
+ assert pagination.volume == "42"
298
+ assert pagination.issue == "7"
299
+ assert pagination.first_page is None
300
+ assert pagination.last_page == "100"
@@ -30,6 +30,59 @@ def test_doi_url_removed():
30
30
  assert obj.identifier == "10.1000/xyz123"
31
31
 
32
32
 
33
+ @pytest.mark.parametrize(
34
+ "doi",
35
+ [
36
+ # DataCite GLIS characters: = ~ * $
37
+ # Crossref/legacy: #
38
+ "10.18730/9WQ$D", # Dollar sign
39
+ "10.18730/9WQ*D", # Asterisk
40
+ "10.18730/9WQ~D", # Tilde
41
+ "10.18730/9WQ=D", # Equals
42
+ "10.18730/9WQ#D", # Hash
43
+ "10.18730/9WQ$~*=#D", # Multiple special characters
44
+ # Latin Extended characters (accented letters)
45
+ "10.1000/journalÉdition", # É (U+00C9)
46
+ "10.1000/café", # é (U+00E9)
47
+ "10.1000/naïve", # ï (U+00EF)
48
+ "10.1000/Müller", # ü (U+00FC)
49
+ "10.1000/señor", # ñ (U+00F1)
50
+ ],
51
+ )
52
+ def test_valid_doi_with_special_characters(doi: str):
53
+ """Test that DOIs with DataCite GLIS and Latin Extended characters are valid."""
54
+ obj = destiny_sdk.identifiers.DOIIdentifier(
55
+ identifier_type=destiny_sdk.identifiers.ExternalIdentifierType.DOI,
56
+ identifier=doi,
57
+ )
58
+ assert obj.identifier == doi
59
+
60
+
61
+ @pytest.mark.parametrize(
62
+ ("doi_input", "expected"),
63
+ [
64
+ # Unicode hyphen → ASCII hyphen
65
+ ("10.1000/abc\u2010def", "10.1000/abc-def"),
66
+ # NBSP stripped (leading/trailing)
67
+ ("\u00a010.1000/xyz123", "10.1000/xyz123"),
68
+ ("10.1000/xyz123\u00a0", "10.1000/xyz123"),
69
+ # Case-insensitive URL prefix stripping
70
+ ("HTTP://DOI.ORG/10.1000/xyz123", "10.1000/xyz123"),
71
+ ("HTTPS://DOI.ORG/10.1000/xyz123", "10.1000/xyz123"),
72
+ ("https://DX.DOI.ORG/10.1000/xyz123", "10.1000/xyz123"),
73
+ ("DOI:10.1000/xyz123", "10.1000/xyz123"),
74
+ ("doi: 10.1000/xyz123", "10.1000/xyz123"),
75
+ ],
76
+ )
77
+ def test_doi_canonicalization(doi_input: str, expected: str):
78
+ """Test DOI canonicalization: Unicode normalization and URL prefix stripping."""
79
+ obj = destiny_sdk.identifiers.DOIIdentifier(
80
+ identifier_type=destiny_sdk.identifiers.ExternalIdentifierType.DOI,
81
+ identifier=doi_input,
82
+ )
83
+ assert obj.identifier == expected
84
+
85
+
33
86
  def test_valid_eric_identifier():
34
87
  obj = destiny_sdk.identifiers.ERICIdentifier(
35
88
  identifier_type=destiny_sdk.identifiers.ExternalIdentifierType.ERIC,
@@ -233,7 +233,7 @@ wheels = [
233
233
 
234
234
  [[package]]
235
235
  name = "destiny-sdk"
236
- version = "0.6.0"
236
+ version = "0.8.0"
237
237
  source = { editable = "." }
238
238
  dependencies = [
239
239
  { name = "cachetools" },
@@ -241,9 +241,6 @@ dependencies = [
241
241
  { name = "httpx" },
242
242
  { name = "msal" },
243
243
  { name = "pydantic" },
244
- { name = "pytest" },
245
- { name = "pytest-asyncio" },
246
- { name = "pytest-httpx" },
247
244
  { name = "python-jose" },
248
245
  ]
249
246
 
@@ -251,7 +248,10 @@ dependencies = [
251
248
  dev = [
252
249
  { name = "mypy" },
253
250
  { name = "pre-commit" },
251
+ { name = "pytest" },
252
+ { name = "pytest-asyncio" },
254
253
  { name = "pytest-env" },
254
+ { name = "pytest-httpx" },
255
255
  { name = "ruff" },
256
256
  { name = "uvloop" },
257
257
  ]
@@ -263,9 +263,6 @@ requires-dist = [
263
263
  { name = "httpx", specifier = ">=0.28.1,<0.29" },
264
264
  { name = "msal", specifier = ">=1.34.0" },
265
265
  { name = "pydantic", specifier = ">=2.11.3,<3" },
266
- { name = "pytest", specifier = ">=8.4.0,<9" },
267
- { name = "pytest-asyncio", specifier = ">=1.0.0,<2" },
268
- { name = "pytest-httpx", specifier = ">=0.35.0,<0.36" },
269
266
  { name = "python-jose", specifier = ">=3.4.0,<4" },
270
267
  ]
271
268
  provides-extras = ["labs"]
@@ -274,7 +271,10 @@ provides-extras = ["labs"]
274
271
  dev = [
275
272
  { name = "mypy", specifier = ">=1.15.0,<2" },
276
273
  { name = "pre-commit", specifier = ">=4.2.0,<5" },
274
+ { name = "pytest", specifier = ">=8.4.0,<9" },
275
+ { name = "pytest-asyncio", specifier = ">=1.0.0,<2" },
277
276
  { name = "pytest-env", specifier = ">=1.1.5,<2" },
277
+ { name = "pytest-httpx", specifier = ">=0.35.0,<0.36" },
278
278
  { name = "ruff", specifier = ">=0.11.5,<0.12" },
279
279
  { name = "uvloop", specifier = ">=0.21.0,<0.22" },
280
280
  ]
File without changes
File without changes
File without changes