attestation 0.2.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.
- attestation/__init__.py +38 -0
- attestation/citations.py +534 -0
- attestation/claims.py +515 -0
- attestation/cli.py +1595 -0
- attestation/corpus.py +279 -0
- attestation/db.py +949 -0
- attestation/embed.py +73 -0
- attestation/emit.py +229 -0
- attestation/explain.py +239 -0
- attestation/features.py +726 -0
- attestation/feed_candidates.toml +42 -0
- attestation/feeds.py +253 -0
- attestation/feeds.toml +51 -0
- attestation/implicit.py +105 -0
- attestation/ingest.py +438 -0
- attestation/install.py +1231 -0
- attestation/kg.py +513 -0
- attestation/kg_aliases.toml +117 -0
- attestation/ledger.py +1330 -0
- attestation/ledger_adapters/__init__.py +26 -0
- attestation/ledger_adapters/generic.py +1607 -0
- attestation/library.py +1149 -0
- attestation/library_readers.py +775 -0
- attestation/llm.py +203 -0
- attestation/manifest.py +181 -0
- attestation/mcp/__init__.py +218 -0
- attestation/mcp/_shared.py +135 -0
- attestation/mcp/_tool.py +299 -0
- attestation/mcp/ask.py +516 -0
- attestation/mcp/citation.py +353 -0
- attestation/mcp/claims_tools.py +108 -0
- attestation/mcp/disclosure.py +79 -0
- attestation/mcp/feed.py +1043 -0
- attestation/mcp/knowledge.py +237 -0
- attestation/mcp/personas.py +128 -0
- attestation/mcp/provenance.py +635 -0
- attestation/mcp/research.py +140 -0
- attestation/mcp/routing.py +365 -0
- attestation/mcp/routing_research.py +190 -0
- attestation/mcp/subscriptions.py +122 -0
- attestation/mcp/symbolic.py +180 -0
- attestation/mcp_server.py +102 -0
- attestation/paths.py +57 -0
- attestation/personas.py +286 -0
- attestation/ports.py +162 -0
- attestation/py.typed +0 -0
- attestation/rank.py +1116 -0
- attestation/record.py +552 -0
- attestation/research.py +719 -0
- attestation/server.py +414 -0
- attestation/simulate.py +209 -0
- attestation/skills/attestation-annotate/SKILL.md +175 -0
- attestation/skills/attestation-feed/SKILL.md +293 -0
- attestation/skills/attestation-knowledge/SKILL.md +156 -0
- attestation/skills/attestation-provenance/SKILL.md +174 -0
- attestation/skills/attestation-record/SKILL.md +169 -0
- attestation/skills/attestation-setup/SKILL.md +181 -0
- attestation/skills/attestation-setup/scripts/setup.sh +63 -0
- attestation/skills/attestation-symbolic/SKILL.md +92 -0
- attestation/static/htmx.min.js +1 -0
- attestation/symbolic.py +295 -0
- attestation/symbolic_ops.py +331 -0
- attestation-0.2.0.dist-info/METADATA +238 -0
- attestation-0.2.0.dist-info/RECORD +67 -0
- attestation-0.2.0.dist-info/WHEEL +4 -0
- attestation-0.2.0.dist-info/entry_points.txt +4 -0
- attestation-0.2.0.dist-info/licenses/LICENSE +21 -0
attestation/__init__.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Auditable research provenance: experiment runs, verifiable claims, a
|
|
2
|
+
reading graph, and symbolic derivations -- fully local.
|
|
3
|
+
|
|
4
|
+
Nothing is re-exported here: the modules listed in `__all__` are the public
|
|
5
|
+
API, imported directly (`from attestation import ledger`, `import
|
|
6
|
+
attestation.claims`), as decided in
|
|
7
|
+
`docs/superpowers/specs/2026-08-21-onion-refactor-design.md`. This module
|
|
8
|
+
states what those are and nothing more.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
12
|
+
|
|
13
|
+
try:
|
|
14
|
+
__version__ = version("attestation")
|
|
15
|
+
except PackageNotFoundError:
|
|
16
|
+
# Not installed (e.g. src/ on sys.path with no editable install) --
|
|
17
|
+
# `pyproject.toml` stays the one source, but a bare checkout must not
|
|
18
|
+
# raise just because the package metadata was never generated.
|
|
19
|
+
__version__ = "0+unknown"
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"ledger",
|
|
23
|
+
"claims",
|
|
24
|
+
"citations",
|
|
25
|
+
"rank",
|
|
26
|
+
"ingest",
|
|
27
|
+
"features",
|
|
28
|
+
"simulate",
|
|
29
|
+
"explain",
|
|
30
|
+
"symbolic",
|
|
31
|
+
"kg",
|
|
32
|
+
"emit",
|
|
33
|
+
"install",
|
|
34
|
+
"llm",
|
|
35
|
+
"embed",
|
|
36
|
+
"db",
|
|
37
|
+
"ports",
|
|
38
|
+
]
|
attestation/citations.py
ADDED
|
@@ -0,0 +1,534 @@
|
|
|
1
|
+
"""Bibliographic records, read from disk by default.
|
|
2
|
+
|
|
3
|
+
`claims.py` can verify that a number in prose matches a run in the ledger. It
|
|
4
|
+
could not verify that a *citation* in prose points at a real paper, and had no
|
|
5
|
+
way to express "supported by someone else's published result" as distinct from
|
|
6
|
+
"supported by my run". A reference had no representation here at all.
|
|
7
|
+
|
|
8
|
+
**The offline guarantee and its exception.** `CLAUDE.md` states "Local models
|
|
9
|
+
via Ollama; nothing leaves the machine." The `web` reader breaks that, so:
|
|
10
|
+
|
|
11
|
+
1. It is absent unless `ATTEST_CITATION_WEB` is set, checked when the
|
|
12
|
+
resolver is BUILT rather than when it is called -- a disabled reader
|
|
13
|
+
cannot be coaxed into one request by an unusual code path.
|
|
14
|
+
2. Every record carries `source` and `fetched_at`, so any answer can be
|
|
15
|
+
asked where it came from.
|
|
16
|
+
3. `cite.sources` reports which readers can reach the network, from the same
|
|
17
|
+
surface that would have done the reaching.
|
|
18
|
+
|
|
19
|
+
A guarantee with a documented exception is honest. One that quietly stopped
|
|
20
|
+
holding is not.
|
|
21
|
+
|
|
22
|
+
**No Zotero library existed on the machine where this was written.** The reader
|
|
23
|
+
is built from Zotero's documented schema and tested against a fixture built to
|
|
24
|
+
the same document, so it is plausible rather than verified. If you have a real
|
|
25
|
+
library, point this at it -- the shape-tolerance tests say what should happen
|
|
26
|
+
when the layout differs, but only a real one proves what does.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import os
|
|
32
|
+
import re
|
|
33
|
+
import sqlite3
|
|
34
|
+
from collections.abc import Iterator
|
|
35
|
+
from dataclasses import dataclass, field
|
|
36
|
+
from pathlib import Path
|
|
37
|
+
|
|
38
|
+
from attestation import paths
|
|
39
|
+
|
|
40
|
+
DEFAULT_ZOTERO = Path.home() / "Zotero" / "zotero.sqlite"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _on(value: str) -> bool:
|
|
44
|
+
return value.strip() not in ("", "0", "false")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
# The two flags are read with literal variable names, not through a helper
|
|
48
|
+
# taking the name as an argument: tests/test_llm.py derives the set of
|
|
49
|
+
# documented settings from the literals in the source, so an indirection
|
|
50
|
+
# would make a real setting invisible to the guard that keeps .env.sample
|
|
51
|
+
# honest. The names carry no digits for the same reason.
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def web_enabled() -> bool:
|
|
55
|
+
"""ATTEST_CITATION_WEB: CrossRef and the arXiv API.
|
|
56
|
+
|
|
57
|
+
Read by the caller that BUILDS readers, never by a reader at call time --
|
|
58
|
+
a disabled reader cannot be coaxed into one request by an unusual path.
|
|
59
|
+
"""
|
|
60
|
+
return _on(os.environ.get("ATTEST_CITATION_WEB", ""))
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def s2_enabled() -> bool:
|
|
64
|
+
"""ATTEST_CITATION_SCHOLAR: Semantic Scholar reference lists.
|
|
65
|
+
|
|
66
|
+
A second flag because reference lists are a larger, rate-limited surface
|
|
67
|
+
than a metadata lookup, and a reader who accepts one need not accept both.
|
|
68
|
+
"""
|
|
69
|
+
return _on(os.environ.get("ATTEST_CITATION_SCHOLAR", ""))
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def bib_paths_from_env() -> list[Path]:
|
|
73
|
+
"""ATTEST_BIB_PATHS (os.pathsep-separated), else every `*.bib` in the cwd."""
|
|
74
|
+
configured = os.environ.get("ATTEST_BIB_PATHS", "")
|
|
75
|
+
if configured.strip():
|
|
76
|
+
return [Path(p).expanduser() for p in configured.split(os.pathsep) if p.strip()]
|
|
77
|
+
return sorted(Path.cwd().glob("*.bib"))
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def zotero_path_from_env() -> Path:
|
|
81
|
+
"""ATTEST_ZOTERO_PATH, else Zotero's default location."""
|
|
82
|
+
configured = os.environ.get("ATTEST_ZOTERO_PATH", "")
|
|
83
|
+
return Path(configured).expanduser() if configured.strip() else DEFAULT_ZOTERO
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@dataclass(frozen=True)
|
|
87
|
+
class Reference:
|
|
88
|
+
"""One bibliographic record, and where it came from.
|
|
89
|
+
|
|
90
|
+
`source` and `fetched_at` are the provenance pair. A record from disk has
|
|
91
|
+
`fetched_at=None`; one from the network carries a date. A cache keeps the
|
|
92
|
+
original date rather than refreshing it -- the cache must not launder a
|
|
93
|
+
network record into something that looks local.
|
|
94
|
+
"""
|
|
95
|
+
|
|
96
|
+
key: str
|
|
97
|
+
title: str
|
|
98
|
+
authors: list[str] = field(default_factory=list)
|
|
99
|
+
year: int | None = None
|
|
100
|
+
doi: str | None = None
|
|
101
|
+
arxiv_id: str | None = None
|
|
102
|
+
url: str | None = None
|
|
103
|
+
source: str = ""
|
|
104
|
+
fetched_at: str | None = None
|
|
105
|
+
|
|
106
|
+
def matches(self, needle: str) -> bool:
|
|
107
|
+
"""Whether a free-text query plausibly names this record."""
|
|
108
|
+
hay = " ".join([self.key, self.title, *self.authors]).lower()
|
|
109
|
+
return needle.lower() in hay
|
|
110
|
+
|
|
111
|
+
def to_row(self) -> dict:
|
|
112
|
+
"""This reference's `cite.*` wire projection.
|
|
113
|
+
|
|
114
|
+
Truncates `authors` to 6 and adds `n_authors` (the true count) so a
|
|
115
|
+
long author list does not blow out a response -- the same 3-of-n
|
|
116
|
+
budget pattern `RankedItem.to_row` applies to tags.
|
|
117
|
+
|
|
118
|
+
`arxiv_id` is deliberately OMITTED: it is redundant with `doi`/`url`
|
|
119
|
+
for most records, and dropping it here is a stated decision rather
|
|
120
|
+
than a silent gap a future editor might "fix" back in inconsistently.
|
|
121
|
+
"""
|
|
122
|
+
return {
|
|
123
|
+
"key": self.key,
|
|
124
|
+
"title": self.title,
|
|
125
|
+
"authors": self.authors[:6],
|
|
126
|
+
"n_authors": len(self.authors),
|
|
127
|
+
"year": self.year,
|
|
128
|
+
"doi": self.doi,
|
|
129
|
+
"url": self.url,
|
|
130
|
+
# The provenance pair, on every record. This is what makes the
|
|
131
|
+
# offline guarantee's exception inspectable rather than merely
|
|
132
|
+
# documented.
|
|
133
|
+
"source": self.source,
|
|
134
|
+
"fetched_at": self.fetched_at,
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _year(value: str | None) -> int | None:
|
|
139
|
+
"""A four-digit year out of whatever a date field holds.
|
|
140
|
+
|
|
141
|
+
Zotero stores '2017-06-12', '2017', and '2017-06-12 2017' depending on how
|
|
142
|
+
the item was imported.
|
|
143
|
+
"""
|
|
144
|
+
if not value:
|
|
145
|
+
return None
|
|
146
|
+
match = re.search(r"\b(1[89]\d{2}|20\d{2})\b", str(value))
|
|
147
|
+
return int(match.group(1)) if match else None
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
# ---------------------------------------------------------------------------
|
|
151
|
+
# readers
|
|
152
|
+
# ---------------------------------------------------------------------------
|
|
153
|
+
|
|
154
|
+
_ENTRY = re.compile(r"@(\w+)\s*\{\s*([^,\s]+)\s*,(.*?)\n\}", re.DOTALL)
|
|
155
|
+
_ENTRY_BOUNDARY = re.compile(r"\n(?=@\w+\s*\{)")
|
|
156
|
+
_FIELD = re.compile(r"(\w+)\s*=\s*[{\"](.*?)[}\"]\s*,?\s*$", re.MULTILINE | re.DOTALL)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _parse_bib_entries(text: str) -> Iterator[tuple[str, dict]]:
|
|
160
|
+
"""(key, {lowercased field: single-spaced value}) for each entry with a title.
|
|
161
|
+
|
|
162
|
+
The ONE `.bib` parser: `BibtexReader` (the cite.* lookup path) and
|
|
163
|
+
`library_readers.BibtexRecords` (the library sync) both read through it,
|
|
164
|
+
so a grammar fix lands in both.
|
|
165
|
+
"""
|
|
166
|
+
# Split at entry boundaries before matching: `_ENTRY`'s lazy body would
|
|
167
|
+
# otherwise rescan to end-of-file for every entry whose closing brace is
|
|
168
|
+
# not alone on a line -- quadratic, 3.6 s for 3,000 such entries
|
|
169
|
+
# (review round 1). Per chunk, a bad entry costs only its own length.
|
|
170
|
+
for chunk in _ENTRY_BOUNDARY.split(text):
|
|
171
|
+
for _kind, key, body in _ENTRY.findall(chunk):
|
|
172
|
+
fields = {k.lower(): " ".join(v.split()) for k, v in _FIELD.findall(body)}
|
|
173
|
+
if fields.get("title"):
|
|
174
|
+
yield key, fields
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
class BibtexReader:
|
|
178
|
+
"""`.bib` files on disk.
|
|
179
|
+
|
|
180
|
+
A hand-rolled reader rather than a dependency: the grammar used here is
|
|
181
|
+
`@type{key, field = {value},}`, which is what every tool emits, and this
|
|
182
|
+
codebase has a standing preference against pulling a parser in for a
|
|
183
|
+
machine-generated format (see ledger_adapters/generic._config_shape).
|
|
184
|
+
|
|
185
|
+
A truncated entry yields nothing rather than raising: a `.bib` killed
|
|
186
|
+
mid-write is the commonest way one ends, and losing the whole file for one
|
|
187
|
+
bad entry is silent data loss.
|
|
188
|
+
"""
|
|
189
|
+
|
|
190
|
+
name = "bibtex"
|
|
191
|
+
network = False
|
|
192
|
+
|
|
193
|
+
def __init__(self, paths):
|
|
194
|
+
self.paths = [Path(p) for p in paths]
|
|
195
|
+
|
|
196
|
+
def all(self) -> Iterator[Reference]:
|
|
197
|
+
"""Every entry each `.bib` file yields, skipping ones with no title.
|
|
198
|
+
|
|
199
|
+
A missing file is silently skipped rather than an error, matching
|
|
200
|
+
`ZoteroReader`: a `.bib` named in config but not (yet) present is an
|
|
201
|
+
absent source, not a broken one.
|
|
202
|
+
"""
|
|
203
|
+
for path in self.paths:
|
|
204
|
+
if not path.is_file():
|
|
205
|
+
continue
|
|
206
|
+
for key, fields in _parse_bib_entries(path.read_text(errors="replace")):
|
|
207
|
+
authors = [a.strip() for a in fields.get("author", "").split(" and ") if a.strip()]
|
|
208
|
+
yield Reference(
|
|
209
|
+
key=key,
|
|
210
|
+
title=fields["title"],
|
|
211
|
+
authors=authors,
|
|
212
|
+
year=_year(fields.get("year") or fields.get("date")),
|
|
213
|
+
doi=fields.get("doi"),
|
|
214
|
+
url=fields.get("url"),
|
|
215
|
+
source=self.name,
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
def lookup(self, key: str) -> Reference | None:
|
|
219
|
+
"""The entry whose citation key or DOI matches `key`, case-insensitive.
|
|
220
|
+
|
|
221
|
+
Scans `all()` rather than an index: `.bib` files are small and this
|
|
222
|
+
reader has no persistent state to keep one in sync with a file that
|
|
223
|
+
may have changed on disk between calls.
|
|
224
|
+
"""
|
|
225
|
+
needle = key.lower()
|
|
226
|
+
for ref in self.all():
|
|
227
|
+
if ref.key.lower() == needle or (ref.doi or "").lower() == needle:
|
|
228
|
+
return ref
|
|
229
|
+
return None
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
# Zotero's schema is normalised three deep: items -> itemData -> itemDataValues,
|
|
233
|
+
# with `fields` naming the column. Documented at
|
|
234
|
+
# https://www.zotero.org/support/dev/client_coding/direct_sqlite_database_access
|
|
235
|
+
_ZOTERO_SQL = """
|
|
236
|
+
SELECT i.key AS key,
|
|
237
|
+
f.fieldName AS field,
|
|
238
|
+
v.value AS value
|
|
239
|
+
FROM items i
|
|
240
|
+
JOIN itemData d ON d.itemID = i.itemID
|
|
241
|
+
JOIN fields f ON f.fieldID = d.fieldID
|
|
242
|
+
JOIN itemDataValues v ON v.valueID = d.valueID
|
|
243
|
+
WHERE i.itemID NOT IN (SELECT itemID FROM deletedItems)
|
|
244
|
+
"""
|
|
245
|
+
|
|
246
|
+
_ZOTERO_CREATORS = """
|
|
247
|
+
SELECT i.key AS key, c.lastName AS last, c.firstName AS first
|
|
248
|
+
FROM items i
|
|
249
|
+
JOIN itemCreators ic ON ic.itemID = i.itemID
|
|
250
|
+
JOIN creators c ON c.creatorID = ic.creatorID
|
|
251
|
+
WHERE i.itemID NOT IN (SELECT itemID FROM deletedItems)
|
|
252
|
+
ORDER BY ic.orderIndex
|
|
253
|
+
"""
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
class ZoteroReader:
|
|
257
|
+
"""A local Zotero library, opened read-only.
|
|
258
|
+
|
|
259
|
+
Zotero holds an exclusive lock while running, so this opens with
|
|
260
|
+
`mode=ro&immutable=1`: it reads a live library without being able to write
|
|
261
|
+
to it. If that fails -- no library, a corrupt file, a schema this does not
|
|
262
|
+
recognise -- the reader returns no records rather than raising. A missing
|
|
263
|
+
Zotero is an absent source, not an error, and a resolver whose other
|
|
264
|
+
readers work must keep working.
|
|
265
|
+
"""
|
|
266
|
+
|
|
267
|
+
name = "zotero"
|
|
268
|
+
network = False
|
|
269
|
+
|
|
270
|
+
def __init__(self, path: Path | None = None):
|
|
271
|
+
self.path = Path(path) if path else DEFAULT_ZOTERO
|
|
272
|
+
|
|
273
|
+
def _connect(self) -> sqlite3.Connection:
|
|
274
|
+
conn = sqlite3.connect(f"file:{self.path}?mode=ro&immutable=1", uri=True)
|
|
275
|
+
conn.row_factory = sqlite3.Row
|
|
276
|
+
return conn
|
|
277
|
+
|
|
278
|
+
def all(self) -> Iterator[Reference]:
|
|
279
|
+
"""Every item in the library with a title, read-only and tolerant.
|
|
280
|
+
|
|
281
|
+
No library, a corrupt file, or a schema this does not recognise all
|
|
282
|
+
yield nothing rather than raising -- see the class docstring. This
|
|
283
|
+
reader ships tested only against a fixture built to Zotero's
|
|
284
|
+
documented schema; there was no real library on the machine where it
|
|
285
|
+
was written, so the fixture is plausible, not verified. If you have a
|
|
286
|
+
real one, point this at it.
|
|
287
|
+
"""
|
|
288
|
+
for key, data, authors in self.raw_items():
|
|
289
|
+
yield Reference(
|
|
290
|
+
key=key,
|
|
291
|
+
title=data["title"],
|
|
292
|
+
authors=authors,
|
|
293
|
+
year=_year(data.get("date")),
|
|
294
|
+
doi=data.get("DOI"),
|
|
295
|
+
url=data.get("url"),
|
|
296
|
+
source=self.name,
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
def raw_items(self) -> Iterator[tuple[str, dict, list[str]]]:
|
|
300
|
+
"""(zotero key, {field: value}, [authors]) for every titled, undeleted item.
|
|
301
|
+
|
|
302
|
+
The library sync reads this rather than `all()` because it wants
|
|
303
|
+
fields `Reference` does not carry (abstractNote, publicationTitle,
|
|
304
|
+
extra). Same tolerance as `all()`: nothing on any sqlite error.
|
|
305
|
+
"""
|
|
306
|
+
if not self.path.is_file():
|
|
307
|
+
return
|
|
308
|
+
try:
|
|
309
|
+
conn = self._connect()
|
|
310
|
+
rows = conn.execute(_ZOTERO_SQL).fetchall()
|
|
311
|
+
creators = conn.execute(_ZOTERO_CREATORS).fetchall()
|
|
312
|
+
conn.close()
|
|
313
|
+
except sqlite3.Error:
|
|
314
|
+
# Narrow on purpose: a locked, corrupt or unrecognised library is
|
|
315
|
+
# an absent source. Anything else here is a real bug and should
|
|
316
|
+
# surface rather than be swallowed as "no citations".
|
|
317
|
+
return
|
|
318
|
+
|
|
319
|
+
by_key: dict[str, dict] = {}
|
|
320
|
+
for row in rows:
|
|
321
|
+
by_key.setdefault(row["key"], {})[row["field"]] = row["value"]
|
|
322
|
+
authors: dict[str, list[str]] = {}
|
|
323
|
+
for row in creators:
|
|
324
|
+
name = ", ".join(p for p in (row["last"], row["first"]) if p)
|
|
325
|
+
authors.setdefault(row["key"], []).append(name)
|
|
326
|
+
|
|
327
|
+
for key, data in by_key.items():
|
|
328
|
+
if data.get("title"):
|
|
329
|
+
yield key, data, authors.get(key, [])
|
|
330
|
+
|
|
331
|
+
def lookup(self, key: str) -> Reference | None:
|
|
332
|
+
"""The item whose Zotero key or DOI matches `key`, case-insensitive.
|
|
333
|
+
|
|
334
|
+
Scans `all()` -- the library is opened fresh each call (see
|
|
335
|
+
`_connect`), so there is no cached index to keep in sync with a
|
|
336
|
+
library that changed since the last lookup.
|
|
337
|
+
"""
|
|
338
|
+
needle = key.lower()
|
|
339
|
+
for ref in self.all():
|
|
340
|
+
if ref.key.lower() == needle or (ref.doi or "").lower() == needle:
|
|
341
|
+
return ref
|
|
342
|
+
return None
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def _chmod(path: Path, mode: int) -> None:
|
|
346
|
+
"""Best-effort chmod. A filesystem that cannot express POSIX modes
|
|
347
|
+
(Windows, most network mounts) loses the hardening, not the data."""
|
|
348
|
+
try:
|
|
349
|
+
path.chmod(mode)
|
|
350
|
+
except OSError:
|
|
351
|
+
pass
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def _write_cached(cache_dir: Path, cached: Path, ref) -> None:
|
|
355
|
+
"""Write one cached record, 0700 dir / 0600 file.
|
|
356
|
+
|
|
357
|
+
What is cached here is not the paper -- it is the record that this machine
|
|
358
|
+
looked this key up, and the date it did. A directory listing of it is a
|
|
359
|
+
reading trail, so the default 0755/0644 publishes to every local account
|
|
360
|
+
something the researcher never chose to share.
|
|
361
|
+
|
|
362
|
+
Applied at CREATION only: `mode=` on mkdir is ignored for a directory that
|
|
363
|
+
already exists, so a cache someone deliberately opened up stays open.
|
|
364
|
+
"""
|
|
365
|
+
import json
|
|
366
|
+
|
|
367
|
+
existed = cache_dir.is_dir()
|
|
368
|
+
cache_dir.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
369
|
+
if not existed:
|
|
370
|
+
_chmod(cache_dir, 0o700)
|
|
371
|
+
cached.write_text(json.dumps(ref.__dict__))
|
|
372
|
+
_chmod(cached, 0o600)
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
class WebReader:
|
|
376
|
+
"""Metadata by DOI or arXiv id, from CrossRef and arXiv.
|
|
377
|
+
|
|
378
|
+
**One of the four things in the project that leave the machine** (the
|
|
379
|
+
others are library_readers' three enrichers, behind the same flag and
|
|
380
|
+
`ATTEST_CITATION_SCHOLAR`), and it exists only when `ATTEST_CITATION_WEB`
|
|
381
|
+
was set at construction. Records are
|
|
382
|
+
cached content-addressed and never expire: a published paper's metadata
|
|
383
|
+
does not change, and an expiring cache turns one network call into a
|
|
384
|
+
recurring one, which is the opposite of the guarantee.
|
|
385
|
+
"""
|
|
386
|
+
|
|
387
|
+
name = "web"
|
|
388
|
+
network = True
|
|
389
|
+
|
|
390
|
+
def __init__(self, cache_dir: Path | None = None):
|
|
391
|
+
self.cache_dir = cache_dir or paths.citation_cache()
|
|
392
|
+
|
|
393
|
+
def all(self) -> Iterator[Reference]:
|
|
394
|
+
"""Not supported: a network source has no fixed set to enumerate.
|
|
395
|
+
|
|
396
|
+
`Resolver.search` relies on this raising -- it skips every reader
|
|
397
|
+
with `network=True` before calling `all()`, so a search that fanned
|
|
398
|
+
out to CrossRef here would be the offline guarantee failing quietly.
|
|
399
|
+
This exists so that bypassing that guard is loud rather than a silent
|
|
400
|
+
empty result.
|
|
401
|
+
"""
|
|
402
|
+
raise NotImplementedError("a network source cannot be enumerated")
|
|
403
|
+
|
|
404
|
+
def lookup(self, key: str) -> Reference | None:
|
|
405
|
+
"""DOI or arXiv id metadata from CrossRef, cached content-addressed.
|
|
406
|
+
|
|
407
|
+
A cache hit returns the ORIGINAL `fetched_at`, never today's date --
|
|
408
|
+
the cache must not launder a network record into one that looks
|
|
409
|
+
local. Any failure (unreachable network, a 404, a changed payload
|
|
410
|
+
shape) returns `None` rather than raising: an unreachable network is
|
|
411
|
+
an absent source, exactly like a missing Zotero library, and should
|
|
412
|
+
not break a lookup whose other readers can still answer.
|
|
413
|
+
"""
|
|
414
|
+
import json
|
|
415
|
+
from datetime import UTC, datetime
|
|
416
|
+
|
|
417
|
+
safe = re.sub(r"[^A-Za-z0-9._-]", "_", key)
|
|
418
|
+
cached = self.cache_dir / f"{safe}.json"
|
|
419
|
+
if cached.is_file():
|
|
420
|
+
# Keep the ORIGINAL fetched_at: the cache must not launder a
|
|
421
|
+
# network record into one that looks local.
|
|
422
|
+
return Reference(**json.loads(cached.read_text()))
|
|
423
|
+
|
|
424
|
+
import httpx
|
|
425
|
+
|
|
426
|
+
try:
|
|
427
|
+
resp = httpx.get(
|
|
428
|
+
f"https://api.crossref.org/works/{key}",
|
|
429
|
+
timeout=10.0,
|
|
430
|
+
headers={"User-Agent": "attestation/citations"},
|
|
431
|
+
)
|
|
432
|
+
resp.raise_for_status()
|
|
433
|
+
msg = resp.json()["message"]
|
|
434
|
+
except Exception: # noqa: BLE001 -- an unreachable network is an absent
|
|
435
|
+
# source, exactly like an absent Zotero. Every failure mode here
|
|
436
|
+
# (DNS, timeout, 404, a changed payload shape) means the same thing
|
|
437
|
+
# to the caller, and none of them should break a lookup whose other
|
|
438
|
+
# readers can answer.
|
|
439
|
+
return None
|
|
440
|
+
|
|
441
|
+
authors = [
|
|
442
|
+
", ".join(p for p in (a.get("family"), a.get("given")) if p)
|
|
443
|
+
for a in msg.get("author", [])
|
|
444
|
+
]
|
|
445
|
+
ref = Reference(
|
|
446
|
+
key=key,
|
|
447
|
+
title=" ".join(msg.get("title") or ["(untitled)"]),
|
|
448
|
+
authors=authors,
|
|
449
|
+
year=_year(str((msg.get("issued", {}).get("date-parts") or [[None]])[0][0])),
|
|
450
|
+
doi=msg.get("DOI"),
|
|
451
|
+
url=msg.get("URL"),
|
|
452
|
+
source=self.name,
|
|
453
|
+
fetched_at=datetime.now(UTC).date().isoformat(),
|
|
454
|
+
)
|
|
455
|
+
_write_cached(self.cache_dir, cached, ref)
|
|
456
|
+
return ref
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
# ---------------------------------------------------------------------------
|
|
460
|
+
# resolver
|
|
461
|
+
# ---------------------------------------------------------------------------
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
class Resolver:
|
|
465
|
+
"""The configured readers, asked in order, recording which one answered."""
|
|
466
|
+
|
|
467
|
+
def __init__(self, readers, store=None):
|
|
468
|
+
self.readers = list(readers)
|
|
469
|
+
# A zero-arg callable returning an open connection to the reference
|
|
470
|
+
# library, consulted before any reader; None means readers only.
|
|
471
|
+
self.store = store
|
|
472
|
+
|
|
473
|
+
@classmethod
|
|
474
|
+
def from_env(cls, *, zotero_path=None, bib_paths=None, cache_dir=None, store=None) -> Resolver:
|
|
475
|
+
"""Build from the environment.
|
|
476
|
+
|
|
477
|
+
`ATTEST_CITATION_WEB` is read HERE (via `web_enabled`) and by the
|
|
478
|
+
library sync's reader list, both at construction. Reading it at call
|
|
479
|
+
time would mean a resolver built while disabled could still make a
|
|
480
|
+
request if the variable changed under it.
|
|
481
|
+
"""
|
|
482
|
+
zotero_path = zotero_path or zotero_path_from_env()
|
|
483
|
+
readers: list = [ZoteroReader(zotero_path)] if zotero_path.is_file() else []
|
|
484
|
+
paths = list(bib_paths) if bib_paths else bib_paths_from_env()
|
|
485
|
+
if paths:
|
|
486
|
+
readers.append(BibtexReader(paths))
|
|
487
|
+
if web_enabled():
|
|
488
|
+
readers.append(WebReader(cache_dir))
|
|
489
|
+
return cls(readers, store=store)
|
|
490
|
+
|
|
491
|
+
def lookup(self, key: str) -> Reference | None:
|
|
492
|
+
"""The library store's answer, else the first reader's, tried in order.
|
|
493
|
+
|
|
494
|
+
Order is the constructor's reader list, which `from_env` fixes as
|
|
495
|
+
zotero, then bibtex, then web -- so a network lookup is only ever
|
|
496
|
+
tried after every local, offline reader has already said no.
|
|
497
|
+
"""
|
|
498
|
+
if self.store is not None:
|
|
499
|
+
from attestation import library
|
|
500
|
+
|
|
501
|
+
conn = self.store()
|
|
502
|
+
row = library.lookup_row(conn, key)
|
|
503
|
+
if row is not None:
|
|
504
|
+
return library.to_reference(conn, row)
|
|
505
|
+
for reader in self.readers:
|
|
506
|
+
found = reader.lookup(key)
|
|
507
|
+
if found is not None:
|
|
508
|
+
return found
|
|
509
|
+
return None
|
|
510
|
+
|
|
511
|
+
def search(self, needle: str) -> list[Reference]:
|
|
512
|
+
"""Free-text search across the readers that can be enumerated.
|
|
513
|
+
|
|
514
|
+
Network readers are skipped rather than queried: `all()` raises on
|
|
515
|
+
them, and a search that silently fanned out to CrossRef would be the
|
|
516
|
+
offline guarantee failing quietly.
|
|
517
|
+
"""
|
|
518
|
+
out: list[Reference] = []
|
|
519
|
+
seen: set[str] = set()
|
|
520
|
+
for reader in self.readers:
|
|
521
|
+
if reader.network:
|
|
522
|
+
continue
|
|
523
|
+
for ref in reader.all():
|
|
524
|
+
if ref.matches(needle) and ref.key not in seen:
|
|
525
|
+
seen.add(ref.key)
|
|
526
|
+
out.append(ref)
|
|
527
|
+
return out
|
|
528
|
+
|
|
529
|
+
def sources(self) -> list[dict]:
|
|
530
|
+
"""Which readers are configured, and which of them can reach the
|
|
531
|
+
network -- so `cite.sources` reports the offline exception from the
|
|
532
|
+
same surface that would have done the reaching, rather than a
|
|
533
|
+
separate claim about it."""
|
|
534
|
+
return [{"name": r.name, "network": r.network} for r in self.readers]
|