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,668 @@
1
+ """The local library: which ZIM files are installed, what they are, and which one a name means.
2
+
3
+ The home directory is the collection's home (`home.collection_home("zim", "zimlore")`): `$ZIMLORE_HOME`, then
4
+ `$PRIMARYSOURCE_ZIM_HOME`, then `$PRIMARYSOURCE_HOME/zim`, then an existing `~/.zimlore`, then the per-user
5
+ default. The ZIM files live in its `zim/` subdirectory. `PRIMARYSOURCE_ZIM_PATH` (legacy: `ZIMLORE_ZIM_PATH`; a
6
+ list separated by the platform's path separator) replaces that subdirectory, so an existing Kiwix library can be
7
+ used where it already is instead of copied. Nothing in this module writes to the
8
+ repository, and the repository's `.gitignore` names every way a ZIM could be spelled.
9
+
10
+ A ZIM identifies itself by its `Name` metadata (`devdocs_en_flask`), not by its file name, and carries a `Date`.
11
+ Two ZIMs with one `Name` and different dates are two *editions* of one library; resolution picks the newest and
12
+ reports the others rather than choosing silently.
13
+
14
+ Producers that build documentation per package version name each build differently (`golang_..._cobra_v1.8.1`),
15
+ so a version is NOT an edition: those are separate Names. They share a *family* (the Name without its trailing
16
+ version token, or the `purl:` tag without its version) and carry a `version`. A query naming a family resolves to
17
+ its newest version by `version_key` order; a query naming a version selects that one (`select`).
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import datetime as dt
23
+ import difflib
24
+ import json
25
+ import os
26
+ import re
27
+ from dataclasses import dataclass, field
28
+ from datetime import UTC, datetime
29
+ from functools import cached_property
30
+ from pathlib import Path
31
+ from typing import Any, NamedTuple
32
+
33
+ from primarysource import home as _homes
34
+
35
+ from . import _libzim, purl
36
+ from .errors import LibraryNotFound, ZimloreError
37
+
38
+ HOME_ENV = "ZIMLORE_HOME"
39
+ """The setting from before the move into primarysource; it still wins (`home.collection_home`)."""
40
+ PATH_ENV = "PRIMARYSOURCE_ZIM_PATH"
41
+ LEGACY_PATH_ENV = "ZIMLORE_ZIM_PATH"
42
+ STALE_DAYS = 365
43
+ """A ZIM older than this is reported `stale`. The number is a convention, not a measurement: the response always
44
+ carries the date and the age in days so a caller can apply its own threshold."""
45
+
46
+ _DATE_SUFFIX = re.compile(r"_\d{4}-\d{2}$")
47
+ _VERSION_TOKEN = re.compile(
48
+ r"^(?P<family>.*[A-Za-z].*?)_(?P<version>[vV]?\d+(?:\.[0-9A-Za-z]+)*(?:-[0-9A-Za-z.]+)?)$"
49
+ )
50
+ _BARE_YEAR = re.compile(r"^(19|20)\d\d$")
51
+ _DATE_TEXT = re.compile(r"(\d{4})-(\d{1,2})-(\d{1,2})(?![0-9])")
52
+
53
+
54
+ def parse_date(text: str) -> dt.date | None:
55
+ """A ZIM `Date` (or a catalogue date) as a calendar date, or None when it is not one. `2026-9-15` is read as
56
+ September 15th, a trailing time is ignored, and nothing else is guessed: free text is never compared as a
57
+ string, because `"2026-9-15" > "2026-10-01"` and `"" < everything` would reorder editions."""
58
+ match = _DATE_TEXT.match(text.strip())
59
+ if match is None:
60
+ return None
61
+ try:
62
+ return dt.date(int(match.group(1)), int(match.group(2)), int(match.group(3)))
63
+ except ValueError:
64
+ return None
65
+
66
+
67
+ def date_key(text: str) -> dt.date:
68
+ """A sort key for display and 'which is newest' choices: undated sorts oldest. Nothing is ever DELETED on the
69
+ strength of this; `manage.prune` refuses a group with an unparseable date instead."""
70
+ return parse_date(text) or dt.date.min
71
+
72
+
73
+ def is_newer(candidate: str, installed: str) -> bool | None:
74
+ """Is `candidate` a later date than `installed`? None when either is not a date: unknown is not 'no'."""
75
+ a, b = parse_date(candidate), parse_date(installed)
76
+ return None if a is None or b is None else a > b
77
+
78
+
79
+ def split_name(name: str) -> tuple[str, str | None]:
80
+ """A ZIM `Name` as (family, version): the trailing `_v?<digits>(.<alnum>)*(-<alnum>)?` token is the version and
81
+ the rest is the family; None when the Name ends in no such token. A `_YYYY-MM` date suffix is an edition
82
+ marker and is dropped first (it is not a version); a bare four-digit year and a bare integer of more than two
83
+ digits (`wikipedia_en_100`) are not versions either, because a count or a year in a name is not a release."""
84
+ base = _DATE_SUFFIX.sub("", name)
85
+ match = _VERSION_TOKEN.match(base)
86
+ if match:
87
+ version = match.group("version")
88
+ bare = version.isdigit()
89
+ if not (bare and (_BARE_YEAR.match(version) or len(version) > 2)):
90
+ return match.group("family"), version
91
+ return base, None
92
+
93
+
94
+ _PRE = {"dev": -4, "a": -3, "alpha": -3, "b": -2, "beta": -2, "c": -1, "rc": -1, "pre": -1, "preview": -1}
95
+ _POST = {"post": 1, "p": 1, "patch": 1, "sp": 1, "rev": 1, "r": 1}
96
+ _FINAL = {"final", "release", "ga", "stable"}
97
+
98
+
99
+ def _version_parts(text: str) -> tuple[list[int], list[str]]:
100
+ """(leading numeric release components, the tokens after them) of a version, case-folded, without a `v` prefix
101
+ and without `+build` metadata."""
102
+ folded = re.sub(r"^v(?=\d)", "", text.strip().lower()).split("+", 1)[0]
103
+ tokens = re.findall(r"\d+|[a-z]+", folded)
104
+ release: list[int] = []
105
+ while tokens and tokens[0].isdigit():
106
+ release.append(int(tokens.pop(0)))
107
+ return release, tokens
108
+
109
+
110
+ def version_key(text: str) -> tuple[int, tuple[int, ...], int, tuple[int, ...], str]:
111
+ """A sort key for version strings, tolerant of what package ecosystems write. Numeric components compare as
112
+ numbers (`1.10` > `1.8`), a `v` prefix and trailing zeros do not matter (`v1.0` == `1.0.0`), a pre-release
113
+ (`dev` < `a`/`alpha` < `b`/`beta` < `rc`) sorts below its release and a post-release above it, an unknown
114
+ suffix (`1.2.x`) sorts with the pre-releases, and a version with no number at all (`latest`, `main`) sorts
115
+ below every numbered one: 'newest' never prefers it."""
116
+ release, rest = _version_parts(text)
117
+ numbered = bool(release) # before trimming: `0.0` has a number even though its release trims to nothing
118
+ while release and release[-1] == 0:
119
+ release.pop()
120
+ word = rest[0] if rest and not rest[0].isdigit() else ""
121
+ known = not word or word in _PRE or word in _POST or word in _FINAL
122
+ phase = 0 if not word or word in _FINAL else _PRE.get(word, _POST.get(word, -1))
123
+ numbers = tuple(int(t) for t in rest[1:] if t.isdigit()) if word else tuple(int(t) for t in rest if t.isdigit())
124
+ return (1 if numbered else 0, tuple(release), phase, numbers, "" if known and release else text.strip().lower())
125
+
126
+
127
+ def same_version(a: str, b: str) -> bool:
128
+ """The same version as written, ignoring case, a `v` prefix and build metadata. `3.1` is NOT `3.1.0` here
129
+ (they sort alike, but asking for `3.1` means 'the 3.1 line', which `version_prefix` answers)."""
130
+ return _version_parts(a) == _version_parts(b)
131
+
132
+
133
+ def version_prefix(requested: str, candidate: str) -> bool:
134
+ """Does `candidate` start with `requested`, component by component? `3.1` matches `3.1.4` and not `3.10.0`. A
135
+ requested version carrying a pre-release or other suffix has no prefix reading, only an exact one."""
136
+ wanted, suffix = _version_parts(requested)
137
+ have, _ = _version_parts(candidate)
138
+ return bool(wanted) and not suffix and have[: len(wanted)] == wanted
139
+
140
+
141
+ def home() -> Path:
142
+ """Where the library lives. An explicit setting wins; without one an existing `~/.zimlore` is found instead of
143
+ an empty library being started beside it."""
144
+ explicit = any(os.environ.get(name) for name in (HOME_ENV, "PRIMARYSOURCE_ZIM_HOME", "PRIMARYSOURCE_HOME"))
145
+ old = Path.home() / ".zimlore"
146
+ if not explicit and old.is_dir():
147
+ return old
148
+ return _homes.collection_home("zim", "zimlore").expanduser()
149
+
150
+
151
+ def zim_dirs() -> list[Path]:
152
+ override = os.environ.get(PATH_ENV) or os.environ.get(LEGACY_PATH_ENV)
153
+ if override:
154
+ return [Path(part).expanduser() for part in override.split(os.pathsep) if part]
155
+ return [home() / "zim"]
156
+
157
+
158
+ def norm(text: str) -> str:
159
+ """Lower-case words separated by single spaces: `Flask Docs`, `devdocs_en_flask` and `devdocs/flask` all
160
+ become comparable token strings."""
161
+ return " ".join(re.split(r"[^0-9a-z+#]+", text.lower())).strip()
162
+
163
+
164
+ @dataclass(frozen=True)
165
+ class Book:
166
+ name: str
167
+ title: str
168
+ date: str # ISO YYYY-MM-DD from the Date metadata, "" when the ZIM has none
169
+ language: str
170
+ creator: str
171
+ publisher: str
172
+ description: str
173
+ tags: tuple[str, ...]
174
+ license: str # the License metadata, "" when the ZIM does not state one
175
+ source: str
176
+ path: Path
177
+ uuid: str
178
+ articles: int
179
+ size: int
180
+ has_fulltext: bool
181
+ has_titles: bool
182
+ scraper: str = ""
183
+
184
+ @property
185
+ def when(self) -> dt.date | None:
186
+ return parse_date(self.date)
187
+
188
+ @cached_property
189
+ def package(self) -> purl.Purl | None:
190
+ """The package this ZIM documents, from a `purl:pkg:...` tag (set by the producer), else None. A tag that
191
+ is not a usable purl is ignored: a scan must not fail on someone else's metadata."""
192
+ for tag in self.tags:
193
+ if tag[:5].lower() == "purl:":
194
+ try:
195
+ return purl.parse(tag[5:])
196
+ except ZimloreError:
197
+ continue
198
+ return None
199
+
200
+ @cached_property
201
+ def family(self) -> str:
202
+ """What versions of one package share: the purl without its version when the ZIM carries one, else the
203
+ Name without its trailing version token (`golang_github.com_spf13_cobra_v1.8.1` ->
204
+ `golang_github.com_spf13_cobra`). A Name with no version token is its own family."""
205
+ return self.package.text if self.package else split_name(self.name)[0]
206
+
207
+ @cached_property
208
+ def version(self) -> str | None:
209
+ """The upstream version this ZIM documents, from the purl tag or the Name's version token; None when the
210
+ ZIM does not say (a DevDocs ZIM records a date, not a release)."""
211
+ return (self.package.version if self.package else None) or split_name(self.name)[1]
212
+
213
+ def age_days(self, today: dt.date | None = None) -> int | None:
214
+ when = self.when
215
+ if when is None:
216
+ return None
217
+ return ((today or datetime.now(UTC).date()) - when).days
218
+
219
+ def freshness(self, today: dt.date | None = None) -> dict[str, object]:
220
+ """Staleness as data: the date, the age, and whether it passes STALE_DAYS. `unknown` when the ZIM
221
+ carries no usable Date, which is not the same as fresh."""
222
+ age = self.age_days(today)
223
+ return {
224
+ "date": self.date or None,
225
+ "age_days": age,
226
+ "stale": None if age is None else age > STALE_DAYS,
227
+ "stale_after_days": STALE_DAYS,
228
+ }
229
+
230
+ def aliases(self, user: dict[str, str] | None = None) -> set[str]:
231
+ found = name_aliases(self.name, self.title, self.version)
232
+ if self.package:
233
+ found.update(
234
+ {norm(self.package.name), norm(self.package.words), norm(f"{self.package.type} {self.package.name}")}
235
+ )
236
+ if user:
237
+ found.update(norm(alias) for alias, target in user.items() if target in {self.name, self.title})
238
+ found.discard("")
239
+ return found
240
+
241
+
242
+ def name_aliases(name: str, title: str, version: str | None = None) -> set[str]:
243
+ """The comparable spellings of one library from its `Name` and `Title`: shared by installed books and by
244
+ catalogue snapshot entries, so a query ranks the same way against both.
245
+
246
+ A versioned library also gets its version-free spellings. Without them `cobra` scores `v1.8.1` above `v1.10.2`
247
+ only because that alias is a character shorter: the version is not part of what a name query asks about."""
248
+ found = {norm(name), norm(_DATE_SUFFIX.sub("", name)), norm(title)}
249
+ normal_title = norm(title)
250
+ if version is None:
251
+ version = split_name(name)[1]
252
+ if version:
253
+ found.add(norm(split_name(name)[0]))
254
+ wanted = {norm(version), norm(version.lstrip("vV"))} - {""}
255
+ for spelling in wanted:
256
+ normal_title = f" {normal_title} ".replace(f" {spelling} ", " ").strip()
257
+ normal_title = " ".join(normal_title.split())
258
+ found.add(normal_title)
259
+ for suffix in (" docs", " documentation", " reference", " manual"):
260
+ if normal_title.endswith(suffix):
261
+ found.add(normal_title[: -len(suffix)])
262
+ parts = (split_name(name)[0] if version else _DATE_SUFFIX.sub("", name)).split("_")
263
+ if parts[0] == "devdocs" and len(parts) >= 3:
264
+ slug = "_".join(parts[2:])
265
+ found.update({norm(slug), norm("devdocs " + slug)})
266
+ host = parts[0]
267
+ if "." in host: # stackoverflow.com_en_all, security.stackexchange.com_en_all, docs.python.org_en_all
268
+ found.add(norm(host))
269
+ first = host.split(".")[0]
270
+ if host.endswith("stackexchange.com") or host.endswith("stackoverflow.com"):
271
+ found.add(norm(first))
272
+ found.discard("")
273
+ return found
274
+
275
+
276
+ def score_aliases(wanted: str, aliases: set[str]) -> tuple[float, str]:
277
+ """Best score of a normalised query against a set of aliases: exact beats all-words beats prefix beats
278
+ substring beats fuzzy. `(0.0, "")` means no match. Shared by `rank` and the snapshot's `available`."""
279
+ words = set(wanted.split())
280
+ best, why = 0.0, ""
281
+ for alias in aliases:
282
+ if alias == wanted:
283
+ score, reason = 100.0, f"exact match on '{alias}'"
284
+ elif words and words <= set(alias.split()):
285
+ score, reason = 80.0 - 0.1 * len(alias), f"every word appears in '{alias}'"
286
+ elif alias.startswith(wanted):
287
+ score, reason = 70.0 - 0.1 * len(alias), f"'{alias}' starts with the query"
288
+ elif wanted in alias:
289
+ score, reason = 60.0 - 0.1 * len(alias), f"'{alias}' contains the query"
290
+ else:
291
+ ratio = difflib.SequenceMatcher(None, wanted, alias).ratio()
292
+ score, reason = (ratio * 50.0, f"close to '{alias}' ({ratio:.2f})") if ratio >= 0.8 else (0.0, "")
293
+ if score > best:
294
+ best, why = score, reason
295
+ return best, why
296
+
297
+
298
+ @dataclass
299
+ class Scan:
300
+ books: list[Book] = field(default_factory=list)
301
+ problems: list[dict[str, str]] = field(default_factory=list)
302
+ directories: list[Path] = field(default_factory=list)
303
+
304
+
305
+ _ARCHIVES: dict[tuple[str, int, int], Any] = {}
306
+ _BOOKS: dict[tuple[str, int, int], Book] = {}
307
+
308
+
309
+ def close_all() -> None:
310
+ """Drop every cached archive handle. Windows keeps a ZIM file locked while a handle is open, so anything
311
+ that deletes or replaces a ZIM (the test suite, `primarysource zim get --replace`) calls this first."""
312
+ _ARCHIVES.clear()
313
+ _BOOKS.clear()
314
+
315
+
316
+ def close_path(path: Path) -> None:
317
+ """Drop every cached handle for ONE file (under any recorded mtime and size), leaving the others open. Deleting
318
+ or replacing a ZIM calls this: on Windows an `Archive` blocks the delete until its last reference is gone."""
319
+ for cache in (_ARCHIVES, _BOOKS):
320
+ for key in [k for k in cache if k[0] == str(path)]:
321
+ del cache[key]
322
+
323
+
324
+ def open_archive(path: Path) -> Any:
325
+ stat = path.stat()
326
+ key = (str(path), stat.st_mtime_ns, stat.st_size)
327
+ if key not in _ARCHIVES:
328
+ for stale in [k for k in _ARCHIVES if k[0] == key[0]]:
329
+ del _ARCHIVES[stale] # a replaced file: the old handle would keep the old inode open for the process
330
+ for stale in [k for k in _BOOKS if k[0] == key[0] and k != key]:
331
+ del _BOOKS[stale]
332
+ reader = _libzim.load("reader")
333
+ try:
334
+ _ARCHIVES[key] = reader.Archive(str(path))
335
+ except (RuntimeError, OSError) as error:
336
+ raise ZimloreError(
337
+ f"{path} is not a readable ZIM file ({error})", "re-download it: primarysource zim get"
338
+ ) from error
339
+ return _ARCHIVES[key]
340
+
341
+
342
+ def _meta(archive: Any, key: str) -> str:
343
+ try:
344
+ raw = archive.get_metadata(key)
345
+ except (KeyError, RuntimeError):
346
+ return ""
347
+ return bytes(raw).decode("utf-8", errors="replace").strip()
348
+
349
+
350
+ def read_book(path: Path) -> Book:
351
+ stat = path.stat()
352
+ key = (str(path), stat.st_mtime_ns, stat.st_size)
353
+ if key in _BOOKS:
354
+ return _BOOKS[key]
355
+ archive = open_archive(path)
356
+ tags = tuple(tag for tag in _meta(archive, "Tags").split(";") if tag and not tag.startswith("_"))
357
+ book = Book(
358
+ name=_meta(archive, "Name") or path.stem,
359
+ title=_meta(archive, "Title") or path.stem,
360
+ date=_meta(archive, "Date"),
361
+ language=_meta(archive, "Language"),
362
+ creator=_meta(archive, "Creator"),
363
+ publisher=_meta(archive, "Publisher"),
364
+ description=_meta(archive, "Description"),
365
+ tags=tags,
366
+ license=_meta(archive, "License"),
367
+ source=_meta(archive, "Source"),
368
+ path=path,
369
+ uuid=str(archive.uuid),
370
+ articles=int(archive.article_count),
371
+ size=stat.st_size,
372
+ has_fulltext=bool(archive.has_fulltext_index),
373
+ has_titles=bool(archive.has_title_index),
374
+ scraper=_meta(archive, "Scraper"),
375
+ )
376
+ _BOOKS[key] = book
377
+ return book
378
+
379
+
380
+ def scan(directories: list[Path] | None = None) -> Scan:
381
+ """Read every `*.zim` under the library directories. A file that is not a readable ZIM is reported in
382
+ `problems`, never dropped: the books that are missing are exactly the ones a user wonders about."""
383
+ result = Scan(directories=directories if directories is not None else zim_dirs())
384
+ for directory in result.directories:
385
+ if not directory.is_dir():
386
+ continue
387
+ for path in sorted(directory.glob("*.zim")):
388
+ try:
389
+ result.books.append(read_book(path))
390
+ except ZimloreError as error:
391
+ result.problems.append({"path": str(path), "error": error.message})
392
+ result.books.sort(key=lambda b: (b.name, date_key(b.date), str(b.path)))
393
+ return result
394
+
395
+
396
+ def user_aliases() -> dict[str, str]:
397
+ """`$ZIMLORE_HOME/aliases.json`: {"py": "devdocs_en_python"}. A malformed file is an error, not an empty map."""
398
+ path = home() / "aliases.json"
399
+ if not path.is_file():
400
+ return {}
401
+ try:
402
+ loaded = json.loads(path.read_text(encoding="utf-8"))
403
+ except (OSError, ValueError) as error:
404
+ raise ZimloreError(f"{path} is not valid JSON ({error})", f"fix or delete {path}") from error
405
+ if not isinstance(loaded, dict) or not all(isinstance(v, str) for v in loaded.values()):
406
+ raise ZimloreError(f"{path} must map alias to library name", f"fix or delete {path}")
407
+ return {str(k): v for k, v in loaded.items()}
408
+
409
+
410
+ @dataclass(frozen=True)
411
+ class Candidate:
412
+ book: Book
413
+ score: float
414
+ why: str
415
+ members: tuple[Book, ...] = () # every installed book of the family that matched; `book` is the newest
416
+
417
+
418
+ def empty_library_error(scanned: Scan) -> ZimloreError:
419
+ where = ", ".join(str(d) for d in scanned.directories)
420
+ unreadable = ""
421
+ if scanned.problems:
422
+ unreadable = (
423
+ f"; {len(scanned.problems)} file(s) there could not be read, first: {scanned.problems[0]['error']}"
424
+ )
425
+ # Files that cannot be opened because the binding is missing are not an empty library: install it first.
426
+ fix = (
427
+ "download one: primarysource zim catalog flask ; primarysource zim get devdocs_en_flask "
428
+ "(or copy a .zim into that directory)"
429
+ if not scanned.problems or _libzim.available()
430
+ else _libzim.REMEDY
431
+ )
432
+ return ZimloreError(
433
+ f"no ZIM files found in {where}{unreadable}",
434
+ fix,
435
+ searched=[str(d) for d in scanned.directories],
436
+ problems=scanned.problems,
437
+ )
438
+
439
+
440
+ def newest_version(books: list[Book]) -> Book:
441
+ """The book with the newest version (a book with no version loses to any that has one), then the newest
442
+ edition date, then the file name so that the answer never depends on directory order."""
443
+ return max(
444
+ sorted(books, key=lambda b: str(b.path)),
445
+ key=lambda b: (version_key(b.version) if b.version else (-1, (), 0, (), ""), date_key(b.date)),
446
+ )
447
+
448
+
449
+ def rank(query: str, books: list[Book], user: dict[str, str] | None = None) -> list[Candidate]:
450
+ """Score each FAMILY of books against a name, best first: exact alias beats all-words beats prefix beats
451
+ substring beats fuzzy. Every version and edition of one family scores alike (the best of them), so a tie can
452
+ only be between different libraries; the candidate's `book` is the family's newest version, newest edition,
453
+ and `members` is everything installed in the family.
454
+
455
+ This is where `cobra` once resolved to v1.8.1 over v1.10.2: each version was scored on its own alias, the
456
+ scores differed by 0.1 per character of alias length, and the ambiguity guard compared only the top two."""
457
+ wanted = norm(query)
458
+ if not wanted:
459
+ return []
460
+ families: dict[str, list[tuple[Book, float, str]]] = {}
461
+ for book in books:
462
+ best, why = score_aliases(wanted, book.aliases(user))
463
+ if best:
464
+ families.setdefault(book.family, []).append((book, best, why))
465
+ out: list[Candidate] = []
466
+ for members in families.values():
467
+ _, score, why = max(members, key=lambda m: m[1])
468
+ matched = [m[0] for m in members]
469
+ out.append(Candidate(newest_version(matched), score, why, tuple(matched)))
470
+ return sorted(out, key=lambda c: (-c.score, c.book.family))
471
+
472
+
473
+ def editions(book: Book, books: list[Book]) -> list[Book]:
474
+ """Every installed file of `book`'s library, newest first by PARSED date (undated last, ties by path)."""
475
+ mine = sorted((b for b in books if b.name == book.name), key=lambda b: str(b.path))
476
+ return sorted(mine, key=lambda b: date_key(b.date), reverse=True)
477
+
478
+
479
+ def family_of(book: Book, books: list[Book]) -> list[Book]:
480
+ """Every installed book in `book`'s family: all its versions and all their editions."""
481
+ return [b for b in books if b.family == book.family]
482
+
483
+
484
+ def versions_of(book: Book, books: list[Book]) -> list[str]:
485
+ """The distinct upstream versions installed in `book`'s family, newest first; versions the ZIMs do not state
486
+ are left out (a family of ZIMs that state none has none to list)."""
487
+ found = {b.version for b in books if b.family == book.family and b.version}
488
+ return sorted(found, key=version_key, reverse=True)
489
+
490
+
491
+ def newest(books: list[Book]) -> Book | None:
492
+ """The newest of `books` by parsed date; None for an empty list."""
493
+ return max(sorted(books, key=lambda b: str(b.path)), key=lambda b: date_key(b.date), default=None)
494
+
495
+
496
+ @dataclass(frozen=True)
497
+ class Selection:
498
+ """What a library query selected. `kind` is `newest` (a family named, no version asked), `exact` or `prefix`
499
+ (the version asked for) or `name` (a whole `Name` was given, which is already one version)."""
500
+
501
+ book: Book
502
+ why: str
503
+ kind: str
504
+ requested: str | None = None
505
+ members: tuple[Book, ...] = ()
506
+
507
+ @property
508
+ def versions(self) -> list[str]:
509
+ return versions_of(self.book, list(self.members))
510
+
511
+ @property
512
+ def other_versions(self) -> list[str]:
513
+ return [v for v in self.versions if v != self.book.version]
514
+
515
+
516
+ class Query(NamedTuple):
517
+ package: purl.Purl | None
518
+ text: str # the name part: the whole query, or what is left of it after the version is split off
519
+ version: str | None
520
+
521
+
522
+ _VERSION_TAIL = re.compile(r"^[vV]?\d+(?:\.[0-9A-Za-z]+)*(?:-[0-9A-Za-z.]+)?$")
523
+
524
+
525
+ def split_query(text: str) -> tuple[str, str] | None:
526
+ """`cryptography==50.0.2`, `cryptography@50`, `cobra v1.8.1`, `flask 3.1` as (name, version); None when the
527
+ query carries no version. An `@` at the very start is an npm scope, not a version separator."""
528
+ cleaned = text.strip()
529
+ for separator in ("==", "@"):
530
+ if separator in cleaned[1:]:
531
+ name, _, version = cleaned.rpartition(separator)
532
+ if name.strip() and version.strip() and " " not in version.strip():
533
+ return name.strip(), version.strip()
534
+ parts = cleaned.rsplit(None, 1)
535
+ if len(parts) == 2 and _VERSION_TAIL.match(parts[1]):
536
+ return parts[0].strip(), parts[1]
537
+ return None
538
+
539
+
540
+ def parse_query(text: str) -> list[Query]:
541
+ """The readings of a library query, most specific first. A purl is one reading; anything else is a name with a
542
+ version split off (when it has one) and then the whole text as a name."""
543
+ if purl.is_purl(text):
544
+ parsed = purl.parse(text)
545
+ return [Query(parsed, parsed.words, parsed.version)]
546
+ readings: list[Query] = []
547
+ split = split_query(text)
548
+ if split:
549
+ readings.append(Query(None, split[0], split[1]))
550
+ readings.append(Query(None, text.strip(), None))
551
+ return readings
552
+
553
+
554
+ def _version_error(family: str, requested: str, members: list[Book]) -> ZimloreError:
555
+ """The requested version is not installed. No build command is named: none exists."""
556
+ installed = versions_of(members[0], members)
557
+ stated = (
558
+ f"installed versions: {', '.join(installed)}" if installed else "none of the installed ZIMs states a version"
559
+ )
560
+ return LibraryNotFound(
561
+ f"{family} has no installed version matching '{requested}' ({stated})",
562
+ "ask for one of the installed versions, or omit the version to get the newest; "
563
+ "primarysource zim list shows them"
564
+ if installed
565
+ else "this library records its edition date, not a release: omit the version",
566
+ tool_hint="omit the version to get the newest, or ask for an installed one: zim_libraries lists them",
567
+ requested_version=requested,
568
+ installed_versions=installed,
569
+ installed=sorted({b.name for b in members}),
570
+ )
571
+
572
+
573
+ def _pick_version(members: list[Book], requested: str | None) -> tuple[Book, str] | None:
574
+ """The book of `members` for a requested version and how it matched (`exact`, `prefix`, `newest`); None when
575
+ nothing installed matches the version."""
576
+ if requested is None:
577
+ return newest_version(members), "newest"
578
+ exact = [b for b in members if b.version and same_version(b.version, requested)]
579
+ if exact:
580
+ return newest_version(exact), "exact"
581
+ near = [b for b in members if b.version and version_prefix(requested, b.version)]
582
+ if near:
583
+ return newest_version(near), "prefix"
584
+ return None
585
+
586
+
587
+ def _tied(query: str, tied: list[Candidate]) -> ZimloreError:
588
+ return ZimloreError(
589
+ f"'{query}' matches {len(tied)} libraries equally well; name one of them",
590
+ "use the library name exactly as listed",
591
+ candidates=[{"name": c.book.name, "title": c.book.title, "why": c.why} for c in tied],
592
+ )
593
+
594
+
595
+ def select(query: str, scanned: Scan | None = None) -> Selection:
596
+ """The one book a library query means, or a `ZimloreError` that lists the candidates or the installed versions.
597
+
598
+ The query may be a name or alias (`cobra`: the newest installed version), a name with a version (`cobra
599
+ v1.8.1`, `flask 3.1`, `cryptography==50.0.2`, `cryptography@50`: that version, or the newest that starts
600
+ with it), a whole `Name` (that very ZIM) or a purl (`pkg:pypi/flask@3.1.0`, matched on the ZIM's `purl:` tag).
601
+ Never guesses between two different libraries that match equally well; the versions of one library are
602
+ ordered, not tied."""
603
+ scanned = scanned or scan()
604
+ if not scanned.books:
605
+ raise empty_library_error(scanned)
606
+ books = scanned.books
607
+ user = user_aliases()
608
+ readings = parse_query(query)
609
+ wanted = norm(query)
610
+ pinned = {target for alias, target in user.items() if norm(alias) == wanted}
611
+ named = [] if readings[0].package else [b for b in books if norm(b.name) == wanted or b.name in pinned]
612
+ if named:
613
+ families = sorted({b.family for b in named})
614
+ if len(families) > 1:
615
+ raise _tied(
616
+ query,
617
+ [
618
+ Candidate(newest_version([b for b in named if b.family == f]), 100.0, "exact Name")
619
+ for f in families
620
+ ],
621
+ )
622
+ return Selection(
623
+ newest_version(named),
624
+ f"exact match on '{named[0].name}'",
625
+ "name",
626
+ None,
627
+ tuple(family_of(named[0], books)),
628
+ )
629
+ unstated: ZimloreError | None = None
630
+ for reading in readings:
631
+ if reading.package:
632
+ tagged = [b for b in books if b.package and b.package.key == reading.package.key]
633
+ if tagged:
634
+ members = family_of(tagged[0], books)
635
+ chosen = _pick_version(members, reading.version)
636
+ if chosen is None:
637
+ raise _version_error(tagged[0].family, reading.version or "", members)
638
+ return Selection(
639
+ chosen[0], f"purl tag {reading.package.text}", chosen[1], reading.version, tuple(members)
640
+ )
641
+ ranked = rank(reading.text, books, user)
642
+ if not ranked:
643
+ continue
644
+ top = ranked[0]
645
+ tied = [c for c in ranked if abs(c.score - top.score) < 1e-9]
646
+ if len(tied) > 1:
647
+ raise _tied(query, tied)
648
+ chosen = _pick_version(list(top.members), reading.version)
649
+ if chosen is None:
650
+ if reading.version and not versions_of(top.book, list(top.members)) and len(readings) > 1:
651
+ # nothing installed states a version: `python 3` may be a name, so read the whole text, and
652
+ # report the missing version if that finds nothing either
653
+ unstated = _version_error(top.book.family, reading.version, list(top.members))
654
+ continue
655
+ raise _version_error(top.book.family, reading.version or "", list(top.members))
656
+ return Selection(chosen[0], top.why, chosen[1], reading.version, top.members)
657
+ if unstated:
658
+ raise unstated
659
+ raise LibraryNotFound(
660
+ f"no installed library matches '{query}'",
661
+ "primarysource zim list shows what is installed; primarysource zim catalog <name> finds one to download",
662
+ installed=[b.name for b in books],
663
+ )
664
+
665
+
666
+ def find(query: str, scanned: Scan | None = None) -> Book:
667
+ """The one book a name means (see `select`)."""
668
+ return select(query, scanned).book