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 +5 -0
- ref_verify/abstract_lookup.py +226 -0
- ref_verify/claim_check.py +876 -0
- ref_verify/cli.py +162 -0
- ref_verify/crossref.py +89 -0
- ref_verify/doi_check.py +191 -0
- ref_verify/models.py +89 -0
- ref_verify/numeric_claim.py +425 -0
- ref_verify/pubmed.py +156 -0
- ref_verify/semantic_scholar.py +80 -0
- ref_verify-1.1.2.dist-info/METADATA +250 -0
- ref_verify-1.1.2.dist-info/RECORD +16 -0
- ref_verify-1.1.2.dist-info/WHEEL +5 -0
- ref_verify-1.1.2.dist-info/entry_points.txt +2 -0
- ref_verify-1.1.2.dist-info/licenses/LICENSE +21 -0
- ref_verify-1.1.2.dist-info/top_level.txt +1 -0
ref_verify/__init__.py
ADDED
|
@@ -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
|
+
)
|