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.
Files changed (67) hide show
  1. attestation/__init__.py +38 -0
  2. attestation/citations.py +534 -0
  3. attestation/claims.py +515 -0
  4. attestation/cli.py +1595 -0
  5. attestation/corpus.py +279 -0
  6. attestation/db.py +949 -0
  7. attestation/embed.py +73 -0
  8. attestation/emit.py +229 -0
  9. attestation/explain.py +239 -0
  10. attestation/features.py +726 -0
  11. attestation/feed_candidates.toml +42 -0
  12. attestation/feeds.py +253 -0
  13. attestation/feeds.toml +51 -0
  14. attestation/implicit.py +105 -0
  15. attestation/ingest.py +438 -0
  16. attestation/install.py +1231 -0
  17. attestation/kg.py +513 -0
  18. attestation/kg_aliases.toml +117 -0
  19. attestation/ledger.py +1330 -0
  20. attestation/ledger_adapters/__init__.py +26 -0
  21. attestation/ledger_adapters/generic.py +1607 -0
  22. attestation/library.py +1149 -0
  23. attestation/library_readers.py +775 -0
  24. attestation/llm.py +203 -0
  25. attestation/manifest.py +181 -0
  26. attestation/mcp/__init__.py +218 -0
  27. attestation/mcp/_shared.py +135 -0
  28. attestation/mcp/_tool.py +299 -0
  29. attestation/mcp/ask.py +516 -0
  30. attestation/mcp/citation.py +353 -0
  31. attestation/mcp/claims_tools.py +108 -0
  32. attestation/mcp/disclosure.py +79 -0
  33. attestation/mcp/feed.py +1043 -0
  34. attestation/mcp/knowledge.py +237 -0
  35. attestation/mcp/personas.py +128 -0
  36. attestation/mcp/provenance.py +635 -0
  37. attestation/mcp/research.py +140 -0
  38. attestation/mcp/routing.py +365 -0
  39. attestation/mcp/routing_research.py +190 -0
  40. attestation/mcp/subscriptions.py +122 -0
  41. attestation/mcp/symbolic.py +180 -0
  42. attestation/mcp_server.py +102 -0
  43. attestation/paths.py +57 -0
  44. attestation/personas.py +286 -0
  45. attestation/ports.py +162 -0
  46. attestation/py.typed +0 -0
  47. attestation/rank.py +1116 -0
  48. attestation/record.py +552 -0
  49. attestation/research.py +719 -0
  50. attestation/server.py +414 -0
  51. attestation/simulate.py +209 -0
  52. attestation/skills/attestation-annotate/SKILL.md +175 -0
  53. attestation/skills/attestation-feed/SKILL.md +293 -0
  54. attestation/skills/attestation-knowledge/SKILL.md +156 -0
  55. attestation/skills/attestation-provenance/SKILL.md +174 -0
  56. attestation/skills/attestation-record/SKILL.md +169 -0
  57. attestation/skills/attestation-setup/SKILL.md +181 -0
  58. attestation/skills/attestation-setup/scripts/setup.sh +63 -0
  59. attestation/skills/attestation-symbolic/SKILL.md +92 -0
  60. attestation/static/htmx.min.js +1 -0
  61. attestation/symbolic.py +295 -0
  62. attestation/symbolic_ops.py +331 -0
  63. attestation-0.2.0.dist-info/METADATA +238 -0
  64. attestation-0.2.0.dist-info/RECORD +67 -0
  65. attestation-0.2.0.dist-info/WHEEL +4 -0
  66. attestation-0.2.0.dist-info/entry_points.txt +4 -0
  67. attestation-0.2.0.dist-info/licenses/LICENSE +21 -0
@@ -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
+ ]
@@ -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]