destiny_sdk 0.7.4__tar.gz → 0.7.6__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.4 → destiny_sdk-0.7.6}/PKG-INFO +2 -5
  2. {destiny_sdk-0.7.4 → destiny_sdk-0.7.6}/pyproject.toml +5 -5
  3. {destiny_sdk-0.7.4 → destiny_sdk-0.7.6}/src/destiny_sdk/client.py +19 -7
  4. {destiny_sdk-0.7.4 → destiny_sdk-0.7.6}/src/destiny_sdk/enhancements.py +41 -1
  5. {destiny_sdk-0.7.4 → destiny_sdk-0.7.6}/src/destiny_sdk/identifiers.py +59 -20
  6. {destiny_sdk-0.7.4 → destiny_sdk-0.7.6}/tests/unit/test_enhancements.py +54 -1
  7. {destiny_sdk-0.7.4 → destiny_sdk-0.7.6}/tests/unit/test_identifiers.py +53 -0
  8. {destiny_sdk-0.7.4 → destiny_sdk-0.7.6}/uv.lock +8 -8
  9. {destiny_sdk-0.7.4 → destiny_sdk-0.7.6}/.gitignore +0 -0
  10. {destiny_sdk-0.7.4 → destiny_sdk-0.7.6}/LICENSE +0 -0
  11. {destiny_sdk-0.7.4 → destiny_sdk-0.7.6}/README.md +0 -0
  12. {destiny_sdk-0.7.4 → destiny_sdk-0.7.6}/src/destiny_sdk/__init__.py +0 -0
  13. {destiny_sdk-0.7.4 → destiny_sdk-0.7.6}/src/destiny_sdk/auth.py +0 -0
  14. {destiny_sdk-0.7.4 → destiny_sdk-0.7.6}/src/destiny_sdk/core.py +0 -0
  15. {destiny_sdk-0.7.4 → destiny_sdk-0.7.6}/src/destiny_sdk/imports.py +0 -0
  16. {destiny_sdk-0.7.4 → destiny_sdk-0.7.6}/src/destiny_sdk/labs/__init__.py +0 -0
  17. {destiny_sdk-0.7.4 → destiny_sdk-0.7.6}/src/destiny_sdk/labs/references.py +0 -0
  18. {destiny_sdk-0.7.4 → destiny_sdk-0.7.6}/src/destiny_sdk/parsers/__init__.py +0 -0
  19. {destiny_sdk-0.7.4 → destiny_sdk-0.7.6}/src/destiny_sdk/parsers/eppi_parser.py +0 -0
  20. {destiny_sdk-0.7.4 → destiny_sdk-0.7.6}/src/destiny_sdk/parsers/exceptions.py +0 -0
  21. {destiny_sdk-0.7.4 → destiny_sdk-0.7.6}/src/destiny_sdk/py.typed +0 -0
  22. {destiny_sdk-0.7.4 → destiny_sdk-0.7.6}/src/destiny_sdk/references.py +0 -0
  23. {destiny_sdk-0.7.4 → destiny_sdk-0.7.6}/src/destiny_sdk/robots.py +0 -0
  24. {destiny_sdk-0.7.4 → destiny_sdk-0.7.6}/src/destiny_sdk/search.py +0 -0
  25. {destiny_sdk-0.7.4 → destiny_sdk-0.7.6}/src/destiny_sdk/visibility.py +0 -0
  26. {destiny_sdk-0.7.4 → destiny_sdk-0.7.6}/tests/unit/__init__.py +0 -0
  27. {destiny_sdk-0.7.4 → destiny_sdk-0.7.6}/tests/unit/conftest.py +0 -0
  28. {destiny_sdk-0.7.4 → destiny_sdk-0.7.6}/tests/unit/labs/test_references.py +0 -0
  29. {destiny_sdk-0.7.4 → destiny_sdk-0.7.6}/tests/unit/parsers/test_eppi_parser.py +0 -0
  30. {destiny_sdk-0.7.4 → destiny_sdk-0.7.6}/tests/unit/test_auth.py +0 -0
  31. {destiny_sdk-0.7.4 → destiny_sdk-0.7.6}/tests/unit/test_client.py +0 -0
  32. {destiny_sdk-0.7.4 → destiny_sdk-0.7.6}/tests/unit/test_data/destiny_references.jsonl +0 -0
  33. {destiny_sdk-0.7.4 → destiny_sdk-0.7.6}/tests/unit/test_data/eppi_import.jsonl +0 -0
  34. {destiny_sdk-0.7.4 → destiny_sdk-0.7.6}/tests/unit/test_data/eppi_import_with_annotations.jsonl +0 -0
  35. {destiny_sdk-0.7.4 → destiny_sdk-0.7.6}/tests/unit/test_data/eppi_import_with_raw.jsonl +0 -0
  36. {destiny_sdk-0.7.4 → destiny_sdk-0.7.6}/tests/unit/test_data/eppi_report.json +0 -0
  37. {destiny_sdk-0.7.4 → destiny_sdk-0.7.6}/tests/unit/test_references.py +0 -0
  38. {destiny_sdk-0.7.4 → destiny_sdk-0.7.6}/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.4
