primarysource 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.
Files changed (303) hide show
  1. primarysource/__init__.py +5 -0
  2. primarysource/bbf/__init__.py +56 -0
  3. primarysource/bbf/_xml.py +63 -0
  4. primarysource/bbf/catalogue.json +777 -0
  5. primarysource/bbf/catalogue.py +137 -0
  6. primarysource/bbf/cli.py +79 -0
  7. primarysource/bbf/datamodel.py +281 -0
  8. primarysource/bbf/importer.py +151 -0
  9. primarysource/bbf/library.py +380 -0
  10. primarysource/bbf/server.py +72 -0
  11. primarysource/budget.py +63 -0
  12. primarysource/c2sp/__init__.py +53 -0
  13. primarysource/c2sp/_fetch.py +5 -0
  14. primarysource/c2sp/catalogue.json +231 -0
  15. primarysource/c2sp/catalogue.py +95 -0
  16. primarysource/c2sp/cli.py +62 -0
  17. primarysource/c2sp/importer.py +90 -0
  18. primarysource/c2sp/library.py +309 -0
  19. primarysource/c2sp/markdown.py +31 -0
  20. primarysource/c2sp/server.py +68 -0
  21. primarysource/cavp/__init__.py +42 -0
  22. primarysource/cavp/cli.py +84 -0
  23. primarysource/cavp/data/vectors.toml +47 -0
  24. primarysource/cavp/importer.py +156 -0
  25. primarysource/cavp/model.py +279 -0
  26. primarysource/cavp/parse.py +269 -0
  27. primarysource/cavp/paths.py +17 -0
  28. primarysource/cavp/server.py +67 -0
  29. primarysource/cavp/tools.py +169 -0
  30. primarysource/cli.py +107 -0
  31. primarysource/cursor.py +82 -0
  32. primarysource/cve/__init__.py +5 -0
  33. primarysource/cve/__main__.py +6 -0
  34. primarysource/cve/cli.py +144 -0
  35. primarysource/cve/collection.py +20 -0
  36. primarysource/cve/config.py +58 -0
  37. primarysource/cve/errors.py +16 -0
  38. primarysource/cve/kernel.py +232 -0
  39. primarysource/cve/mcp.py +139 -0
  40. primarysource/cve/parse.py +318 -0
  41. primarysource/cve/py.typed +0 -0
  42. primarysource/cve/service.py +760 -0
  43. primarysource/cve/store.py +214 -0
  44. primarysource/cve/sync.py +438 -0
  45. primarysource/cve/views.py +304 -0
  46. primarysource/debpolicy/__init__.py +16 -0
  47. primarysource/debpolicy/catalogue.py +263 -0
  48. primarysource/debpolicy/cli.py +12 -0
  49. primarysource/debpolicy/server.py +11 -0
  50. primarysource/draft/__init__.py +11 -0
  51. primarysource/draft/catalogue.py +117 -0
  52. primarysource/draft/cli.py +149 -0
  53. primarysource/draft/collection.py +21 -0
  54. primarysource/draft/datatracker.py +158 -0
  55. primarysource/draft/importer.py +163 -0
  56. primarysource/draft/licence.py +89 -0
  57. primarysource/draft/names.py +64 -0
  58. primarysource/draft/net.py +133 -0
  59. primarysource/draft/segment.py +192 -0
  60. primarysource/draft/server.py +91 -0
  61. primarysource/draft/status.py +146 -0
  62. primarysource/draft/store.py +199 -0
  63. primarysource/draft/tracked.json +104 -0
  64. primarysource/draft/views.py +430 -0
  65. primarysource/draft/xmlmeta.py +74 -0
  66. primarysource/ecma/__init__.py +52 -0
  67. primarysource/ecma/_pdf.py +143 -0
  68. primarysource/ecma/catalogue.json +22 -0
  69. primarysource/ecma/catalogue.py +100 -0
  70. primarysource/ecma/cli.py +67 -0
  71. primarysource/ecma/identify.py +25 -0
  72. primarysource/ecma/importer.py +108 -0
  73. primarysource/ecma/library.py +240 -0
  74. primarysource/ecma/server.py +67 -0
  75. primarysource/errors.py +29 -0
  76. primarysource/fetch.py +360 -0
  77. primarysource/fsutil.py +150 -0
  78. primarysource/gitdocs/__init__.py +15 -0
  79. primarysource/gitdocs/catalogue.py +116 -0
  80. primarysource/gitdocs/cli.py +12 -0
  81. primarysource/gitdocs/server.py +8 -0
  82. primarysource/home.py +65 -0
  83. primarysource/iana/__init__.py +32 -0
  84. primarysource/iana/catalogue.json +244 -0
  85. primarysource/iana/catalogue.py +69 -0
  86. primarysource/iana/cli.py +69 -0
  87. primarysource/iana/importer.py +157 -0
  88. primarysource/iana/model.py +325 -0
  89. primarysource/iana/reader.py +315 -0
  90. primarysource/iana/server.py +67 -0
  91. primarysource/iana/store.py +83 -0
  92. primarysource/iana/xmlsafe.py +47 -0
  93. primarysource/identify.py +186 -0
  94. primarysource/ieee/__init__.py +58 -0
  95. primarysource/ieee/_html.py +49 -0
  96. primarysource/ieee/catalogue.json +74 -0
  97. primarysource/ieee/catalogue.py +123 -0
  98. primarysource/ieee/cli.py +79 -0
  99. primarysource/ieee/identify.py +24 -0
  100. primarysource/ieee/ieee802.py +47 -0
  101. primarysource/ieee/importer.py +305 -0
  102. primarysource/ieee/posix.py +233 -0
  103. primarysource/ieee/server.py +79 -0
  104. primarysource/index.py +446 -0
  105. primarysource/iso/__init__.py +51 -0
  106. primarysource/iso/cli.py +98 -0
  107. primarysource/iso/designation.py +95 -0
  108. primarysource/iso/identify.py +100 -0
  109. primarysource/iso/importer.py +224 -0
  110. primarysource/iso/library.py +249 -0
  111. primarysource/iso/server.py +69 -0
  112. primarysource/itu/__init__.py +41 -0
  113. primarysource/itu/__main__.py +8 -0
  114. primarysource/itu/asn1mod.py +478 -0
  115. primarysource/itu/catalog.py +472 -0
  116. primarysource/itu/cli.py +445 -0
  117. primarysource/itu/collection.py +20 -0
  118. primarysource/itu/corpus.py +118 -0
  119. primarysource/itu/errata.py +16 -0
  120. primarysource/itu/formal.py +565 -0
  121. primarysource/itu/identify.py +26 -0
  122. primarysource/itu/index.py +490 -0
  123. primarysource/itu/mcp.py +474 -0
  124. primarysource/itu/py.typed +0 -0
  125. primarysource/itu/sources.py +600 -0
  126. primarysource/itu/text.py +609 -0
  127. primarysource/kconfig/__init__.py +16 -0
  128. primarysource/kconfig/catalogue.py +98 -0
  129. primarysource/kconfig/cli.py +12 -0
  130. primarysource/kconfig/server.py +8 -0
  131. primarysource/model.py +63 -0
  132. primarysource/nist/__init__.py +5 -0
  133. primarysource/nist/collection.py +46 -0
  134. primarysource/nist/data/acquired-nist.json +1657 -0
  135. primarysource/nist/data/nist-catalog.json +12090 -0
  136. primarysource/nist/data/sources.toml +1996 -0
  137. primarysource/nist/identify.py +41 -0
  138. primarysource/nist/ingest/__init__.py +1 -0
  139. primarysource/nist/ingest/acquire_nist.py +128 -0
  140. primarysource/nist/ingest/controls.py +82 -0
  141. primarysource/nist/ingest/discover_nist.py +218 -0
  142. primarysource/nist/ingest/extract.py +48 -0
  143. primarysource/nist/ingest/fetch.py +211 -0
  144. primarysource/nist/ingest/index.py +14 -0
  145. primarysource/nist/ingest/oscal.py +480 -0
  146. primarysource/nist/ingest/prose_layouts.py +236 -0
  147. primarysource/nist/ingest/sources.py +94 -0
  148. primarysource/nist/ingest/status.py +255 -0
  149. primarysource/nist/models.py +7 -0
  150. primarysource/nist/paths.py +33 -0
  151. primarysource/nist/server/__init__.py +3 -0
  152. primarysource/nist/server/corpus.py +38 -0
  153. primarysource/nist/server/mcp.py +19 -0
  154. primarysource/nist/server/tools.py +35 -0
  155. primarysource/nist/spec.py +38 -0
  156. primarysource/pdfcorpus/__init__.py +16 -0
  157. primarysource/pdfcorpus/corpus.py +159 -0
  158. primarysource/pdfcorpus/fts.py +175 -0
  159. primarysource/pdfcorpus/index.py +70 -0
  160. primarysource/pdfcorpus/models.py +30 -0
  161. primarysource/pdfcorpus/server.py +96 -0
  162. primarysource/pdfcorpus/spec.py +37 -0
  163. primarysource/pdfcorpus/split.py +391 -0
  164. primarysource/pdfcorpus/tools.py +244 -0
  165. primarysource/pep/__init__.py +15 -0
  166. primarysource/pep/catalogue.py +191 -0
  167. primarysource/pep/cli.py +12 -0
  168. primarysource/pep/server.py +8 -0
  169. primarysource/pkgdocs/__init__.py +19 -0
  170. primarysource/pkgdocs/fetch.py +356 -0
  171. primarysource/pkgdocs/index.py +89 -0
  172. primarysource/pkgdocs/model.py +76 -0
  173. primarysource/pkgdocs/readers/__init__.py +1 -0
  174. primarysource/pkgdocs/readers/_common.py +134 -0
  175. primarysource/pkgdocs/readers/asciidoc.py +160 -0
  176. primarysource/pkgdocs/readers/latex.py +315 -0
  177. primarysource/pkgdocs/readers/markdown.py +173 -0
  178. primarysource/pkgdocs/readers/pod.py +132 -0
  179. primarysource/pkgdocs/readers/rst.py +239 -0
  180. primarysource/pkgdocs/readers/scdoc.py +108 -0
  181. primarysource/pkgdocs/runtime.py +552 -0
  182. primarysource/pkgdocs/serving.py +158 -0
  183. primarysource/pkgdocs/store.py +167 -0
  184. primarysource/pms/__init__.py +16 -0
  185. primarysource/pms/catalogue.py +88 -0
  186. primarysource/pms/cli.py +12 -0
  187. primarysource/pms/server.py +8 -0
  188. primarysource/py.typed +0 -0
  189. primarysource/readers/__init__.py +10 -0
  190. primarysource/readers/bookmarks.py +192 -0
  191. primarysource/readers/html.py +1312 -0
  192. primarysource/readers/markdown.py +189 -0
  193. primarysource/readers/pdf.py +187 -0
  194. primarysource/readers/xml.py +230 -0
  195. primarysource/refs.py +87 -0
  196. primarysource/registry.py +161 -0
  197. primarysource/render.py +49 -0
  198. primarysource/rfc/__init__.py +3 -0
  199. primarysource/rfc/__main__.py +6 -0
  200. primarysource/rfc/cite.py +204 -0
  201. primarysource/rfc/cli.py +903 -0
  202. primarysource/rfc/collection.py +19 -0
  203. primarysource/rfc/config.py +138 -0
  204. primarysource/rfc/errata.py +203 -0
  205. primarysource/rfc/fsutil.py +82 -0
  206. primarysource/rfc/graph.py +213 -0
  207. primarysource/rfc/index/__init__.py +1 -0
  208. primarysource/rfc/index/chunker.py +281 -0
  209. primarysource/rfc/index/embed.py +166 -0
  210. primarysource/rfc/index/hybrid.py +305 -0
  211. primarysource/rfc/index/lexical.py +223 -0
  212. primarysource/rfc/index/rfcxml.py +673 -0
  213. primarysource/rfc/index/search.py +508 -0
  214. primarysource/rfc/index/txtseg.py +585 -0
  215. primarysource/rfc/library.py +294 -0
  216. primarysource/rfc/mcp/__init__.py +1 -0
  217. primarysource/rfc/mcp/server.py +381 -0
  218. primarysource/rfc/packs/__init__.py +1 -0
  219. primarysource/rfc/packs/builder.py +228 -0
  220. primarysource/rfc/packs/collections/all-current-txt.json +10 -0
  221. primarysource/rfc/packs/collections/essentials.json +19 -0
  222. primarysource/rfc/packs/collections/fyi-current.json +10 -0
  223. primarysource/rfc/packs/collections/standards-current.json +10 -0
  224. primarysource/rfc/packs/collections/year-2025.json +10 -0
  225. primarysource/rfc/packs/collections/year-2026.json +10 -0
  226. primarysource/rfc/packs/collections.py +163 -0
  227. primarysource/rfc/packs/docsetexport.py +163 -0
  228. primarysource/rfc/packs/epub.py +159 -0
  229. primarysource/rfc/packs/export.py +306 -0
  230. primarysource/rfc/packs/htmlexport.py +82 -0
  231. primarysource/rfc/packs/serve.py +143 -0
  232. primarysource/rfc/packs/zimexport.py +171 -0
  233. primarysource/rfc/query.py +466 -0
  234. primarysource/rfc/subjects.py +457 -0
  235. primarysource/rfc/sync/__init__.py +1 -0
  236. primarysource/rfc/sync/downloader.py +132 -0
  237. primarysource/rfc/sync/manifest.py +162 -0
  238. primarysource/rfc/sync/scanner.py +49 -0
  239. primarysource/rfc/sync/state.py +464 -0
  240. primarysource/rfc/views.py +1067 -0
  241. primarysource/rpmdocs/__init__.py +15 -0
  242. primarysource/rpmdocs/catalogue.py +117 -0
  243. primarysource/rpmdocs/cli.py +12 -0
  244. primarysource/rpmdocs/server.py +8 -0
  245. primarysource/scaffold.py +223 -0
  246. primarysource/serve.py +75 -0
  247. primarysource/settings.py +152 -0
  248. primarysource/usb/__init__.py +5 -0
  249. primarysource/usb/collection.py +37 -0
  250. primarysource/usb/data/acquired.json +2025 -0
  251. primarysource/usb/data/sources-usb.toml +852 -0
  252. primarysource/usb/data/sources.toml +10 -0
  253. primarysource/usb/data/usb-catalog.json +1663 -0
  254. primarysource/usb/data/usb-classification.json +1986 -0
  255. primarysource/usb/data/usb-corpus-build.json +608 -0
  256. primarysource/usb/data/usb-pdf-choice.json +839 -0
  257. primarysource/usb/data/usb-split-measure.json +1803 -0
  258. primarysource/usb/ingest/__init__.py +1 -0
  259. primarysource/usb/ingest/acquire.py +130 -0
  260. primarysource/usb/ingest/discover_usb.py +88 -0
  261. primarysource/usb/ingest/extract.py +48 -0
  262. primarysource/usb/ingest/fetch.py +197 -0
  263. primarysource/usb/ingest/index.py +14 -0
  264. primarysource/usb/ingest/sources.py +85 -0
  265. primarysource/usb/models.py +7 -0
  266. primarysource/usb/paths.py +33 -0
  267. primarysource/usb/server/__init__.py +3 -0
  268. primarysource/usb/server/corpus.py +38 -0
  269. primarysource/usb/server/mcp.py +19 -0
  270. primarysource/usb/server/tools.py +35 -0
  271. primarysource/usb/spec.py +20 -0
  272. primarysource/usbids/__init__.py +56 -0
  273. primarysource/usbids/cli.py +65 -0
  274. primarysource/usbids/importer.py +272 -0
  275. primarysource/usbids/parse.py +283 -0
  276. primarysource/usbids/server.py +55 -0
  277. primarysource/usbids/store.py +80 -0
  278. primarysource/usbids/tools.py +174 -0
  279. primarysource/zim/__init__.py +5 -0
  280. primarysource/zim/__main__.py +8 -0
  281. primarysource/zim/_libzim.py +37 -0
  282. primarysource/zim/catalog.py +594 -0
  283. primarysource/zim/cli.py +555 -0
  284. primarysource/zim/collection.py +41 -0
  285. primarysource/zim/errors.py +100 -0
  286. primarysource/zim/groups.py +30 -0
  287. primarysource/zim/library.py +668 -0
  288. primarysource/zim/manage.py +636 -0
  289. primarysource/zim/markdown.py +1247 -0
  290. primarysource/zim/mcp.py +201 -0
  291. primarysource/zim/purl.py +130 -0
  292. primarysource/zim/py.typed +0 -0
  293. primarysource/zim/refs.py +90 -0
  294. primarysource/zim/service.py +1182 -0
  295. primarysource/zim/snapshot.py +197 -0
  296. primarysource/zim/sync.py +269 -0
  297. primarysource/zim/views.py +230 -0
  298. primarysource-0.1.0.dist-info/METADATA +118 -0
  299. primarysource-0.1.0.dist-info/RECORD +303 -0
  300. primarysource-0.1.0.dist-info/WHEEL +4 -0
  301. primarysource-0.1.0.dist-info/entry_points.txt +2 -0
  302. primarysource-0.1.0.dist-info/licenses/LICENSE +203 -0
  303. primarysource-0.1.0.dist-info/licenses/NOTICE +51 -0
