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 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())