3
+ Version: 0.7.6
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.4"
37
+ requires-python = ">=3.12, <4"
38
+ version = "0.7.6"
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
@@ -479,6 +477,7 @@ class OAuthClient:
479
477
  self,
480
478
  base_url: HttpUrl | str,
481
479
  auth: httpx.Auth | None = None,
480
+ timeout: int = 10,
482
481
  ) -> None:
483
482
  """
484
483
  Initialize the client.
@@ -490,6 +489,8 @@ class OAuthClient:
490
489
  instance of ``OAuthMiddleware``, unless you need to create a custom auth
491
490
  class.
492
491
  :type auth: httpx.Auth | None
492
+ :param timeout: The timeout for requests, in seconds. Defaults to 10 seconds.
493
+ :type timeout: int
493
494
  """
494
495
  self._client = httpx.Client(
495
496
  base_url=str(base_url).removesuffix("/").removesuffix("/v1") + "/v1",
@@ -497,6 +498,7 @@ class OAuthClient:
497
498
  "Content-Type": "application/json",
498
499
  "User-Agent": user_agent,
499
500
  },
501
+ timeout=timeout,
500
502
  )
501
503
 
502
504
  if auth:
@@ -529,6 +531,7 @@ class OAuthClient:
529
531
  annotations: list[str | AnnotationFilter] | None = None,
530
532
  sort: str | None = None,
531
533
  page: int = 1,
534
+ timeout: int | None = None,
532
535
  ) -> ReferenceSearchResult:
533
536
  """
534
537
  Send a search request to the Destiny Repository API.
@@ -547,6 +550,9 @@ class OAuthClient:
547
550
  :type sort: str | None
548
551
  :param page: The page number of results to retrieve.
549
552
  :type page: int
553
+ :param timeout: The timeout for the request, in seconds. If provided, this will override
554
+ the client timeout.
555
+ :type timeout: int | None
550
556
  :return: The response from the API.
551
557
  :rtype: libs.sdk.src.destiny_sdk.references.ReferenceSearchResult
552
558
  """ # noqa: E501
@@ -562,6 +568,7 @@ class OAuthClient:
562
568
  response = self._client.get(
563
569
  "/references/search/",
564
570
  params=params,
571
+ timeout=timeout or httpx.USE_CLIENT_DEFAULT,
565
572
  )
566
573
  self._raise_for_status(response)
567
574
  return ReferenceSearchResult.model_validate(response.json())
@@ -569,6 +576,7 @@ class OAuthClient:
569
576
  def lookup(
570
577
  self,
571
578
  identifiers: list[str | IdentifierLookup],
579
+ timeout: int | None = None,
572
580
  ) -> list[Reference]:
573
581
  """
574
582
  Lookup references by identifiers.
@@ -577,6 +585,9 @@ class OAuthClient:
577
585
 
578
586
  :param identifiers: The identifiers to look up.
579
587
  :type identifiers: list[str | libs.sdk.src.destiny_sdk.identifiers.IdentifierLookup]
588
+ :param timeout: The timeout for the request, in seconds. If provided, this will override
589
+ the client timeout.
590
+ :type timeout: int | None
580
591
  :return: The list of references matching the identifiers.
581
592
  :rtype: list[libs.sdk.src.destiny_sdk.references.Reference]
582
593
  """ # noqa: E501
