bibauthors 0.1.0__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.
- bibauthors/__init__.py +108 -0
- bibauthors/cache.py +199 -0
- bibauthors/cli.py +297 -0
- bibauthors/config.py +191 -0
- bibauthors/entry.py +142 -0
- bibauthors/http.py +380 -0
- bibauthors/latex.py +399 -0
- bibauthors/names.py +1424 -0
- bibauthors/parsers/__init__.py +148 -0
- bibauthors/parsers/bibtex.py +593 -0
- bibauthors/parsers/csljson.py +220 -0
- bibauthors/providers/__init__.py +239 -0
- bibauthors/providers/arxiv.py +209 -0
- bibauthors/providers/crossref.py +254 -0
- bibauthors/providers/openalex.py +212 -0
- bibauthors/providers/semanticscholar.py +160 -0
- bibauthors/report.py +371 -0
- bibauthors/verdict.py +153 -0
- bibauthors/verify.py +397 -0
- bibauthors-0.1.0.dist-info/METADATA +542 -0
- bibauthors-0.1.0.dist-info/RECORD +25 -0
- bibauthors-0.1.0.dist-info/WHEEL +5 -0
- bibauthors-0.1.0.dist-info/entry_points.txt +2 -0
- bibauthors-0.1.0.dist-info/licenses/LICENSE +21 -0
- bibauthors-0.1.0.dist-info/top_level.txt +1 -0
bibauthors/__init__.py
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""bibauthors: verify bibliography AUTHOR LISTS against authoritative metadata.
|
|
2
|
+
|
|
3
|
+
Every citation checker matches on title and DOI. This one checks the author
|
|
4
|
+
list, because the most dangerous hallucinated citation is the chimeric one: a
|
|
5
|
+
real title, a real venue, a real year, and an invented author list. It passes
|
|
6
|
+
a title-matching checker with a clean report and it looks real at a glance.
|
|
7
|
+
|
|
8
|
+
The short version of the API:
|
|
9
|
+
|
|
10
|
+
from bibauthors import verify_bibliography, Config, Verdict
|
|
11
|
+
|
|
12
|
+
report = verify_bibliography("refs.bib", Config(offline=True))
|
|
13
|
+
for result in report.results:
|
|
14
|
+
if result.verdict == Verdict.DISJOINT:
|
|
15
|
+
print(result.entry.key, "needs a manual look")
|
|
16
|
+
raise SystemExit(report.exit_code())
|
|
17
|
+
|
|
18
|
+
A DISJOINT verdict is a prompt to check an entry by hand. It is not an
|
|
19
|
+
accusation of fabrication.
|
|
20
|
+
|
|
21
|
+
Author: Manu Nicholas Jacob, Independent Researcher.
|
|
22
|
+
ORCID 0009-0007-6589-6572. MIT licensed.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
__version__ = "0.1.0"
|
|
28
|
+
__author__ = "Manu Nicholas Jacob"
|
|
29
|
+
__license__ = "MIT"
|
|
30
|
+
|
|
31
|
+
from .cache import Cache, CacheMiss
|
|
32
|
+
from .config import Config
|
|
33
|
+
from .entry import Entry, extract_arxiv_id, normalize_doi
|
|
34
|
+
from .http import HttpClient
|
|
35
|
+
from .names import (
|
|
36
|
+
ListComparison,
|
|
37
|
+
NameComparison,
|
|
38
|
+
PersonName,
|
|
39
|
+
compare_author_lists,
|
|
40
|
+
compare_names,
|
|
41
|
+
fold,
|
|
42
|
+
parse_name,
|
|
43
|
+
parse_names,
|
|
44
|
+
)
|
|
45
|
+
from .parsers import ParseError, load_bibliography, parse_bibtex, parse_csl_json
|
|
46
|
+
from .providers import Provider, ProviderUnavailable, Record, build_providers
|
|
47
|
+
from .report import format_github_annotations, format_json, format_text, to_dict
|
|
48
|
+
from .verdict import DESCRIPTIONS, SEVERITY, Thresholds, Verdict
|
|
49
|
+
from .verify import (
|
|
50
|
+
SCHEMA_VERSION,
|
|
51
|
+
EntryResult,
|
|
52
|
+
Report,
|
|
53
|
+
Verifier,
|
|
54
|
+
YearFinding,
|
|
55
|
+
verify_bibliography,
|
|
56
|
+
verify_entries,
|
|
57
|
+
verify_entry,
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
__all__ = [
|
|
61
|
+
"__version__",
|
|
62
|
+
# front doors
|
|
63
|
+
"verify_bibliography",
|
|
64
|
+
"verify_entries",
|
|
65
|
+
"verify_entry",
|
|
66
|
+
"Verifier",
|
|
67
|
+
# input
|
|
68
|
+
"load_bibliography",
|
|
69
|
+
"parse_bibtex",
|
|
70
|
+
"parse_csl_json",
|
|
71
|
+
"ParseError",
|
|
72
|
+
"Entry",
|
|
73
|
+
"normalize_doi",
|
|
74
|
+
"extract_arxiv_id",
|
|
75
|
+
# name matching
|
|
76
|
+
"PersonName",
|
|
77
|
+
"parse_name",
|
|
78
|
+
"parse_names",
|
|
79
|
+
"fold",
|
|
80
|
+
"compare_names",
|
|
81
|
+
"compare_author_lists",
|
|
82
|
+
"NameComparison",
|
|
83
|
+
"ListComparison",
|
|
84
|
+
# verdicts and tuning
|
|
85
|
+
"Verdict",
|
|
86
|
+
"Thresholds",
|
|
87
|
+
"SEVERITY",
|
|
88
|
+
"DESCRIPTIONS",
|
|
89
|
+
"Config",
|
|
90
|
+
# results
|
|
91
|
+
"EntryResult",
|
|
92
|
+
"Report",
|
|
93
|
+
"YearFinding",
|
|
94
|
+
"SCHEMA_VERSION",
|
|
95
|
+
# output
|
|
96
|
+
"format_text",
|
|
97
|
+
"format_json",
|
|
98
|
+
"format_github_annotations",
|
|
99
|
+
"to_dict",
|
|
100
|
+
# sources
|
|
101
|
+
"Provider",
|
|
102
|
+
"Record",
|
|
103
|
+
"ProviderUnavailable",
|
|
104
|
+
"build_providers",
|
|
105
|
+
"HttpClient",
|
|
106
|
+
"Cache",
|
|
107
|
+
"CacheMiss",
|
|
108
|
+
]
|
bibauthors/cache.py
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
"""On-disk HTTP response cache, and the offline mode that makes CI honest.
|
|
2
|
+
|
|
3
|
+
Every provider response this tool ever sees arrives through :class:`Cache`.
|
|
4
|
+
That has two consequences worth stating up front.
|
|
5
|
+
|
|
6
|
+
First, reproducibility. A bibliography checker that talks to four live APIs
|
|
7
|
+
cannot be tested, because the APIs change under you and because a test suite
|
|
8
|
+
that hammers a free public service is rude. So the library can run with
|
|
9
|
+
``offline=True``, in which case a cache miss is a hard error and nothing is
|
|
10
|
+
fetched. The test suite runs that way against recorded fixtures, which means a
|
|
11
|
+
failing test is a real regression rather than someone else's outage.
|
|
12
|
+
|
|
13
|
+
Second, politeness. OpenAlex serves unauthenticated clients out of a small
|
|
14
|
+
shared daily budget per network address, and Semantic Scholar rate-limits
|
|
15
|
+
hard. Serving a repeated lookup from disk is the difference between a tool
|
|
16
|
+
that can be run in a loop and one that gets blocked.
|
|
17
|
+
|
|
18
|
+
Cache entry files are named by a hash of the request and written with
|
|
19
|
+
``ensure_ascii=True, sort_keys=True, indent=1`` so that they stay pure ASCII
|
|
20
|
+
and produce readable diffs when they are committed as fixtures.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import datetime
|
|
26
|
+
import errno
|
|
27
|
+
import hashlib
|
|
28
|
+
import json
|
|
29
|
+
import os
|
|
30
|
+
from typing import Any, Dict, Optional
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class CacheMiss(Exception):
|
|
34
|
+
"""Raised when a request is not in the cache.
|
|
35
|
+
|
|
36
|
+
In offline mode this propagates out of the HTTP client and the providers
|
|
37
|
+
turn it into :class:`bibauthors.providers.ProviderUnavailable`, which the
|
|
38
|
+
verifier reports as "could not ask" rather than "not found". The
|
|
39
|
+
distinction matters: a missing record is weak evidence about a citation,
|
|
40
|
+
while a missing cache entry is no evidence at all.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
#: Timestamp format used for ``recorded_at``. Second resolution, UTC, with the
|
|
45
|
+
#: literal "Z" suffix, so the value sorts lexicographically and stays ASCII.
|
|
46
|
+
TIMESTAMP_FORMAT = "%Y-%m-%dT%H:%M:%SZ"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def utc_now_stamp() -> str:
|
|
50
|
+
"""Current UTC time as an ISO 8601 second-resolution string."""
|
|
51
|
+
now = datetime.datetime.now(datetime.timezone.utc)
|
|
52
|
+
return now.strftime(TIMESTAMP_FORMAT)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def canonical_request(url: str, params: Optional[Dict[str, Any]]) -> str:
|
|
56
|
+
"""The string that gets hashed into a cache key.
|
|
57
|
+
|
|
58
|
+
``"GET|<url>|<k>=<v>&<k>=<v>"`` with the parameters sorted by name, so the
|
|
59
|
+
key does not depend on dict ordering. Only GET is ever cached, and only
|
|
60
|
+
GET is ever issued: this tool has no code path that writes to any API.
|
|
61
|
+
"""
|
|
62
|
+
pairs = []
|
|
63
|
+
for key in sorted((params or {}).keys()):
|
|
64
|
+
pairs.append("%s=%s" % (key, (params or {})[key]))
|
|
65
|
+
return "GET|%s|%s" % (url, "&".join(pairs))
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class Cache(object):
|
|
69
|
+
"""A flat directory of JSON files, one per request.
|
|
70
|
+
|
|
71
|
+
Parameters
|
|
72
|
+
----------
|
|
73
|
+
directory:
|
|
74
|
+
Where the entry files live. Created on first write unless offline.
|
|
75
|
+
offline:
|
|
76
|
+
When true, :meth:`get` still reads the directory but :meth:`put`
|
|
77
|
+
becomes a no-op and nothing is ever created. A miss raises
|
|
78
|
+
:class:`CacheMiss`.
|
|
79
|
+
ttl_days:
|
|
80
|
+
When None (the default) entries never expire and ``recorded_at`` is
|
|
81
|
+
purely informational. Committed fixtures are meant to be read years
|
|
82
|
+
after they were recorded, so ignoring the timestamp has to be the
|
|
83
|
+
default rather than an option someone remembers to set.
|
|
84
|
+
"""
|
|
85
|
+
|
|
86
|
+
def __init__(
|
|
87
|
+
self,
|
|
88
|
+
directory: str,
|
|
89
|
+
offline: bool = False,
|
|
90
|
+
ttl_days: Optional[int] = None,
|
|
91
|
+
) -> None:
|
|
92
|
+
self.directory = str(directory)
|
|
93
|
+
self.offline = bool(offline)
|
|
94
|
+
self.ttl_days = ttl_days
|
|
95
|
+
|
|
96
|
+
# -- keys and paths ---------------------------------------------------
|
|
97
|
+
|
|
98
|
+
def key(self, url: str, params: Optional[Dict[str, Any]] = None) -> str:
|
|
99
|
+
"""sha256 hex digest of the canonical request string."""
|
|
100
|
+
canonical = canonical_request(url, params)
|
|
101
|
+
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
|
|
102
|
+
|
|
103
|
+
def path_for(self, url: str, params: Optional[Dict[str, Any]] = None) -> str:
|
|
104
|
+
"""Absolute-or-relative path of the entry file for this request."""
|
|
105
|
+
return os.path.join(self.directory, self.key(url, params) + ".json")
|
|
106
|
+
|
|
107
|
+
# -- reading ----------------------------------------------------------
|
|
108
|
+
|
|
109
|
+
def get(
|
|
110
|
+
self, url: str, params: Optional[Dict[str, Any]] = None
|
|
111
|
+
) -> Dict[str, Any]:
|
|
112
|
+
"""Return the stored entry dict, or raise :class:`CacheMiss`.
|
|
113
|
+
|
|
114
|
+
The returned dict has the keys ``url``, ``params``, ``status``,
|
|
115
|
+
``recorded_at``, ``content_type`` and ``body``. A stored non-200
|
|
116
|
+
status is a legitimate cache entry: the 404 that CrossRef returns for
|
|
117
|
+
an arXiv DOI is a fact worth recording, not an error to discard.
|
|
118
|
+
"""
|
|
119
|
+
path = self.path_for(url, params)
|
|
120
|
+
try:
|
|
121
|
+
with open(path, "r", encoding="ascii") as handle:
|
|
122
|
+
entry = json.load(handle)
|
|
123
|
+
except IOError as exc:
|
|
124
|
+
if exc.errno in (errno.ENOENT, errno.EISDIR, errno.ENOTDIR):
|
|
125
|
+
raise CacheMiss("not cached: %s" % url)
|
|
126
|
+
raise
|
|
127
|
+
except ValueError as exc:
|
|
128
|
+
raise CacheMiss("corrupt cache entry for %s: %s" % (url, exc))
|
|
129
|
+
if not isinstance(entry, dict) or "body" not in entry:
|
|
130
|
+
raise CacheMiss("malformed cache entry for %s" % url)
|
|
131
|
+
if self.is_expired(entry):
|
|
132
|
+
raise CacheMiss("cache entry expired for %s" % url)
|
|
133
|
+
return entry
|
|
134
|
+
|
|
135
|
+
def has(self, url: str, params: Optional[Dict[str, Any]] = None) -> bool:
|
|
136
|
+
"""True when :meth:`get` would succeed."""
|
|
137
|
+
try:
|
|
138
|
+
self.get(url, params)
|
|
139
|
+
except CacheMiss:
|
|
140
|
+
return False
|
|
141
|
+
return True
|
|
142
|
+
|
|
143
|
+
def is_expired(self, entry: Dict[str, Any]) -> bool:
|
|
144
|
+
"""TTL check. Always false when ``ttl_days`` is None."""
|
|
145
|
+
if self.ttl_days is None:
|
|
146
|
+
return False
|
|
147
|
+
stamp = entry.get("recorded_at")
|
|
148
|
+
if not stamp:
|
|
149
|
+
# No timestamp and a TTL in force: treat as too old to trust.
|
|
150
|
+
return True
|
|
151
|
+
try:
|
|
152
|
+
recorded = datetime.datetime.strptime(stamp, TIMESTAMP_FORMAT)
|
|
153
|
+
except (TypeError, ValueError):
|
|
154
|
+
return True
|
|
155
|
+
recorded = recorded.replace(tzinfo=datetime.timezone.utc)
|
|
156
|
+
age = datetime.datetime.now(datetime.timezone.utc) - recorded
|
|
157
|
+
return age > datetime.timedelta(days=self.ttl_days)
|
|
158
|
+
|
|
159
|
+
# -- writing ----------------------------------------------------------
|
|
160
|
+
|
|
161
|
+
def put(
|
|
162
|
+
self,
|
|
163
|
+
url: str,
|
|
164
|
+
params: Optional[Dict[str, Any]],
|
|
165
|
+
status: int,
|
|
166
|
+
body: Any,
|
|
167
|
+
content_type: str,
|
|
168
|
+
) -> None:
|
|
169
|
+
"""Store one response. A no-op in offline mode.
|
|
170
|
+
|
|
171
|
+
``body`` is a parsed JSON value for JSON responses and a plain string
|
|
172
|
+
for XML (arXiv). Strings are stored as JSON strings, so
|
|
173
|
+
``ensure_ascii=True`` escapes any non-ASCII character in the XML and
|
|
174
|
+
the file on disk stays pure ASCII.
|
|
175
|
+
"""
|
|
176
|
+
if self.offline:
|
|
177
|
+
return
|
|
178
|
+
entry = {
|
|
179
|
+
"url": url,
|
|
180
|
+
"params": dict(params or {}),
|
|
181
|
+
"status": int(status),
|
|
182
|
+
"recorded_at": utc_now_stamp(),
|
|
183
|
+
"content_type": content_type or "",
|
|
184
|
+
"body": body,
|
|
185
|
+
}
|
|
186
|
+
self.write_entry(self.path_for(url, params), entry)
|
|
187
|
+
|
|
188
|
+
def write_entry(self, path: str, entry: Dict[str, Any]) -> None:
|
|
189
|
+
"""Serialize one entry to ``path`` in the committed fixture format."""
|
|
190
|
+
if self.offline:
|
|
191
|
+
return
|
|
192
|
+
directory = os.path.dirname(path)
|
|
193
|
+
if directory and not os.path.isdir(directory):
|
|
194
|
+
os.makedirs(directory)
|
|
195
|
+
temporary = path + ".tmp"
|
|
196
|
+
with open(temporary, "w", encoding="ascii", newline="\n") as handle:
|
|
197
|
+
json.dump(entry, handle, ensure_ascii=True, sort_keys=True, indent=1)
|
|
198
|
+
handle.write("\n")
|
|
199
|
+
os.replace(temporary, path)
|
bibauthors/cli.py
ADDED
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
"""Command line front end.
|
|
2
|
+
|
|
3
|
+
Exit codes:
|
|
4
|
+
|
|
5
|
+
0 clean: every entry MATCH or MATCH_WITH_VARIANTS
|
|
6
|
+
1 author mismatch found (DISJOINT or PARTIAL_OVERLAP, plus whatever the
|
|
7
|
+
config promotes)
|
|
8
|
+
2 no mismatch, but at least one entry could not be verified
|
|
9
|
+
3 usage or input error
|
|
10
|
+
|
|
11
|
+
Code 1 wins over code 2 when both apply.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import argparse
|
|
17
|
+
import os
|
|
18
|
+
import sys
|
|
19
|
+
from typing import List, Optional, Sequence
|
|
20
|
+
|
|
21
|
+
from . import __version__
|
|
22
|
+
from .config import Config, parse_threshold_overrides
|
|
23
|
+
from .parsers import ParseError, load_bibliography
|
|
24
|
+
from .report import format_github_annotations, format_json, format_text
|
|
25
|
+
from .verdict import Thresholds
|
|
26
|
+
from .verify import Verifier
|
|
27
|
+
|
|
28
|
+
EXIT_CLEAN = 0
|
|
29
|
+
EXIT_MISMATCH = 1
|
|
30
|
+
EXIT_UNVERIFIABLE = 2
|
|
31
|
+
EXIT_USAGE = 3
|
|
32
|
+
|
|
33
|
+
_EPILOG = """\
|
|
34
|
+
exit codes:
|
|
35
|
+
0 clean
|
|
36
|
+
1 author mismatch found
|
|
37
|
+
2 no mismatch, but some entries could not be verified
|
|
38
|
+
3 usage or input error
|
|
39
|
+
|
|
40
|
+
A DISJOINT verdict is a prompt to check the entry by hand. It is not an
|
|
41
|
+
accusation of fabrication.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
46
|
+
parser = argparse.ArgumentParser(
|
|
47
|
+
prog="bibauthors",
|
|
48
|
+
description=(
|
|
49
|
+
"Verify the author list of every bibliography entry against "
|
|
50
|
+
"authoritative metadata."
|
|
51
|
+
),
|
|
52
|
+
epilog=_EPILOG,
|
|
53
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
54
|
+
)
|
|
55
|
+
parser.add_argument(
|
|
56
|
+
"paths",
|
|
57
|
+
nargs="*",
|
|
58
|
+
help="bibliography files (.bib or CSL-JSON). Reads stdin when absent.",
|
|
59
|
+
)
|
|
60
|
+
parser.add_argument("--version", action="version", version="bibauthors " + __version__)
|
|
61
|
+
parser.add_argument(
|
|
62
|
+
"--format",
|
|
63
|
+
dest="fmt",
|
|
64
|
+
choices=("bibtex", "csljson"),
|
|
65
|
+
help="input format (sniffed by default)",
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
out = parser.add_argument_group("output")
|
|
69
|
+
out.add_argument(
|
|
70
|
+
"--json",
|
|
71
|
+
dest="json_path",
|
|
72
|
+
metavar="PATH",
|
|
73
|
+
help="write the machine-readable report here ('-' for stdout)",
|
|
74
|
+
)
|
|
75
|
+
out.add_argument(
|
|
76
|
+
"--quiet", action="store_true", help="suppress the human report"
|
|
77
|
+
)
|
|
78
|
+
out.add_argument(
|
|
79
|
+
"--verbose",
|
|
80
|
+
"-v",
|
|
81
|
+
action="store_true",
|
|
82
|
+
help="show clean entries and every provider note",
|
|
83
|
+
)
|
|
84
|
+
out.add_argument(
|
|
85
|
+
"--github",
|
|
86
|
+
action="store_true",
|
|
87
|
+
help="also emit GitHub Actions annotations on stdout",
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
net = parser.add_argument_group("sources and network")
|
|
91
|
+
net.add_argument(
|
|
92
|
+
"--offline",
|
|
93
|
+
action="store_true",
|
|
94
|
+
help="use only the on-disk cache; never touch the network",
|
|
95
|
+
)
|
|
96
|
+
net.add_argument(
|
|
97
|
+
"--cache-dir", metavar="DIR", help="where the HTTP cache lives"
|
|
98
|
+
)
|
|
99
|
+
net.add_argument(
|
|
100
|
+
"--providers",
|
|
101
|
+
metavar="LIST",
|
|
102
|
+
help="comma-separated subset of: crossref,arxiv,openalex,semanticscholar",
|
|
103
|
+
)
|
|
104
|
+
net.add_argument(
|
|
105
|
+
"--min-interval",
|
|
106
|
+
type=float,
|
|
107
|
+
metavar="SECONDS",
|
|
108
|
+
help="minimum delay between requests to one host (default 1.0)",
|
|
109
|
+
)
|
|
110
|
+
net.add_argument(
|
|
111
|
+
"--timeout", type=float, metavar="SECONDS", help="per-request timeout"
|
|
112
|
+
)
|
|
113
|
+
net.add_argument(
|
|
114
|
+
"--ask-every-provider",
|
|
115
|
+
action="store_true",
|
|
116
|
+
help=(
|
|
117
|
+
"do not stop at the first confident identifier hit. Slower and "
|
|
118
|
+
"spends more of someone else's rate limit; useful for auditing "
|
|
119
|
+
"disagreement between sources."
|
|
120
|
+
),
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
tune = parser.add_argument_group("thresholds and policy")
|
|
124
|
+
tune.add_argument(
|
|
125
|
+
"--config", metavar="PATH", help="TOML config file (default: discovered)"
|
|
126
|
+
)
|
|
127
|
+
tune.add_argument(
|
|
128
|
+
"--set",
|
|
129
|
+
dest="overrides",
|
|
130
|
+
action="append",
|
|
131
|
+
default=[],
|
|
132
|
+
metavar="NAME=VALUE",
|
|
133
|
+
help="override one threshold; repeatable. See --list-thresholds.",
|
|
134
|
+
)
|
|
135
|
+
tune.add_argument(
|
|
136
|
+
"--list-thresholds",
|
|
137
|
+
action="store_true",
|
|
138
|
+
help="print every threshold with its default and exit",
|
|
139
|
+
)
|
|
140
|
+
tune.add_argument(
|
|
141
|
+
"--strict",
|
|
142
|
+
action="store_true",
|
|
143
|
+
help="also fail on ORDER_DIFFERS and TRUNCATED",
|
|
144
|
+
)
|
|
145
|
+
tune.add_argument(
|
|
146
|
+
"--fail-on-unverifiable",
|
|
147
|
+
action="store_true",
|
|
148
|
+
help="treat exit code 2 as a failure (returns 1)",
|
|
149
|
+
)
|
|
150
|
+
tune.add_argument(
|
|
151
|
+
"--no-year-check",
|
|
152
|
+
action="store_true",
|
|
153
|
+
help="skip the date comparison entirely",
|
|
154
|
+
)
|
|
155
|
+
tune.add_argument(
|
|
156
|
+
"--ignore-key",
|
|
157
|
+
dest="ignore_keys",
|
|
158
|
+
action="append",
|
|
159
|
+
default=[],
|
|
160
|
+
metavar="KEY",
|
|
161
|
+
help="skip this citation key; repeatable",
|
|
162
|
+
)
|
|
163
|
+
tune.add_argument(
|
|
164
|
+
"--max-entries", type=int, metavar="N", help="stop after N entries"
|
|
165
|
+
)
|
|
166
|
+
return parser
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _print_thresholds(stream) -> None:
|
|
170
|
+
import dataclasses
|
|
171
|
+
|
|
172
|
+
defaults = Thresholds()
|
|
173
|
+
stream.write("thresholds (use --set NAME=VALUE):\n\n")
|
|
174
|
+
doc = (Thresholds.__doc__ or "").splitlines()
|
|
175
|
+
descriptions = {}
|
|
176
|
+
current = None
|
|
177
|
+
for line in doc:
|
|
178
|
+
stripped = line.strip()
|
|
179
|
+
if not stripped:
|
|
180
|
+
continue
|
|
181
|
+
if not line.startswith(" ") and stripped.replace("_", "").replace(
|
|
182
|
+
" / ", ""
|
|
183
|
+
).isalnum():
|
|
184
|
+
current = stripped
|
|
185
|
+
descriptions[current] = []
|
|
186
|
+
elif current:
|
|
187
|
+
descriptions[current].append(stripped)
|
|
188
|
+
for field in dataclasses.fields(defaults):
|
|
189
|
+
stream.write(" %-28s %s\n" % (field.name, getattr(defaults, field.name)))
|
|
190
|
+
for key, lines in descriptions.items():
|
|
191
|
+
if field.name in [part.strip() for part in key.split("/")]:
|
|
192
|
+
stream.write(" %s\n" % " ".join(lines))
|
|
193
|
+
break
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _read_inputs(paths: Sequence[str], fmt: Optional[str]):
|
|
197
|
+
entries = []
|
|
198
|
+
sources: List[str] = []
|
|
199
|
+
if not paths:
|
|
200
|
+
text = sys.stdin.read()
|
|
201
|
+
if not text.strip():
|
|
202
|
+
raise ParseError("no input: give a file path or pipe a bibliography in")
|
|
203
|
+
entries.extend(load_bibliography(text, fmt=fmt, is_text=True))
|
|
204
|
+
sources.append("<stdin>")
|
|
205
|
+
return entries, sources
|
|
206
|
+
for path in paths:
|
|
207
|
+
if not os.path.exists(path):
|
|
208
|
+
raise ParseError("no such file: %s" % path)
|
|
209
|
+
entries.extend(load_bibliography(path, fmt=fmt))
|
|
210
|
+
sources.append(path)
|
|
211
|
+
return entries, sources
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def main(argv: Optional[Sequence[str]] = None) -> int:
|
|
215
|
+
parser = build_parser()
|
|
216
|
+
args = parser.parse_args(argv)
|
|
217
|
+
|
|
218
|
+
if args.list_thresholds:
|
|
219
|
+
_print_thresholds(sys.stdout)
|
|
220
|
+
return EXIT_CLEAN
|
|
221
|
+
|
|
222
|
+
try:
|
|
223
|
+
base = (
|
|
224
|
+
Config.from_toml(args.config)
|
|
225
|
+
if args.config
|
|
226
|
+
else Config.discover(args.paths[0] if args.paths else ".")
|
|
227
|
+
)
|
|
228
|
+
except (OSError, ValueError) as exc:
|
|
229
|
+
sys.stderr.write("bibauthors: config error: %s\n" % exc)
|
|
230
|
+
return EXIT_USAGE
|
|
231
|
+
|
|
232
|
+
try:
|
|
233
|
+
overrides = parse_threshold_overrides(args.overrides)
|
|
234
|
+
if args.strict:
|
|
235
|
+
overrides.setdefault("promote_order_differs", True)
|
|
236
|
+
overrides.setdefault("promote_truncated", True)
|
|
237
|
+
providers = (
|
|
238
|
+
[p.strip() for p in args.providers.split(",") if p.strip()]
|
|
239
|
+
if args.providers
|
|
240
|
+
else None
|
|
241
|
+
)
|
|
242
|
+
config = base.with_overrides(
|
|
243
|
+
offline=True if args.offline else None,
|
|
244
|
+
cache_dir=args.cache_dir,
|
|
245
|
+
providers=providers,
|
|
246
|
+
min_interval=args.min_interval,
|
|
247
|
+
timeout=args.timeout,
|
|
248
|
+
max_entries=args.max_entries,
|
|
249
|
+
check_years=False if args.no_year_check else None,
|
|
250
|
+
stop_after_confident=False if args.ask_every_provider else None,
|
|
251
|
+
ignore_keys=args.ignore_keys or None,
|
|
252
|
+
fail_on_unverifiable=True if args.fail_on_unverifiable else None,
|
|
253
|
+
**overrides
|
|
254
|
+
)
|
|
255
|
+
except ValueError as exc:
|
|
256
|
+
sys.stderr.write("bibauthors: %s\n" % exc)
|
|
257
|
+
return EXIT_USAGE
|
|
258
|
+
|
|
259
|
+
try:
|
|
260
|
+
entries, sources = _read_inputs(args.paths, args.fmt)
|
|
261
|
+
except ParseError as exc:
|
|
262
|
+
sys.stderr.write("bibauthors: %s\n" % exc)
|
|
263
|
+
return EXIT_USAGE
|
|
264
|
+
except OSError as exc:
|
|
265
|
+
sys.stderr.write("bibauthors: %s\n" % exc)
|
|
266
|
+
return EXIT_USAGE
|
|
267
|
+
|
|
268
|
+
if not entries:
|
|
269
|
+
sys.stderr.write("bibauthors: no entries found in the input\n")
|
|
270
|
+
return EXIT_USAGE
|
|
271
|
+
|
|
272
|
+
verifier = Verifier(config=config)
|
|
273
|
+
report = verifier.verify_entries(entries, sources=sources)
|
|
274
|
+
|
|
275
|
+
if not args.quiet:
|
|
276
|
+
sys.stdout.write(format_text(report, verbose=args.verbose))
|
|
277
|
+
if args.github:
|
|
278
|
+
sys.stdout.write(format_github_annotations(report))
|
|
279
|
+
if args.json_path:
|
|
280
|
+
payload = format_json(report)
|
|
281
|
+
if args.json_path == "-":
|
|
282
|
+
sys.stdout.write(payload)
|
|
283
|
+
else:
|
|
284
|
+
directory = os.path.dirname(os.path.abspath(args.json_path))
|
|
285
|
+
if directory and not os.path.isdir(directory):
|
|
286
|
+
os.makedirs(directory, exist_ok=True)
|
|
287
|
+
with open(args.json_path, "w", encoding="ascii") as handle:
|
|
288
|
+
handle.write(payload)
|
|
289
|
+
|
|
290
|
+
code = report.exit_code()
|
|
291
|
+
if code == EXIT_UNVERIFIABLE and config.fail_on_unverifiable:
|
|
292
|
+
return EXIT_MISMATCH
|
|
293
|
+
return code
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
if __name__ == "__main__": # pragma: no cover
|
|
297
|
+
sys.exit(main())
|