ref-verify 1.1.2__py3-none-any.whl

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.
ref_verify/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """Executable reference verification helpers."""
2
+
3
+ __all__ = ["__version__"]
4
+
5
+ __version__ = "1.1.2"
@@ -0,0 +1,226 @@
1
+ from __future__ import annotations
2
+
3
+ import time
4
+ from typing import Protocol
5
+
6
+ from ref_verify.doi_check import doi_matches
7
+ from ref_verify.models import AbstractLookupResult, AbstractSourceAttempt, PaperRecord
8
+
9
+
10
+ class AbstractSourceClient(Protocol):
11
+ source_name: str
12
+
13
+ def fetch_record(self, doi: str) -> PaperRecord | None:
14
+ ...
15
+
16
+
17
+ class AbstractSourceError(Exception):
18
+ def __init__(self, status: str, reason: str) -> None:
19
+ super().__init__(reason)
20
+ self.status = status
21
+ self.reason = reason
22
+
23
+
24
+ def lookup_abstract(
25
+ requested_doi: str,
26
+ crossref_record: PaperRecord,
27
+ fallback_clients: list[AbstractSourceClient] | tuple[AbstractSourceClient, ...] = (),
28
+ *,
29
+ use_crossref_abstract: bool = True,
30
+ ) -> AbstractLookupResult:
31
+ attempts: list[AbstractSourceAttempt] = []
32
+
33
+ if not doi_matches(requested_doi, crossref_record.doi):
34
+ attempts.append(
35
+ AbstractSourceAttempt(
36
+ source="crossref",
37
+ status="DOI_MISMATCH",
38
+ reason="CrossRef returned a DOI that does not match the requested DOI.",
39
+ doi=crossref_record.doi,
40
+ )
41
+ )
42
+ return AbstractLookupResult(
43
+ record=crossref_record,
44
+ abstract_source=None,
45
+ attempts=attempts,
46
+ error_code="DOI_MISMATCH",
47
+ )
48
+
49
+ if crossref_record.abstract and use_crossref_abstract:
50
+ attempts.append(
51
+ AbstractSourceAttempt(
52
+ source="crossref",
53
+ status="FOUND",
54
+ reason="CrossRef record included an abstract.",
55
+ doi=crossref_record.doi,
56
+ )
57
+ )
58
+ return AbstractLookupResult(
59
+ record=crossref_record,
60
+ abstract_source="crossref",
61
+ attempts=attempts,
62
+ )
63
+
64
+ if crossref_record.abstract and not use_crossref_abstract:
65
+ attempts.append(
66
+ AbstractSourceAttempt(
67
+ source="crossref",
68
+ status="UNSUPPORTED",
69
+ reason="CrossRef abstract was skipped by source selection.",
70
+ doi=crossref_record.doi,
71
+ )
72
+ )
73
+ else:
74
+ attempts.append(
75
+ AbstractSourceAttempt(
76
+ source="crossref",
77
+ status="NO_ABSTRACT",
78
+ reason="CrossRef record had no abstract.",
79
+ doi=crossref_record.doi,
80
+ )
81
+ )
82
+
83
+ source_attempts, record, abstract_source = _lookup_fallback_sources(requested_doi, fallback_clients)
84
+ attempts.extend(source_attempts)
85
+ if record is not None:
86
+ return AbstractLookupResult(
87
+ record=record,
88
+ abstract_source=abstract_source,
89
+ attempts=attempts,
90
+ )
91
+
92
+ return AbstractLookupResult(
93
+ record=crossref_record,
94
+ abstract_source=None,
95
+ attempts=attempts,
96
+ error_code=_final_error_code(attempts, promote_doi_mismatch=False),
97
+ )
98
+
99
+
100
+ def lookup_selected_abstract(
101
+ requested_doi: str,
102
+ source_clients: list[AbstractSourceClient] | tuple[AbstractSourceClient, ...],
103
+ ) -> AbstractLookupResult:
104
+ attempts, record, abstract_source = _lookup_fallback_sources(requested_doi, source_clients)
105
+ if record is not None:
106
+ return AbstractLookupResult(
107
+ record=record,
108
+ abstract_source=abstract_source,
109
+ attempts=attempts,
110
+ )
111
+ return AbstractLookupResult(
112
+ record=PaperRecord(
113
+ doi=requested_doi,
114
+ title="[title missing]",
115
+ authors=[],
116
+ year=None,
117
+ abstract=None,
118
+ source="selected abstract source",
119
+ ),
120
+ abstract_source=None,
121
+ attempts=attempts,
122
+ error_code=_final_error_code(attempts, promote_doi_mismatch=True),
123
+ )
124
+
125
+
126
+ def _lookup_fallback_sources(
127
+ requested_doi: str,
128
+ fallback_clients: list[AbstractSourceClient] | tuple[AbstractSourceClient, ...],
129
+ ) -> tuple[list[AbstractSourceAttempt], PaperRecord | None, str | None]:
130
+ attempts: list[AbstractSourceAttempt] = []
131
+ for client in fallback_clients:
132
+ started = time.monotonic()
133
+ try:
134
+ record = client.fetch_record(requested_doi)
135
+ except AbstractSourceError as exc:
136
+ attempts.append(
137
+ _attempt(client.source_name, exc.status, exc.reason, started)
138
+ )
139
+ continue
140
+ except TimeoutError as exc:
141
+ attempts.append(
142
+ _attempt(client.source_name, "TIMEOUT", str(exc) or "Source lookup timed out.", started)
143
+ )
144
+ continue
145
+ except Exception as exc:
146
+ attempts.append(
147
+ _attempt(client.source_name, "API_ERROR", str(exc) or "Source lookup failed.", started)
148
+ )
149
+ continue
150
+
151
+ if record is None:
152
+ attempts.append(
153
+ _attempt(client.source_name, "NO_ABSTRACT", "Source returned no DOI-bound abstract.", started)
154
+ )
155
+ continue
156
+ if not doi_matches(requested_doi, record.doi):
157
+ attempts.append(
158
+ _attempt(
159
+ client.source_name,
160
+ "DOI_MISMATCH",
161
+ "Source returned a DOI that does not match the requested DOI.",
162
+ started,
163
+ doi=record.doi,
164
+ )
165
+ )
166
+ continue
167
+ if not record.abstract:
168
+ attempts.append(
169
+ _attempt(
170
+ client.source_name,
171
+ "NO_ABSTRACT",
172
+ "Source record had no abstract.",
173
+ started,
174
+ doi=record.doi,
175
+ )
176
+ )
177
+ continue
178
+
179
+ attempts.append(
180
+ _attempt(
181
+ client.source_name,
182
+ "FOUND",
183
+ "Source returned a DOI-bound abstract.",
184
+ started,
185
+ doi=record.doi,
186
+ )
187
+ )
188
+ return attempts, record, client.source_name
189
+
190
+ return attempts, None, None
191
+
192
+
193
+ def _final_error_code(
194
+ attempts: list[AbstractSourceAttempt],
195
+ *,
196
+ promote_doi_mismatch: bool,
197
+ ) -> str:
198
+ statuses = [attempt.status for attempt in attempts]
199
+ if promote_doi_mismatch and "DOI_MISMATCH" in statuses:
200
+ return "DOI_MISMATCH"
201
+ if "API_ERROR" in statuses:
202
+ return "SOURCE_API_ERROR"
203
+ if "TIMEOUT" in statuses:
204
+ return "SOURCE_TIMEOUT"
205
+ if "UNSUPPORTED" in statuses and "NO_ABSTRACT" not in statuses:
206
+ return "SOURCE_UNSUPPORTED"
207
+ if statuses and all(status == "NOT_FOUND" for status in statuses):
208
+ return "DOI_NOT_FOUND"
209
+ return "NO_ABSTRACT"
210
+
211
+
212
+ def _attempt(
213
+ source: str,
214
+ status: str,
215
+ reason: str,
216
+ started: float,
217
+ *,
218
+ doi: str | None = None,
219
+ ) -> AbstractSourceAttempt:
220
+ return AbstractSourceAttempt(
221
+ source=source,
222
+ status=status,
223
+ reason=reason,
224
+ doi=doi,
225
+ elapsed_ms=max(0, round((time.monotonic() - started) * 1000)),
226
+ )