@@ -585,6 +596,7 @@ class OAuthClient:
585
596
  params={
586
597
  "identifier": ",".join([str(identifier) for identifier in identifiers])
587
598
  },
599
+ timeout=timeout or httpx.USE_CLIENT_DEFAULT,
588
600
  )
589
601
  self._raise_for_status(response)
590
602
  return TypeAdapter(list[Reference]).validate_python(response.json())
@@ -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
@@ -70,6 +70,42 @@ class Authorship(BaseModel):
70
70
  )
71
71
 
72
72
 
73
+ class Pagination(BaseModel):
74
+ """
75
+ Pagination information for journal articles.
76
+
77
+ Maps to OpenAlex's work.biblio object. All fields are strings to match
78
+ OpenAlex's format, which may include non-numeric values like "Spring" or "A1".
79
+ """
80
+
81
+ volume: str | None = Field(
82
+ default=None,
83
+ description="The volume number of the journal/publication.",
84
+ )
85
+ issue: str | None = Field(
86
+ default=None,
87
+ description="The issue number of the journal/publication.",
88
+ )
89
+ first_page: str | None = Field(
90
+ default=None,
91
+ description="The first page number of the reference in the publication.",
92
+ )
93
+ last_page: str | None = Field(
94
+ default=None,
95
+ description="The last page number of the reference in the publication.",
96
+ )
97
+
98
+ @field_validator("volume", "issue", "first_page", "last_page", mode="before")
99
+ @classmethod
100
+ def normalize_pagination_string(cls, value: str | None) -> str | None:
101
+ """Normalize pagination strings: NBSP to space, strip, empty to None."""
102
+ if isinstance(value, str):
103
+ # Replace NBSP with space, then strip
104
+ value = value.replace("\u00a0", " ").strip()
105
+ return value if value else None
106
+ return value
107
+
108
+
73
109
  class BibliographicMetadataEnhancement(BaseModel):
74
110
  """
75
111
  An enhancement which is made up of bibliographic metadata.
@@ -111,6 +147,10 @@ other works have cited this work
111
147
  description="The name of the entity which published the version of record.",
112
148
  )
113
149
  title: str | None = Field(default=None, description="The title of the reference.")
150
+ pagination: Pagination | None = Field(
151
+ default=None,
152
+ description="Pagination info (volume, issue, pages).",
153
+ )
114
154
 
115
155
  @property
116
156
  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,27 @@ 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"
36
64
 
37
65
 
38
66
  def test_abstract_content_enhancement_valid():
@@ -224,3 +252,28 @@ def test_association_enhancement_invalid_identifier_type_errors():
224
252
  ],
225
253
  association_type=destiny_sdk.enhancements.ReferenceAssociationType.CITES,
226
254
  )
255
+
256
+
257
+ def test_pagination_empty_string_to_none():
258
+ """Test that empty pagination strings are converted to None."""
259
+ pagination = destiny_sdk.enhancements.Pagination(
260
+ volume="", issue=" ", first_page=None, last_page="42"
261
+ )
262
+ assert pagination.volume is None
263
+ assert pagination.issue is None
264
+ assert pagination.first_page is None
265
+ assert pagination.last_page == "42"
266
+
267
+
268
+ def test_pagination_nbsp_normalized():
269
+ """Test that NBSP (U+00A0) is replaced with space and stripped."""
270
+ pagination = destiny_sdk.enhancements.Pagination(
271
+ volume="\u00a042\u00a0", # NBSP padding
272
+ issue="7\u00a0", # Trailing NBSP
273
+ first_page="\u00a0", # Only NBSP -> should become None
274
+ last_page="100",
275
+ )
276
+ assert pagination.volume == "42"
277
+ assert pagination.issue == "7"
278
+ assert pagination.first_page is None
279
+ 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,
@@ -1,5 +1,5 @@
1
1
  version = 1
2
- revision = 2
2
+ revision = 3
3
3
  requires-python = ">=3.12, <4"
4
4
 
5
5
  [[package]]
@@ -233,7 +233,7 @@ wheels = [
233
233
 
234
234
  [[package]]
235
235
  name = "destiny-sdk"
236
- version = "0.6.0"
236
+ version = "0.7.6"
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