@@ -0,0 +1,1182 @@
1
+ """What the tools do, as functions returning plain dicts. The CLI prints them and the MCP server serialises
2
+ them; neither adds behaviour, so what a test pins here is what both surfaces do.
3
+
4
+ Budgets are in *estimated* tokens, `len(text) // CHARS_PER_TOKEN`. That is the same crude estimate rfclib and
5
+ itulib use: the point is that a response is bounded and says when the bound, not the corpus, ended it.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import difflib
11
+ import json
12
+ import math
13
+ import os
14
+ import re
15
+ from bisect import bisect_left
16
+ from collections import OrderedDict
17
+ from dataclasses import dataclass, replace
18
+ from typing import Any
19
+
20
+ from primarysource import budget
21
+ from primarysource.model import span_hash
22
+
23
+ from . import _libzim, library, manage, snapshot
24
+ from .errors import LibraryNotFound, ZimloreError
25
+ from .library import Book
26
+ from .markdown import MAX_OUTPUT_CHARS, Document, Heading, convert, join_blocks, to_plain
27
+ from .refs import Ref, format_ref, parse_ref
28
+
29
+ CHARS_PER_TOKEN = 4
30
+ HTML_TYPES = ("text/html", "application/xhtml+xml")
31
+ TEXT_TYPES = ("text/plain", "text/markdown")
32
+ MAX_REDIRECTS = 8
33
+ DEFAULT_MAX_ENTRY_BYTES = 8 * 1024 * 1024
34
+ MAX_ENTRY_ENV = "PRIMARYSOURCE_ZIM_MAX_ENTRY_BYTES"
35
+ LEGACY_MAX_ENTRY_ENV = "ZIMLORE_MAX_ENTRY_BYTES"
36
+ """The largest entry `document` will read and convert, by the size libzim reports before any byte is read. A page
37
+ this size is not documentation: a 16 MB page of 2,000,000 paragraphs took 47 s to convert, and an entry of
38
+ gigabytes was read into memory whole. Set `PRIMARYSOURCE_ZIM_MAX_ENTRY_BYTES`
39
+ (legacy: `ZIMLORE_MAX_ENTRY_BYTES`) to a larger number to read one anyway."""
40
+ MAX_TOKENS_CAP = 8_000
41
+ """The most one `read`, `search`, `links` or `outline` call may spend. A model that passed `max_tokens=100000` once
42
+ received a 52,000-token page; 8,000 is four default reads, and a longer page is reached by paging, which is
43
+ cheaper to decide on than to undo. A larger request is cut to this and the result says so in `clamped`."""
44
+ MAX_LIMIT = 500
45
+ SEARCH_DEPTH = 25
46
+ """How many candidates each library is asked for at least, whatever page of hits is wanted (see `search`)."""
47
+ _DOCS: OrderedDict[tuple[str, str], tuple[Any, str, Document]] = OrderedDict()
48
+ _DOC_CACHE = 64
49
+
50
+
51
+ def tokens(text: str) -> int:
52
+ return max(1, len(text) // CHARS_PER_TOKEN)
53
+
54
+
55
+ def _bounded(name: str, value: int, low: int, high: int, clamped: dict[str, int] | None = None) -> int:
56
+ """Validate an agent-supplied number. Below `low` (or not an integer) is an error that says the range, because
57
+ a quiet fix would turn a mistake into a wrong answer (`limit=0` once read as 'no entry matched'). Above `high`
58
+ is cut to it and recorded in `clamped`, which the caller puts in its result."""
59
+ if isinstance(value, bool) or not isinstance(value, int) or value < low:
60
+ raise ZimloreError(f"{name} must be an integer of at least {low}, got {value!r}", f"{name} is {low}..{high}")
61
+ if value > high:
62
+ if clamped is not None:
63
+ clamped[name] = high
64
+ return high
65
+ return value
66
+
67
+
68
+ # --- locating things -----------------------------------------------------------------------------------------
69
+
70
+
71
+ def _scan() -> library.Scan:
72
+ scanned = library.scan()
73
+ if not scanned.books:
74
+ error = library.empty_library_error(scanned)
75
+ error.extra.update(
76
+ tool_hint="no ZIM files are installed, so nothing can be read; zim_available lists what could be fetched",
77
+ user_action="ask the user to download a library and put it in the directory named in `searched`",
78
+ user_command=error.cli_hint,
79
+ )
80
+ error.settle()
81
+ raise error
82
+ return scanned
83
+
84
+
85
+ def _select(name: str, scanned: library.Scan) -> library.Selection:
86
+ """`library.select`, and when nothing installed matches, say what the offline snapshot says is obtainable.
87
+ A missing or unreadable snapshot never turns the miss into a different error. A version that is not installed
88
+ is a miss of another kind (the library IS installed) and is left as it is."""
89
+ try:
90
+ return library.select(name, scanned)
91
+ except LibraryNotFound as error:
92
+ if "requested_version" not in error.extra:
93
+ error.extra.update(_obtainable(name, scanned))
94
+ error.extra["tool_hint"] = (
95
+ "call zim_libraries to see what is installed; to install one, call zim_available, then ask the user "
96
+ "to run the `install` command it returns"
97
+ )
98
+ error.settle()
99
+ raise
100
+
101
+
102
+ def _find(name: str, scanned: library.Scan) -> Book:
103
+ return _select(name, scanned).book
104
+
105
+
106
+ def _newest_per_family(books: list[Book]) -> list[Book]:
107
+ """One book for each family: its newest version, in its newest edition. Searching every installed version of
108
+ a package would spend the budget on the same documentation five times."""
109
+ families: dict[str, list[Book]] = {}
110
+ for book in sorted(books, key=lambda b: library.date_key(b.date), reverse=True): # newest first: the order
111
+ families.setdefault(book.family, []).append(book) # the interleaved ranking has always used
112
+ return [library.newest_version(members) for members in families.values()]
113
+
114
+
115
+ def _installed(scanned: library.Scan) -> dict[str, str]:
116
+ newest: dict[str, str] = {}
117
+ for book in scanned.books:
118
+ newest[book.name] = max(newest.get(book.name, ""), book.date)
119
+ return newest
120
+
121
+
122
+ def _no_snapshot(error: ZimloreError) -> dict[str, Any]:
123
+ """What a model needs when there is no usable catalogue snapshot: it cannot make one (that needs the network
124
+ and the user's say-so), so the next move is to ask. `user_command` is the CLI text for the human."""
125
+ command = error.cli_hint or snapshot.HINT
126
+ return {
127
+ "user_action": f"ask the user to run `{command}` once; you cannot, the server never touches the network",
128
+ "user_command": command,
129
+ }
130
+
131
+
132
+ def _obtainable(name: str, scanned: library.Scan) -> dict[str, Any]:
133
+ try:
134
+ found = snapshot.available(name, 5, _installed(scanned))
135
+ except ZimloreError as error:
136
+ return {
137
+ "snapshot_hint": f"{error.message}; run `{error.cli_hint or snapshot.HINT}` "
138
+ "to learn what could be installed",
139
+ **_no_snapshot(error),
140
+ }
141
+ return {"obtainable": found["available"], "snapshot_age_days": found["snapshot"]["age_days"]}
142
+
143
+
144
+ def _obtainable_for_query(query: str, scanned: library.Scan) -> dict[str, Any]:
145
+ """`obtainable` for a search that found nothing it is sure of. A snapshot is matched against library names and
146
+ titles, so a sentence rarely matches whole: the query as written first, then each content word, at most 5 rows
147
+ in all. No snapshot, an unusable one or no match is an empty dict: a search never fails for want of one."""
148
+ rows: list[dict[str, Any]] = []
149
+ age: int | None = None
150
+ for text in [query, *_query_words(query)]:
151
+ try:
152
+ found = snapshot.available(text, 5, _installed(scanned))
153
+ except ZimloreError:
154
+ return {}
155
+ age = found["snapshot"]["age_days"]
156
+ names = {row["name"] for row in rows}
157
+ rows += [row for row in found["available"] if row["name"] not in names]
158
+ if len(rows) >= 5:
159
+ break
160
+ if not rows:
161
+ return {}
162
+ return {"obtainable": rows[:5], "snapshot_age_days": age}
163
+
164
+
165
+ def available(query: str, limit: int = 8, start: int = 0) -> dict[str, Any]:
166
+ """What the offline catalogue snapshot says could be installed for `query` (no network), with which of it
167
+ is already installed. Without a usable snapshot it raises, naming the installed libraries (so "not installed"
168
+ is still answerable) and what only the user can do."""
169
+ scanned = library.scan()
170
+ start = _bounded("start", start, 0, 2**62)
171
+ try:
172
+ found = snapshot.available(query, start + limit, _installed(scanned))
173
+ except ZimloreError as error:
174
+ if error.cli_hint != snapshot.HINT: # an empty query is not a missing snapshot
175
+ raise
176
+ names = sorted(_installed(scanned))
177
+ raise ZimloreError(
178
+ f"{error.message}; the libraries installed are: {', '.join(names) or 'none'}",
179
+ snapshot.HINT,
180
+ installed=names,
181
+ tool_hint="no snapshot, so what could be installed is unknown; if the library you want is not among "
182
+ "the installed ones, it is not installed",
183
+ **_no_snapshot(error),
184
+ ) from error
185
+ found["available"] = found["available"][start:]
186
+ if found["matches"] > start + len(found["available"]):
187
+ found["next"] = start + len(found["available"])
188
+ return found
189
+
190
+
191
+ def _book(name: str, scanned: library.Scan | None = None) -> Book:
192
+ return _find(name, scanned or _scan())
193
+
194
+
195
+ class EntryTooLarge(ZimloreError):
196
+ """An entry over the read budget. A subclass so `search` can keep the hit and say so, where `read` fails."""
197
+
198
+
199
+ def max_entry_bytes() -> int:
200
+ name = MAX_ENTRY_ENV
201
+ raw = os.environ.get(name, "").strip()
202
+ if not raw:
203
+ name = LEGACY_MAX_ENTRY_ENV
204
+ raw = os.environ.get(name, "").strip()
205
+ if not raw:
206
+ return DEFAULT_MAX_ENTRY_BYTES
207
+ try:
208
+ value = int(raw)
209
+ except ValueError:
210
+ value = 0
211
+ if value < 1:
212
+ raise ZimloreError(f"{name}={raw!r} is not a positive number of bytes", f"unset {name}")
213
+ return value
214
+
215
+
216
+ def resolve_redirect(entry: Any, label: str) -> Any:
217
+ """Follow redirects from `entry` to the entry they end at, at most `MAX_REDIRECTS` hops. A loop (only a
218
+ hand-edited ZIM has one: libzim's writer refuses them) or a target that cannot be read raises `ZimloreError`;
219
+ callers that are listing things skip the entry, callers that were asked for it report the error."""
220
+ hops = 0
221
+ while entry.is_redirect:
222
+ hops += 1
223
+ if hops > MAX_REDIRECTS:
224
+ raise ZimloreError(f"redirect loop at {label!r}")
225
+ try:
226
+ entry = entry.get_redirect_entry()
227
+ except (RuntimeError, KeyError, OSError) as error:
228
+ raise ZimloreError(f"redirect at {label!r} points at an entry that cannot be read ({error})") from error
229
+ return entry
230
+
231
+
232
+ def _entry(book: Book, path: str) -> tuple[Any, str, str | None]:
233
+ """The entry at `path`, following redirects. Returns (entry, final path, redirected-from or None)."""
234
+ archive = library.open_archive(book.path)
235
+ if not path:
236
+ if not archive.has_main_entry:
237
+ raise ZimloreError(
238
+ f"{book.name} has no main page",
239
+ f"primarysource zim outline {book.name}",
240
+ tool_hint=f"call zim_outline with ref={book.name!r} to list its entries",
241
+ )
242
+ entry = archive.main_entry
243
+ first = None
244
+ else:
245
+ if not archive.has_entry_by_path(path):
246
+ raise _missing(book, path)
247
+ entry = archive.get_entry_by_path(path)
248
+ first = path
249
+ try:
250
+ entry = resolve_redirect(entry, path or "(main page)")
251
+ except ZimloreError as error:
252
+ raise ZimloreError(f"{book.name}: {error.message}") from error
253
+ return entry, str(entry.path), first if first and first != entry.path else None
254
+
255
+
256
+ def _missing(book: Book, path: str) -> ZimloreError:
257
+ near = _suggest(book, path.rsplit("/", 1)[-1].replace("-", " ").replace("_", " "), 5)
258
+ return ZimloreError(
259
+ f"{book.name} has no entry {path!r}",
260
+ f"primarysource zim search {book.name} <words> finds entries; "
261
+ f"primarysource zim outline {book.name} lists them",
262
+ tool_hint=f"call zim_search with library={book.name!r} to find the entry, or zim_outline to list entries",
263
+ **({"did_you_mean": [format_ref(book.name, p) for p, _ in near]} if near else {}),
264
+ )
265
+
266
+
267
+ def document(book: Book, path: str) -> tuple[Document, str, str | None]:
268
+ """The converted article at `path` (cached). Returns (document, final path, redirected-from)."""
269
+ entry, final, via = _entry(book, path)
270
+ key = (book.uuid, final)
271
+ if key in _DOCS:
272
+ _DOCS.move_to_end(key)
273
+ cached = _DOCS[key]
274
+ return cached[2], final, via
275
+ item = entry.get_item()
276
+ mimetype = str(item.mimetype).split(";")[0].strip().lower()
277
+ size, budget = int(item.size), max_entry_bytes()
278
+ if size > budget and mimetype in HTML_TYPES + TEXT_TYPES:
279
+ raise EntryTooLarge(
280
+ f"{book.name}§{final} is {size:,} bytes, over the {budget:,}-byte read limit",
281
+ f"to read it anyway, set {MAX_ENTRY_ENV}={size} (or more) and repeat the call",
282
+ tool_hint="read a different entry; this one is over the server's size limit",
283
+ user_action=f"only the user can raise the limit: {MAX_ENTRY_ENV}={size} in the server's environment",
284
+ size=size,
285
+ limit=budget,
286
+ )
287
+ raw = bytes(item.content).decode("utf-8", errors="replace")
288
+ if mimetype in HTML_TYPES:
289
+ doc = convert(raw, final)
290
+ elif mimetype in TEXT_TYPES:
291
+ blocks = [part.strip("\n") for part in re.split(r"\n\s*\n", raw) if part.strip()]
292
+ doc = Document(entry.title or final, blocks)
293
+ else:
294
+ raise ZimloreError(
295
+ f"{book.name}§{final} is {mimetype}, not text",
296
+ "only HTML and plain-text entries can be read; primarysource zim outline lists the readable ones",
297
+ tool_hint="only HTML and plain-text entries can be read; zim_outline lists the readable ones",
298
+ mimetype=mimetype,
299
+ size=int(item.size),
300
+ )
301
+ _DOCS[key] = (entry.title, final, doc)
302
+ while len(_DOCS) > _DOC_CACHE:
303
+ _DOCS.popitem(last=False)
304
+ return doc, final, via
305
+
306
+
307
+ def _title(book: Book, path: str) -> str:
308
+ archive = library.open_archive(book.path)
309
+ return str(archive.get_entry_by_path(path).title) if archive.has_entry_by_path(path) else path
310
+
311
+
312
+ EMBED_HOSTS = ("disqus.com", "disquscdn.com", "accounts.google.com")
313
+ """Third-party hosts whose frames a web-crawl ZIM stores as HTML entries under `host/path`. Measured in
314
+ devhints.io_en_all (2026-08): 690 of its 1,037 HTML entries are `disqus.com/...` (an "unable to load Disqus"
315
+ notice, or a JSON API reply labelled text/html) and one is `accounts.google.com/o/oauth2/iframe` (no text at all);
316
+ the 346 real cheatsheets are all under `devhints.io/`. A host list is the cheap test: judging by converted length
317
+ cannot work, because the real page `devhints.io/nocode` is 10 characters and the Disqus notices are 128-144."""
318
+
319
+
320
+ def _is_embed(path: str) -> bool:
321
+ host = path.split("/", 1)[0].lower()
322
+ return any(host == h or host.endswith("." + h) for h in EMBED_HOSTS)
323
+
324
+
325
+ def _is_article(entry: Any) -> bool:
326
+ if _is_embed(str(entry.path)):
327
+ return False
328
+ if entry.is_redirect:
329
+ return True
330
+ return str(entry.get_item().mimetype).split(";")[0].strip().lower() in HTML_TYPES + TEXT_TYPES
331
+
332
+
333
+ # --- citation ------------------------------------------------------------------------------------------------
334
+
335
+
336
+ def citation(book: Book, path: str, anchor: str = "", para: int | str | None = None) -> str:
337
+ """Built from the ZIM's own metadata, never typed by hand: library, edition date, entry path."""
338
+ where = path + (f"#{anchor}" if anchor else "") + (f" ¶{para}" if para else "")
339
+ text = f"{book.title} [{book.name}], ZIM edition {book.date or 'undated'}, entry {where or '(main page)'}"
340
+ if book.source:
341
+ text += f"; source {book.source}"
342
+ return text
343
+
344
+
345
+ def licence_note(book: Book) -> str:
346
+ if book.license:
347
+ return book.license
348
+ return "not stated in the ZIM; it is the upstream project's licence, so check the project before reusing text"
349
+
350
+
351
+ # --- libraries -----------------------------------------------------------------------------------------------
352
+
353
+
354
+ def _book_row(
355
+ book: Book, scanned: library.Scan | None = None, verbose: bool = False, family: bool = False
356
+ ) -> dict[str, Any]:
357
+ """One library as a row. The default row is what choosing a library needs (about 40 tokens); `verbose` adds
358
+ what describing one needs (description, creator, tags, licence, the full provenance record)."""
359
+ row: dict[str, Any] = {
360
+ "name": book.name,
361
+ "title": book.title,
362
+ "language": book.language,
363
+ "articles": book.articles,
364
+ "size_bytes": book.size,
365
+ "search": "full-text" if book.has_fulltext else ("titles only" if book.has_titles else "none"),
366
+ "freshness": _fresh(book),
367
+ }
368
+ provenance = manage.provenance_summary(book.path)
369
+ if verbose:
370
+ row.update(
371
+ description=book.description,
372
+ creator=book.creator,
373
+ tags=list(book.tags),
374
+ license=book.license or None,
375
+ provenance=provenance,
376
+ )
377
+ elif provenance["state"] != "recorded":
378
+ row["provenance"] = provenance["state"] # only the exception is worth a token: `unrecorded` or `unreadable`
379
+ if scanned is not None:
380
+ others = [b.date for b in library.editions(book, scanned.books) if b.path != book.path]
381
+ if others:
382
+ row["other_editions"] = others
383
+ if book.version:
384
+ row["version"] = book.version # a row of a family is its newest version
385
+ versions = library.versions_of(book, scanned.books)
386
+ if family and len(versions) > 1:
387
+ row["family"] = book.family
388
+ row["versions"] = versions
389
+ return row
390
+
391
+
392
+ def _fresh(book: Book) -> dict[str, Any]:
393
+ fresh = book.freshness()
394
+ fresh.pop("stale_after_days", None) # a constant, stated once in the server instructions
395
+ return fresh
396
+
397
+
398
+ def libraries(
399
+ query: str = "", limit: int = 50, verbose: bool = False, all_versions: bool = False, start: int = 0
400
+ ) -> dict[str, Any]:
401
+ """Installed libraries (or those matching `query`, best first), newest edition of each, at most `limit`. The
402
+ versions of one package are ONE row (its newest version, `versions` listing them all) unless `all_versions`
403
+ asks for a row per ZIM `Name`.
404
+
405
+ A library with 243 DevDocs ZIMs installed would otherwise cost about 35,000 tokens in one call."""
406
+ clamped: dict[str, int] = {}
407
+ limit = _bounded("limit", limit, 1, 1000, clamped)
408
+ start = _bounded("start", start, 0, 2**62)
409
+ scanned = _scan()
410
+ home = str(library.zim_dirs()[0]) if library.zim_dirs() else ""
411
+ if query:
412
+ ranked = library.rank(query, scanned.books, library.user_aliases())
413
+ rows = []
414
+ for candidate in ranked[start : start + limit]:
415
+ row = _book_row(candidate.book, scanned, verbose, family=True)
416
+ row["match"] = candidate.why
417
+ row["score"] = round(candidate.score, 1)
418
+ rows.append(row)
419
+ result: dict[str, Any] = {"query": query, "total": len(ranked), "libraries": rows}
420
+ else:
421
+ if all_versions:
422
+ newest: dict[str, Book] = {}
423
+ for book in sorted(scanned.books, key=lambda b: library.date_key(b.date), reverse=True):
424
+ newest.setdefault(book.name, book)
425
+ ordered = sorted(newest.values(), key=lambda b: b.name)
426
+ else:
427
+ ordered = sorted(_newest_per_family(scanned.books), key=lambda b: b.family)
428
+ rows = [_book_row(b, scanned, verbose, family=not all_versions) for b in ordered[start : start + limit]]
429
+ result = {"total": len(ordered), "libraries": rows}
430
+ result["truncated"] = result["total"] > start + len(rows)
431
+ if result["truncated"]:
432
+ result["next"] = start + len(rows)
433
+ result["directory"] = home
434
+ if scanned.problems:
435
+ result["problems"] = scanned.problems
436
+ if clamped:
437
+ result["clamped"] = clamped
438
+ return result
439
+
440
+
441
+ def resolve(name: str) -> dict[str, Any]:
442
+ """The one library a name means, with its edition. Raises with the candidates rather than guessing."""
443
+ scanned = _scan()
444
+ chosen = _select(name, scanned)
445
+ book = chosen.book
446
+ row = _book_row(book, scanned, verbose=True)
447
+ row["match"] = chosen.why
448
+ if book.version:
449
+ row["version"] = {
450
+ "upstream": book.version,
451
+ "selected": chosen.kind,
452
+ "pinned_by": "purl tag" if book.package and book.package.version else "version in the Name",
453
+ "edition": book.date or None,
454
+ }
455
+ if chosen.requested:
456
+ row["version"]["requested"] = chosen.requested
457
+ if chosen.other_versions:
458
+ row["other_versions"] = chosen.other_versions
459
+ else:
460
+ row["version"] = {
461
+ "pinned_by": "ZIM edition date",
462
+ "edition": book.date or None,
463
+ "note": "a ZIM records its date, not the upstream release number",
464
+ }
465
+ return row
466
+
467
+
468
+ # --- search --------------------------------------------------------------------------------------------------
469
+
470
+
471
+ @dataclass
472
+ class Hit:
473
+ book: Book
474
+ path: str
475
+ title: str
476
+ match: str # "full-text" (every word) | "some terms" (partial) | "title"
477
+ lang: str = "" # the language the page's content declares, when the HTML says (MediaWiki does)
478
+ exact: bool = False # a title that is the query: the one `title` match that is not a guess
479
+
480
+
481
+ _CONTENT_LANG = re.compile(r"""<div\b[^>]*\bid=["']mw-content-text["'][^>]*?\blang=["']([A-Za-z0-9-]+)["']""")
482
+
483
+
484
+ def _content_lang(entry: Any) -> str:
485
+ """The language a page's body declares: MediaWiki writes `lang` on `<div id="mw-content-text">`, and that is
486
+ the one reliable marker in ArchWiki (measured, 2026-10 ZIM: 3,674 of 3,779 pages with a `_(Español)`-style path
487
+ suffix declare a language other than `en`; 2,356 of 2,395 pages without a suffix declare `en`). The `<html>`
488
+ element's own `lang` is `en` on every page, and the path suffix alone is not a language marker (`Foo_(2022)`)."""
489
+ item = entry.get_item()
490
+ if str(item.mimetype).split(";")[0].strip().lower() not in HTML_TYPES:
491
+ return ""
492
+ head = bytes(memoryview(item.content)[:16384]) # not bytes(item.content)[:n]: that copies a whole big entry
493
+ match = _CONTENT_LANG.search(head.decode("utf-8", errors="replace"))
494
+ return match.group(1).lower() if match else ""
495
+
496
+
497
+ def _same_language(lang: str, wanted: str) -> bool:
498
+ """`en` matches `en-GB`; a three-letter ISO 639-3 code (the ZIM's `Language` metadata, `eng`) matches the
499
+ two-letter code it extends by prefix, which holds for eng/en, fra/fr, deu/de and is only a convention."""
500
+ lang, wanted = lang.split("-")[0], wanted.lower().split("-")[0]
501
+ return (
502
+ lang == wanted
503
+ or (len(wanted) == 3 and wanted.startswith(lang))
504
+ or (len(lang) == 3 and lang.startswith(wanted))
505
+ )
506
+
507
+
508
+ def _suggest(book: Book, query: str, limit: int) -> list[tuple[str, str]]:
509
+ if not book.has_titles or not query.strip():
510
+ return []
511
+ suggestion = _libzim.load("suggestion")
512
+ archive = library.open_archive(book.path)
513
+ try:
514
+ result = suggestion.SuggestionSearcher(archive).suggest(query)
515
+ paths = list(result.get_results(0, limit))
516
+ except RuntimeError:
517
+ return []
518
+ return [(str(p), _title(book, str(p))) for p in paths]
519
+
520
+
521
+ TITLE_BOOST = 3.0
522
+ STOPWORDS = frozenset(
523
+ [
524
+ "a",
525
+ "an",
526
+ "and",
527
+ "are",
528
+ "as",
529
+ "at",
530
+ "be",
531
+ "by",
532
+ "do",
533
+ "does",
534
+ "for",
535
+ "from",
536
+ "how",
537
+ "i",
538
+ "in",
539
+ "into",
540
+ "is",
541
+ "it",
542
+ "its",
543
+ "my",
544
+ "of",
545
+ "on",
546
+ "or",
547
+ "so",
548
+ "that",
549
+ "the",
550
+ "this",
551
+ "to",
552
+ "use",
553
+ "using",
554
+ "what",
555
+ "when",
556
+ "where",
557
+ "which",
558
+ "with",
559
+ "you",
560
+ "your",
561
+ "can",
562
+ ]
563
+ )
564
+
565
+
566
+ def _run_query(book: Book, text: str, want: int) -> list[str]:
567
+ search = _libzim.load("search")
568
+ archive = library.open_archive(book.path)
569
+ try:
570
+ found = search.Searcher(archive).search(search.Query().set_query(text))
571
+ return [str(r.path) if hasattr(r, "path") else str(r) for r in found.get_results(0, want)]
572
+ except RuntimeError:
573
+ return []
574
+
575
+
576
+ def _fulltext(book: Book, query: str, want: int) -> tuple[list[str], list[str]]:
577
+ """Full-text results as (every-term matches, some-term matches).
578
+
579
+ libzim's query parser requires every term, so a question whose words do not all occur in one page finds
580
+ nothing ("automated" occurs in none of Flask's pages, so "write automated tests for a flask application"
581
+ matched nothing at all). When strict search leaves fewer than `want`, each content word is searched on its
582
+ own and the lists are fused: a page earns, for each word it matches, a weight of ln(pages / pages matching
583
+ the word) divided by 60 plus its rank in that word's list. A word found on every page therefore weighs
584
+ nothing, and a rare one decides. Those extra results are marked as partial matches.
585
+ """
586
+ cleaned = re.sub(r'[^\w\s"+-]', " ", query)
587
+ strict = _run_query(book, query, want) or _run_query(book, cleaned, want)
588
+ if len(strict) >= want:
589
+ return strict, []
590
+ terms = list(
591
+ dict.fromkeys(w for w in re.findall(r"[\w+-]+", cleaned.lower()) if w not in STOPWORDS and len(w) > 1)
592
+ )
593
+ if len(terms) < 2:
594
+ return strict, []
595
+ search = _libzim.load("search")
596
+ archive = library.open_archive(book.path)
597
+ total = max(book.articles, 2)
598
+ scores: dict[str, float] = {}
599
+ for term in terms:
600
+ try:
601
+ found = search.Searcher(archive).search(search.Query().set_query(term))
602
+ matches = int(found.get_estimated_matches())
603
+ ranked = [str(r.path) if hasattr(r, "path") else str(r) for r in found.get_results(0, 50)]
604
+ except RuntimeError:
605
+ continue
606
+ if not matches:
607
+ continue
608
+ weight = math.log(total / matches) if matches < total else 0.0
609
+ for rank, path in enumerate(ranked):
610
+ scores[path] = scores.get(path, 0.0) + weight / (60 + rank)
611
+ for rank, (path, _) in enumerate(_suggest(book, term, 20)): # a word in a page's title is a strong signal
612
+ scores[path] = scores.get(path, 0.0) + TITLE_BOOST * max(weight, 0.5) / (60 + rank)
613
+ loose = [p for p, _ in sorted(scores.items(), key=lambda kv: -kv[1]) if p not in strict and scores[p] > 0]
614
+ return strict, loose[: want * 2]
615
+
616
+
617
+ def _book_hits(book: Book, query: str, want: int, language: str = "") -> tuple[list[Hit], str]:
618
+ """Title matches that are exact come first, then full-text relevance, then the remaining title matches.
619
+ Entries that are not articles (stylesheets, scripts) and redirects to the same article are folded.
620
+
621
+ `language` decides what a page's declared content language does. Empty: pages in another language than the
622
+ library's are kept but ranked after the pages in its own language (ArchWiki holds a translation of most pages
623
+ and they crowded the base page out of the top 5). `any`: no preference. A code (`ru`): only pages in it."""
624
+ archive = library.open_archive(book.path)
625
+ titled = _suggest(book, query, want)
626
+ mode = "full-text" if book.has_fulltext else "titles only"
627
+ strict, loose = _fulltext(book, query, want * 2) if book.has_fulltext else ([], [])
628
+ wanted = " ".join(query.lower().split())
629
+ exact = [p for p, t in titled if " ".join(t.lower().split()) == wanted]
630
+ ordered: list[tuple[str, str]] = (
631
+ [(p, "exact") for p in exact]
632
+ + [(p, "full-text") for p in strict]
633
+ + [(p, "title") for p, _ in titled if p not in exact]
634
+ + [(p, "some terms") for p in loose]
635
+ )
636
+ hits: list[Hit] = []
637
+ foreign: list[Hit] = []
638
+ seen: set[str] = set()
639
+ folding = language not in {"any"}
640
+ for path, how in ordered:
641
+ if not archive.has_entry_by_path(path):
642
+ continue
643
+ entry = archive.get_entry_by_path(path)
644
+ if not _is_article(entry):
645
+ continue
646
+ try:
647
+ entry = resolve_redirect(entry, path)
648
+ except ZimloreError:
649
+ continue # a redirect loop or dangling target is one bad hit, not a failed search
650
+ if entry.path in seen:
651
+ continue
652
+ seen.add(entry.path)
653
+ lang = _content_lang(entry) if folding else ""
654
+ hit = Hit(book, entry.path, str(entry.title), "title" if how == "exact" else how, lang, how == "exact")
655
+ if language and folding:
656
+ if lang and _same_language(lang, language):
657
+ hits.append(hit)
658
+ elif lang and not _same_language(lang, book.language or "eng"):
659
+ foreign.append(hit)
660
+ else:
661
+ hits.append(hit)
662
+ if len(hits) >= want:
663
+ break
664
+ return (hits + foreign)[:want], mode
665
+
666
+
667
+ LARGE_ARTICLE_BLOCKS = 60
668
+ RARE_WORD_BLOCKS = 3 # a single query word is distinctive on a page if at most this many blocks contain it
669
+ """An article with more blocks than this gets section-level hits. 60 blocks is about one default `read` page
670
+ (2000 tokens is roughly 50-100 blocks): past it, an agent that lands on the page pays a whole page to find the
671
+ part it wanted, and an agent testing the server did (Go `httptest`, 168 blocks, ~1.1k tokens to reach the example,
672
+ when the threshold was 200). Measured 2026-11-07 on a 60-400 page random sample per library, the share of articles
673
+ over 60 blocks: PHP 3.5%, Flask 22%, Rust 29%, Python 59%, Go 63%, Node 92% (over 200 it was 0.3%, 7%, 11%, 21%,
674
+ 27% and 65%). The rarity and two-word rules in `_best_section` still decide whether a hit gets one, so a short page
675
+ and a stray word stay article-level."""
676
+
677
+ _CHROME_LEAD = re.compile(
678
+ r"^(pages that refer to this page|colophon\b|this page is part of|table of contents$"
679
+ r"|this document has been placed in the public domain)",
680
+ re.IGNORECASE,
681
+ )
682
+ _BARE_URL = re.compile(r"^[\W_]*(?:https?://|www\.)\S+[\W_]*$", re.IGNORECASE)
683
+ _FIELD_LABEL = re.compile(r"^\*\*[\w -]+:\*\*$")
684
+
685
+
686
+ def _header_end(blocks: list[str]) -> int:
687
+ """Number of leading blocks that are a metadata header: the PEPs open with a field list (`**Author:**`, a
688
+ value block, `**Discussions-To:**`, `[Discourse thread](...)`, ...) closed by a rule. Measured on
689
+ peps.python_en_all: 735 of 765 PEP pages carry it, and 'Discourse thread' was the search excerpt of PEP 649,
690
+ PEP 669 and PEP 728. Requires at least two labels before a rule within the first 60 blocks."""
691
+ labels = 0
692
+ for index, block in enumerate(blocks[:60]):
693
+ if _FIELD_LABEL.match(block):
694
+ labels += 1
695
+ elif block.strip() == "---":
696
+ return index + 1 if labels >= 2 else 0
697
+ return 0
698
+
699
+
700
+ def _is_chrome(block: str) -> bool:
701
+ """Page furniture that is never the answer: a rule, a back-reference list, or a block that is only a URL."""
702
+ flat = " ".join(to_plain(block).split())
703
+ if not flat or set(flat) <= set("-*_= "):
704
+ return True
705
+ return bool(_CHROME_LEAD.match(flat) or _BARE_URL.match(flat))
706
+
707
+
708
+ def _query_words(query: str) -> list[str]:
709
+ """The distinct content words of a query: longer than two characters and not a stopword (`how`, `use`, `the`
710
+ decide nothing about which block answers). One definition for excerpts and section choice."""
711
+ return list(dict.fromkeys(w for w in re.findall(r"\w+", query.lower()) if len(w) > 2 and w not in STOPWORDS))
712
+
713
+
714
+ def _word_patterns(words: list[str]) -> list[re.Pattern[str]]:
715
+ """A word matches from a word boundary (`rotor` finds `rotors`, `cat` does not find `concatenate`)."""
716
+ return [re.compile(r"\b" + re.escape(w)) for w in words]
717
+
718
+
719
+ def _excerpt(blocks: list[str], query: str, width: int = 240) -> str:
720
+ """The best-matching block of `blocks`, flattened and cut to `width`. Prose is preferred; code, tables and
721
+ preformatted text are used when no prose block mentions a query word (man pages are one fence per section).
722
+ Chrome blocks (see `_is_chrome`) and headings are never chosen unless nothing else is left."""
723
+ words = _query_words(query)
724
+ patterns = _word_patterns(words)
725
+ lead = _header_end(blocks)
726
+ body = [b for b in blocks[lead:] if not b.startswith("#") and not _is_chrome(b) and b != "×"] or blocks
727
+ prose = [b for b in body if not b.startswith(("```", "|"))]
728
+ pick = ""
729
+ for pool in (prose, body):
730
+ scored = [(sum(bool(p.search(b.lower())) for p in patterns), -i, b) for i, b in enumerate(pool)]
731
+ best = max(scored, default=(0, 0, ""))
732
+ if best[0]:
733
+ pick = best[2]
734
+ break
735
+ choice = pick or (prose[0] if prose else (body[0] if body else ""))
736
+ flat = " ".join(to_plain(choice).split())
737
+ if len(flat) <= width:
738
+ return flat
739
+ lowered = flat.lower()
740
+ at = min((m.start() for m in (p.search(lowered) for p in patterns) if m), default=0)
741
+ start = max(0, at - width // 3)
742
+ return ("…" if start else "") + flat[start : start + width].strip() + "…"
743
+
744
+
745
+ def _best_section(doc: Document, query: str) -> tuple[Heading, int, int] | None:
746
+ """The addressable heading or definition of a large article whose own text covers most of the query's words.
747
+
748
+ Score = distinct query words found in the section's blocks (sub-sections included) plus 1 for each found in
749
+ the heading text itself; ties go to the shorter section, so a child beats the parent that contains it. A
750
+ section with no query word is never chosen: the caller then keeps the article-level hit as it was.
751
+
752
+ A parent always covers at least what its children do, so a query whose words sit in different parts of the
753
+ page is won by the section that holds them all, often the whole article. A winner that spans more than half of
754
+ the article is no better than the article: `None`, and the caller keeps the article-level hit."""
755
+ words = _query_words(query)
756
+ if not words:
757
+ return None
758
+ patterns = _word_patterns(words)
759
+ masks = []
760
+ for block in doc.blocks:
761
+ low = block.lower()
762
+ masks.append(sum(1 << n for n, p in enumerate(patterns) if p.search(low)))
763
+ best: tuple[tuple[float, int], Heading, int, int] | None = None
764
+ for position, head in enumerate(doc.headings):
765
+ if not head.anchor:
766
+ continue
767
+ lo, hi = doc.span(position)
768
+ found = 0
769
+ for mask in masks[lo:hi]:
770
+ found |= mask
771
+ if not found:
772
+ continue
773
+ title = head.text.lower()
774
+ key = (bin(found).count("1") + sum(bool(p.search(title)) for p in patterns), -(hi - lo))
775
+ if best is None or key > best[0]:
776
+ best = (key, head, lo, hi)
777
+ if best is None or (best[3] - best[2]) * 2 > len(doc.blocks):
778
+ return None
779
+ if best[0][0] < 2:
780
+ # Weak evidence: one query word found, none in the heading. For a query of several words that is a stray
781
+ # match in an unrelated section (found live: 'directory' in 'create a directory with missing parents').
782
+ if len(words) > 1:
783
+ return None
784
+ # For a one-word query it is an answer only if the word is distinctive on this page. 'x509' on the x509
785
+ # reference page is in dozens of blocks and attached 'Custom PKCS7 Test Vectors' by luck; 'quibbleframe',
786
+ # in one definition's body, is the answer.
787
+ if sum(1 for mask in masks if mask & 1) > RARE_WORD_BLOCKS:
788
+ return None
789
+ return best[1:]
790
+
791
+
792
+ def search(
793
+ query: str, library_name: str = "", limit: int = 8, max_tokens: int = 1500, language: str = "", start: int = 0
794
+ ) -> dict[str, Any]:
795
+ """Search one library, or all of them when `library_name` is empty. Hits are refs ready for `read`.
796
+
797
+ `language`: empty ranks pages written in another language than the library's after those in its own (a hit
798
+ then carries `language`), `any` keeps the engine's order, a code such as `ru` returns only pages declaring it.
799
+
800
+ Across libraries the order is rank-by-rank interleaving: libzim does not expose a relevance score, so scores
801
+ from different ZIMs cannot be compared and pretending otherwise would be invented precision.
802
+ """
803
+ if not query.strip():
804
+ raise ZimloreError(
805
+ "empty query",
806
+ "give a few words, e.g. primarysource zim search flask 'application factory'",
807
+ tool_hint="give a few words, e.g. query='application factory'",
808
+ )
809
+ clamped: dict[str, int] = {}
810
+ limit = _bounded("limit", limit, 1, MAX_LIMIT, clamped)
811
+ start = _bounded("start", start, 0, 2**62)
812
+ max_tokens = _bounded("max_tokens", max_tokens, 1, MAX_TOKENS_CAP, clamped)
813
+ scanned = _scan()
814
+ books = [_find(library_name, scanned)] if library_name else _newest_per_family(scanned.books)
815
+ # The engine has no offset: a later page asks it for everything up to its own end, and the earlier hits are
816
+ # skipped. How many title and full-text candidates the engine is asked for decides the order past the first few
817
+ # (`_book_hits` fills from its tiers), so every page asks for at least SEARCH_DEPTH: pages are then cuts of one
818
+ # list, which is what lets `next: start=<n>` lose nothing. Reading a page's hits, the costly step, is unchanged.
819
+ per_book = max(SEARCH_DEPTH, start + (limit if len(books) == 1 else max(2, min(limit, 5))))
820
+ lists: list[list[Hit]] = []
821
+ modes: dict[str, str] = {}
822
+ for book in books:
823
+ hits, mode = _book_hits(book, query, per_book, language.strip().lower())
824
+ modes[book.name] = mode
825
+ lists.append(hits)
826
+ ordered: list[Hit] = []
827
+ for rank in range(per_book):
828
+ for hits in lists:
829
+ if rank < len(hits):
830
+ ordered.append(hits[rank])
831
+ kept: list[dict[str, Any]] = []
832
+ used, truncated, sure = 0, False, False
833
+ for hit in ordered[start : start + limit]:
834
+ too_large = ""
835
+ try:
836
+ doc, _, _ = document(hit.book, hit.path)
837
+ except EntryTooLarge as error:
838
+ doc, too_large = Document("", []), error.message
839
+ picked = _best_section(doc, query) if len(doc.blocks) > LARGE_ARTICLE_BLOCKS else None
840
+ row: dict[str, Any] = {
841
+ "ref": format_ref(hit.book.name, hit.path),
842
+ "title": hit.title,
843
+ "excerpt": _excerpt(doc.blocks[picked[1] : picked[2]] if picked else doc.blocks, query),
844
+ "match": hit.match,
845
+ }
846
+ if too_large:
847
+ row["note"] = f"no excerpt: {too_large}"
848
+ else:
849
+ row["blocks"] = len(doc.blocks) # what reading the whole page would cost, before deciding to
850
+ if hit.lang and not _same_language(hit.lang, hit.book.language or "eng"):
851
+ row["language"] = hit.lang
852
+ cost = tokens(row["excerpt"] + hit.title) + 30
853
+ if picked:
854
+ head = picked[0]
855
+ row["section"] = {
856
+ "anchor": head.anchor,
857
+ "heading": head.text,
858
+ "ref": format_ref(hit.book.name, hit.path, head.anchor),
859
+ "blocks": picked[2] - picked[1],
860
+ }
861
+ cost += tokens(head.text) + 30
862
+ if kept and used + cost > max_tokens:
863
+ truncated = True
864
+ break
865
+ used += cost
866
+ kept.append(row)
867
+ sure = sure or hit.match == "full-text" or hit.exact
868
+ result: dict[str, Any] = {
869
+ "query": query,
870
+ "libraries_searched": [b.name for b in books],
871
+ "editions": {b.name: b.date or None for b in books}, # once, not per hit; `zim_cite` builds the citation
872
+ "hits": kept,
873
+ "truncated": truncated or len(ordered) > start + limit,
874
+ }
875
+ if result["truncated"]:
876
+ result["next"] = start + len(kept)
877
+ title_only = [name for name, mode in modes.items() if mode == "titles only"]
878
+ if title_only:
879
+ result["title_only"] = f"no full-text index in: {', '.join(title_only)}; only titles were searched there"
880
+ if len(books) > 1:
881
+ result["ranking"] = "interleaved rank by rank across libraries; ZIM search exposes no comparable score"
882
+ if clamped:
883
+ result["clamped"] = clamped
884
+ if not kept:
885
+ result["note"] = "no entry matched; this is the search, not proof the topic is absent from the library"
886
+ if not sure:
887
+ # Every hit is partial ('some terms') or a title guess: the engine found pages that share a word, not a page
888
+ # that answers. Found live: an uncovered topic returned 8 plausible hits and nothing said to stop.
889
+ result["confidence"] = "low"
890
+ if kept:
891
+ result["note"] = (
892
+ "no hit matched all your words; the topic may not be covered - check zim_available "
893
+ "(`obtainable`, when present, is what the offline catalogue snapshot lists)"
894
+ )
895
+ result.update(_obtainable_for_query(query, scanned))
896
+ return result
897
+
898
+
899
+ # --- reading -------------------------------------------------------------------------------------------------
900
+
901
+
902
+ def _context(ref: str, library_name: str) -> tuple[Book, Ref]:
903
+ parsed = parse_ref(ref, library_name)
904
+ return _book(parsed.book), parsed
905
+
906
+
907
+ def _no_section(book: Book, final: str, doc: Document, anchor: str) -> ZimloreError:
908
+ """A bad anchor. The three closest real anchors by spelling are named as refs ready to read; the first 25 in
909
+ document order (the old answer) said nothing about a typo like `os.scandirr`, whose fix is one letter."""
910
+ lowered = {h.anchor.lower(): h.anchor for h in doc.headings if h.anchor}
911
+ close = [lowered[m] for m in difflib.get_close_matches(anchor.lower(), list(lowered), n=3, cutoff=0.6)]
912
+ return ZimloreError(
913
+ f"{book.name}§{final} has no section #{anchor}",
914
+ f"primarysource zim outline {book.name}§{final} lists the anchors",
915
+ tool_hint=f"call zim_outline with ref={book.name}§{final} to list its anchors",
916
+ **({"did_you_mean": [format_ref(book.name, final, a) for a in close]} if close else {}),
917
+ )
918
+
919
+
920
+ def _scope(book: Book, final: str, doc: Document, parsed: Ref, onward: bool = False) -> tuple[int, int, str]:
921
+ """The blocks a ref names, as (lo, hi, scope) over `doc.blocks`. `¶n` counts inside the scope it is written
922
+ after: the nth block of the section when there is an `#anchor`, of the article otherwise. Counting the page's
923
+ nth block under a section's name would cite a section for text it does not contain.
924
+
925
+ `¶n` names that one block (what `cite` checks) unless `onward`: a read of `ref¶n` starts at block n and
926
+ continues to the end of the scope, which is how a cut read's `next:` ref pages (PLAN.md §5.11)."""
927
+ lo, hi, scope = 0, len(doc.blocks), "article"
928
+ if parsed.anchor:
929
+ span = doc.section(parsed.anchor)
930
+ if span is None:
931
+ raise _no_section(book, final, doc, parsed.anchor)
932
+ (lo, hi), scope = span, "section"
933
+ if parsed.para is not None:
934
+ if parsed.para > hi - lo:
935
+ where = f"#{parsed.anchor}" if parsed.anchor else ""
936
+ raise ZimloreError(
937
+ f"{book.name}§{final}{where} has {hi - lo} blocks; no ¶{parsed.para}",
938
+ "¶n counts the blocks of the section when the ref has an #anchor, of the article otherwise",
939
+ blocks=hi - lo,
940
+ )
941
+ lo = lo + parsed.para - 1
942
+ if not onward:
943
+ hi, scope = lo + 1, "block"
944
+ return lo, hi, scope
945
+
946
+
947
+ def _label(first: int, last: int, para: int | None) -> int | str | None:
948
+ """What a read's citation names: nothing for a whole scope read from its first block, `¶n` for one block, `¶a-b`
949
+ for a page that starts inside the scope or holds several blocks of a `¶` read (blocks 1-based in the scope)."""
950
+ if para is None and first <= 1:
951
+ return None
952
+ return first if first == last else f"{first}-{last}"
953
+
954
+
955
+ def read(
956
+ ref: str,
957
+ library_name: str = "",
958
+ max_tokens: int = 2000,
959
+ format: str = "markdown", # noqa: A002 - the tool parameter is called format
960
+ ) -> dict[str, Any]:
961
+ """One article, one section (`#anchor`), cut to a token budget, from block `¶n` on when the ref has one. `next`
962
+ is a ref (the same scope with `¶` at the first block not shown) when the budget, not the scope, ended the page."""
963
+ if format not in {"markdown", "text"}:
964
+ raise ZimloreError(f"unknown format {format!r}", "format is 'markdown' or 'text'")
965
+ clamped: dict[str, int] = {}
966
+ max_tokens = _bounded("max_tokens", max_tokens, 1, MAX_TOKENS_CAP, clamped)
967
+ book, parsed = _context(ref, library_name)
968
+ doc, final, via = document(book, parsed.path)
969
+ whole = replace(parsed, para=None)
970
+ scope_lo, hi, scope = _scope(book, final, doc, whole)
971
+ lo, _, _ = _scope(book, final, doc, parsed, onward=True)
972
+ first = lo
973
+ # The core pager: whole blocks until the next would pass the budget, the first always taken (so a cursor
974
+ # following `next` terminates), and `oversize` when that one alone is over.
975
+ page = budget.page(doc.blocks[lo:hi], 1, max_tokens)
976
+ chunk = list(page.units)
977
+ index = lo + len(chunk)
978
+ marks = [doc.tight[i] if i < len(doc.tight) else False for i in range(lo, index)]
979
+ body = join_blocks(chunk, marks)
980
+ if format == "text":
981
+ body = to_plain(body)
982
+ ref_out = format_ref(book.name, final, parsed.anchor)
983
+ label = _label(first - scope_lo + 1, index - scope_lo, parsed.para)
984
+ result: dict[str, Any] = {
985
+ "ref": ref_out,
986
+ "cite": citation(book, final, parsed.anchor, label),
987
+ "title": _title(book, final),
988
+ "scope": scope,
989
+ "freshness": _fresh(book),
990
+ "format": format,
991
+ "blocks": {"from": first - scope_lo + 1, "to": index - scope_lo, "of": hi - scope_lo},
992
+ "content": body,
993
+ "next": None,
994
+ }
995
+ if doc.title and doc.title != result["title"]:
996
+ result["page_title"] = doc.title # only when it differs: DevDocs entry titles are not always page names
997
+ if via:
998
+ result["redirected_from"] = via
999
+ if index < hi:
1000
+ result["next"] = format_ref(book.name, final, parsed.anchor, index - scope_lo + 1)
1001
+ # What is left, in the unit a model budgets in: block counts alone made "17 of 76" mean nothing.
1002
+ result["remaining_blocks"] = hi - index
1003
+ result["remaining_tokens"] = sum(tokens(b) for b in doc.blocks[index:hi])
1004
+ if page.oversize:
1005
+ result["oversize"] = "one block alone exceeds the budget and is returned whole so paging makes progress"
1006
+ if doc.truncated:
1007
+ result["converted_truncated"] = (
1008
+ f"the page is larger than the converter's {MAX_OUTPUT_CHARS:,}-character ceiling; its end is not shown"
1009
+ )
1010
+ if clamped:
1011
+ result["clamped"] = clamped
1012
+ if doc.dropped_images:
1013
+ result["images"] = f"{doc.dropped_images} image(s) are not text and appear as [image] placeholders"
1014
+ return result
1015
+
1016
+
1017
+ def _within(rows: list[dict[str, Any]], budget: int) -> tuple[list[dict[str, Any]], bool]:
1018
+ """The leading rows whose estimated cost fits `budget` tokens (always at least one), and whether rows were cut."""
1019
+ used = 0
1020
+ kept: list[dict[str, Any]] = []
1021
+ for row in rows:
1022
+ cost = tokens(json.dumps(row, ensure_ascii=False, separators=(",", ":")))
1023
+ if kept and used + cost > budget:
1024
+ return kept, True
1025
+ used += cost
1026
+ kept.append(row)
1027
+ return kept, False
1028
+
1029
+
1030
+ def outline(
1031
+ ref: str, library_name: str = "", start: int = 0, limit: int = 100, prefix: str = "", max_tokens: int = 1500
1032
+ ) -> dict[str, Any]:
1033
+ """Headings (with anchors) of one article, or a page of a library's articles when `ref` is a library alone."""
1034
+ clamped: dict[str, int] = {}
1035
+ start = _bounded("start", start, 0, 2**62)
1036
+ limit = _bounded("limit", limit, 1, 1000, clamped)
1037
+ max_tokens = _bounded("max_tokens", max_tokens, 1, MAX_TOKENS_CAP, clamped)
1038
+ book, parsed = _context(ref, library_name)
1039
+ if parsed.path:
1040
+ doc, final, _ = document(book, parsed.path)
1041
+ rows = []
1042
+ for h in doc.headings:
1043
+ # The page ref is stated once; a row's own ref is that plus `#anchor` (the library and path repeated on
1044
+ # every row cost about 60 characters each and told an agent nothing).
1045
+ row: dict[str, Any] = {"text": h.text}
1046
+ if h.kind == "heading":
1047
+ row["level"] = h.level
1048
+ else:
1049
+ row["kind"] = "definition"
1050
+ if h.anchor:
1051
+ row["anchor"] = h.anchor
1052
+ rows.append(row)
1053
+ if start > len(rows):
1054
+ raise ZimloreError(f"{book.name}§{final} has {len(rows)} outline rows; start={start} is past the end")
1055
+ page, cut = _within(rows[start : start + limit], max_tokens)
1056
+ out: dict[str, Any] = {
1057
+ "ref": format_ref(book.name, final),
1058
+ "title": doc.title,
1059
+ "total": len(rows),
1060
+ "outline": page,
1061
+ }
1062
+ if start + len(page) < len(rows):
1063
+ out["next"] = start + len(page)
1064
+ out["truncated"] = cut or "next" in out # `next` present means rows are missing, whoever ended the page
1065
+ if clamped:
1066
+ out["clamped"] = clamped
1067
+ return out
1068
+ archive = library.open_archive(book.path)
1069
+ total = int(archive.entry_count)
1070
+ if start > total:
1071
+ raise ZimloreError(f"{book.name} has {total} entries; start={start} is past the end")
1072
+ first = start
1073
+ if prefix and start == 0:
1074
+ first = bisect_left(range(total), prefix, key=lambda i: str(archive.get_entry_by_id(i).path))
1075
+ entries: list[dict[str, Any]] = []
1076
+ positions: list[int] = [] # the entry id to resume from if the listing is cut after entries[n]
1077
+ index = first
1078
+ scanned_ids = 0
1079
+ while index < total and len(entries) < limit and scanned_ids < 20000:
1080
+ entry = archive.get_entry_by_id(index)
1081
+ index += 1
1082
+ scanned_ids += 1
1083
+ if prefix and not str(entry.path).startswith(prefix):
1084
+ index = total
1085
+ break
1086
+ if entry.is_redirect or not _is_article(entry):
1087
+ continue
1088
+ entries.append({"ref": format_ref(book.name, str(entry.path)), "title": str(entry.title)})
1089
+ positions.append(index)
1090
+ kept, cut = _within(entries, max_tokens)
1091
+ resume = positions[len(kept) - 1] if cut else index
1092
+ result: dict[str, Any] = {"library": book.name, "entries": kept, "freshness": _fresh(book)}
1093
+ if clamped:
1094
+ result["clamped"] = clamped
1095
+ if cut or index < total:
1096
+ result["next"] = resume
1097
+ result["truncated"] = cut or "next" in result
1098
+ return result
1099
+
1100
+
1101
+ def links(
1102
+ ref: str, library_name: str = "", limit: int = 100, max_tokens: int = 1500, start: int = 0
1103
+ ) -> dict[str, Any]:
1104
+ """Links out of an article, internal ones resolved to refs, each marked with whether the target exists in
1105
+ this ZIM. An internal link whose target is absent is reported, not dropped: it tells the reader the
1106
+ article points outside what was archived."""
1107
+ clamped: dict[str, int] = {}
1108
+ limit = _bounded("limit", limit, 1, 1000, clamped)
1109
+ start = _bounded("start", start, 0, 2**62)
1110
+ max_tokens = _bounded("max_tokens", max_tokens, 1, MAX_TOKENS_CAP, clamped)
1111
+ book, parsed = _context(ref, library_name)
1112
+ doc, final, _ = document(book, parsed.path)
1113
+ archive = library.open_archive(book.path)
1114
+ rows: list[dict[str, Any]] = []
1115
+ seen: set[tuple[str, str, str]] = set()
1116
+ used, truncated = 0, False
1117
+ distinct = []
1118
+ for link in doc.links:
1119
+ key = (link.path or link.href, link.anchor, link.text)
1120
+ if key in seen:
1121
+ continue
1122
+ seen.add(key)
1123
+ distinct.append(link)
1124
+ if start > len(distinct):
1125
+ raise ZimloreError(f"{book.name}§{final} has {len(distinct)} links; start={start} is past the end")
1126
+ for link in distinct[start:]:
1127
+ if link.path is None:
1128
+ row: dict[str, Any] = {"text": link.text, "url": link.href, "external": True}
1129
+ else:
1130
+ row = {
1131
+ "text": link.text,
1132
+ "ref": format_ref(book.name, link.path, link.anchor),
1133
+ "external": False,
1134
+ "in_zim": bool(archive.has_entry_by_path(link.path)),
1135
+ }
1136
+ cost = tokens(str(row)) + 4
1137
+ if len(rows) >= limit or (rows and used + cost > max_tokens):
1138
+ truncated = True
1139
+ break
1140
+ used += cost
1141
+ rows.append(row)
1142
+ result: dict[str, Any] = {
1143
+ "ref": format_ref(book.name, final),
1144
+ "links": rows,
1145
+ "total": len(distinct),
1146
+ "truncated": truncated,
1147
+ }
1148
+ if truncated:
1149
+ result["next"] = start + len(rows)
1150
+ if clamped:
1151
+ result["clamped"] = clamped
1152
+ return result
1153
+
1154
+
1155
+ JOINER = "\n"
1156
+
1157
+
1158
+ def cite(ref: str, library_name: str = "") -> dict[str, Any]:
1159
+ book, parsed = _context(ref, library_name)
1160
+ entry, final, _ = _entry(book, parsed.path)
1161
+ span = ""
1162
+ if parsed.anchor or parsed.para is not None:
1163
+ doc, _, _ = document(book, final)
1164
+ lo, hi, _ = _scope(book, final, doc, parsed) # the same check `read` makes: a cite must name something real
1165
+ span = span_hash(JOINER.join(doc.blocks[lo:hi]))
1166
+ return {
1167
+ **({"span": span} if span else {}),
1168
+ "ref": format_ref(book.name, final, parsed.anchor, parsed.para),
1169
+ "cite": citation(book, final, parsed.anchor, parsed.para),
1170
+ "title": str(entry.title),
1171
+ "edition": book.date or None,
1172
+ "freshness": _fresh(book),
1173
+ "license": licence_note(book),
1174
+ "provenance": manage.provenance_summary(book.path),
1175
+ "creator": book.creator or None,
1176
+ "uuid": book.uuid,
1177
+ }
1178
+
1179
+
1180
+ def forget() -> None:
1181
+ """Drop the converted-article cache (used by tests that rebuild a ZIM at the same path)."""
1182
+ _DOCS.clear()