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,197 @@
1
+ """The offline catalogue snapshot: what could be installed, answerable with no network.
2
+
3
+ `refresh()` is the only function here that touches the network (through `catalog`'s own `_get`, so the https-only
4
+ rule and the size limit apply). It pages through the English OPDS feed and writes `$ZIMLORE_HOME/catalog.json`
5
+ atomically. Everything else reads that file. A missing snapshot is an error naming
6
+ `primarysource zim catalog --refresh`; a
7
+ corrupt or wrong-schema one is also an error, never an empty snapshot, because "the catalogue has nothing called
8
+ flask" and "the snapshot could not be read" send an agent to opposite conclusions.
9
+
10
+ Facts measured against the live feed on 2026-11-07: `count=5000&lang=eng` returned all 1,309 entries in one 1.5 MB
11
+ document, but `count` is honoured as a page size and `start` as an offset (`itemsPerPage` and `startIndex` echo
12
+ them), so `refresh` pages in 500s rather than relying on one reply that a server may cap.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ import os
19
+ import re
20
+ import urllib.parse
21
+ from datetime import UTC, datetime
22
+ from pathlib import Path
23
+ from typing import Any
24
+
25
+ from . import catalog, library
26
+ from .errors import ZimloreError
27
+
28
+ SCHEMA = 1
29
+ FILE_NAME = "catalog.json"
30
+ PAGE_SIZE = 500
31
+ MAX_PAGES = 100
32
+ HINT = "primarysource zim catalog --refresh"
33
+ _SAFE_NAME = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]*")
34
+ _FIELDS = ("name", "title", "date", "language", "summary", "creator", "articles", "approx_bytes", "url", "tags")
35
+
36
+
37
+ def path() -> Path:
38
+ return library.home() / FILE_NAME
39
+
40
+
41
+ def _stamp(when: datetime) -> str:
42
+ return when.astimezone(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
43
+
44
+
45
+ def _entry_dict(entry: catalog.CatalogEntry) -> dict[str, Any]:
46
+ return {
47
+ "name": entry.name,
48
+ "title": entry.title,
49
+ "date": entry.date,
50
+ "language": entry.language,
51
+ "summary": entry.summary,
52
+ "creator": entry.creator,
53
+ "articles": entry.articles,
54
+ "approx_bytes": entry.approx_bytes,
55
+ "url": entry.url,
56
+ "tags": list(entry.tags),
57
+ }
58
+
59
+
60
+ def refresh(*, language: str = "eng", base: str = "", opener: catalog.Opener | None = None) -> dict[str, Any]:
61
+ """Fetch the whole feed for `language` and replace the snapshot. Network. The old snapshot is untouched
62
+ unless every page was read and at least one entry came back."""
63
+ base = base or catalog.CATALOG_URL
64
+ entries: list[catalog.CatalogEntry] = []
65
+ total = 0
66
+ start = 0
67
+ pages = 0
68
+ while pages < MAX_PAGES:
69
+ params = {"count": str(PAGE_SIZE), "start": str(start)}
70
+ if language:
71
+ params["lang"] = language
72
+ page, total = catalog.parse_feed(catalog._get(f"{base}?{urllib.parse.urlencode(params)}", opener))
73
+ pages += 1
74
+ entries.extend(e for e in page if _SAFE_NAME.fullmatch(e.name))
75
+ start += PAGE_SIZE
76
+ if start >= total:
77
+ break
78
+ else:
79
+ raise ZimloreError(f"the catalogue still had entries after {MAX_PAGES} pages; not writing a snapshot")
80
+ if not entries:
81
+ raise ZimloreError(
82
+ "the catalogue returned no ZIM entries; the existing snapshot (if any) was kept",
83
+ "try again later, or check the language code",
84
+ )
85
+ fetched_at = _stamp(datetime.now(UTC))
86
+ document = {
87
+ "schema": SCHEMA,
88
+ "fetched_at": fetched_at,
89
+ "language": language,
90
+ "total_results": total,
91
+ "entries": [_entry_dict(e) for e in entries],
92
+ }
93
+ target = path()
94
+ target.parent.mkdir(parents=True, exist_ok=True)
95
+ temporary = target.with_name(target.name + ".tmp")
96
+ temporary.write_text(json.dumps(document, ensure_ascii=False, separators=(",", ":")), encoding="utf-8")
97
+ os.replace(temporary, target)
98
+ return {
99
+ "path": str(target),
100
+ "entries": len(entries),
101
+ "total_results": total,
102
+ "pages": pages,
103
+ "fetched_at": fetched_at,
104
+ "bytes": target.stat().st_size,
105
+ }
106
+
107
+
108
+ def _missing() -> ZimloreError:
109
+ return ZimloreError(f"no catalogue snapshot at {path()}", HINT)
110
+
111
+
112
+ def _corrupt(why: str) -> ZimloreError:
113
+ return ZimloreError(f"the catalogue snapshot {path()} cannot be used ({why})", HINT)
114
+
115
+
116
+ def _entry_from(raw: object) -> catalog.CatalogEntry:
117
+ if not isinstance(raw, dict) or any(key not in raw for key in _FIELDS):
118
+ raise ValueError("an entry lacks required fields")
119
+ strings = ("name", "title", "date", "language", "summary", "creator", "url")
120
+ ints = ("articles", "approx_bytes")
121
+ if not all(isinstance(raw[k], str) for k in strings) or not all(
122
+ isinstance(raw[k], int) and not isinstance(raw[k], bool) for k in ints
123
+ ):
124
+ raise ValueError("an entry has a field of the wrong type")
125
+ tags = raw["tags"]
126
+ if not isinstance(tags, list) or not all(isinstance(t, str) for t in tags):
127
+ raise ValueError("an entry has malformed tags")
128
+ if not _SAFE_NAME.fullmatch(raw["name"]):
129
+ raise ValueError(f"an entry has an unusable name {raw['name']!r}")
130
+ return catalog.CatalogEntry(**{**{k: raw[k] for k in _FIELDS}, "tags": tuple(tags)})
131
+
132
+
133
+ def load(today: datetime | None = None) -> tuple[list[catalog.CatalogEntry], str, int]:
134
+ """(entries, fetched_at, age_days). Offline. Raises `ZimloreError` (hint: `primarysource zim catalog
135
+ --refresh`) when
136
+ there is no snapshot or it cannot be used."""
137
+ target = path()
138
+ if not target.is_file():
139
+ raise _missing()
140
+ try:
141
+ raw = json.loads(target.read_text(encoding="utf-8"))
142
+ except (OSError, ValueError) as error:
143
+ raise _corrupt(str(error)) from error
144
+ if not isinstance(raw, dict) or raw.get("schema") != SCHEMA:
145
+ raise _corrupt(
146
+ f"schema {raw.get('schema') if isinstance(raw, dict) else type(raw).__name__}, expected {SCHEMA}"
147
+ )
148
+ listed = raw.get("entries")
149
+ fetched = raw.get("fetched_at")
150
+ if not isinstance(listed, list) or not isinstance(fetched, str):
151
+ raise _corrupt("no entries list or no fetched_at")
152
+ try:
153
+ when = datetime.strptime(fetched, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=UTC)
154
+ entries = [_entry_from(item) for item in listed]
155
+ except ValueError as error:
156
+ raise _corrupt(str(error)) from error
157
+ age = max(0, ((today or datetime.now(UTC)) - when).days)
158
+ return entries, fetched, age
159
+
160
+
161
+ def available(
162
+ query: str, limit: int = 8, installed: dict[str, str] | None = None, today: datetime | None = None
163
+ ) -> dict[str, Any]:
164
+ """Snapshot entries ranked against `query` with the same ranking as `library.rank` (exact beats all-words
165
+ beats prefix beats substring beats fuzzy), newest edition of each name. Offline."""
166
+ wanted = library.norm(query)
167
+ if not wanted:
168
+ raise ZimloreError("empty query", "give a few words, e.g. primarysource zim available flask")
169
+ entries, fetched_at, age = load(today)
170
+ have = installed or {}
171
+ scored: list[tuple[float, str, catalog.CatalogEntry, str]] = []
172
+ for entry in catalog.latest(entries):
173
+ score, why = library.score_aliases(wanted, library.name_aliases(entry.name, entry.title))
174
+ if score:
175
+ scored.append((score, entry.name, entry, why))
176
+ scored.sort(key=lambda item: (-item[0], item[1]))
177
+ rows = [
178
+ {
179
+ "name": entry.name,
180
+ "title": entry.title,
181
+ "date": entry.date,
182
+ "approx_mb": round(entry.approx_bytes / 1e6, 1),
183
+ "articles": entry.articles,
184
+ "install": f"primarysource zim get {entry.name}",
185
+ "installed": entry.name in have,
186
+ "installed_edition": have.get(entry.name),
187
+ "match": why,
188
+ "snapshot_age_days": age,
189
+ }
190
+ for _, _, entry, why in scored[: max(1, limit)]
191
+ ]
192
+ return {
193
+ "query": query,
194
+ "available": rows,
195
+ "matches": len(scored),
196
+ "snapshot": {"fetched_at": fetched_at, "age_days": age, "entries": len(entries), "offline": True},
197
+ }
@@ -0,0 +1,269 @@
1
+ """Make the installed library match a manifest: plan first, then (unless a dry run) download what is missing.
2
+
3
+ A manifest is TOML and names catalogue entries only:
4
+
5
+ libraries = ["devdocs_en_python", "devdocs_en_rust"]
6
+ groups = ["web-testing"] # named sets from zimlore.groups
7
+ max_age_days = 365 # flag installed editions older than this
8
+ budget = "2GB" # the library may not grow past this on disk
9
+
10
+ It may be committed to a project: it can name nothing that executes, because every entry is validated as a bare
11
+ catalogue name (no scheme, no slash, no colon) and unknown keys are errors. All network goes through `catalog.py`.
12
+ Nothing here deletes: an installed library the manifest does not name is reported as an extra and left alone, and
13
+ an update is a new dated file beside the old edition, exactly as `primarysource zim get` stages it.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import os
19
+ import re
20
+ import tomllib
21
+ from collections.abc import Callable
22
+ from dataclasses import dataclass, field
23
+ from datetime import UTC, date, datetime
24
+ from pathlib import Path
25
+ from typing import Any
26
+
27
+ from . import catalog, groups, library
28
+ from .errors import ZimloreError
29
+
30
+ MANIFEST_NAME = ".zimlore.toml"
31
+ HOME_MANIFEST = "libraries.toml"
32
+ KEYS = {"libraries", "groups", "max_age_days", "budget"}
33
+ NAME_RE = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]{0,127}")
34
+ UNITS = {"B": 1, "KB": 10**3, "MB": 10**6, "GB": 10**9, "TB": 10**12}
35
+
36
+
37
+ @dataclass(frozen=True)
38
+ class Manifest:
39
+ path: Path | None = None
40
+ libraries: tuple[str, ...] = ()
41
+ groups: tuple[str, ...] = ()
42
+ max_age_days: int | None = None
43
+ budget: int | None = None
44
+
45
+
46
+ def check_name(name: object, where: str) -> str:
47
+ """A catalogue name and nothing else. `https://x/y.zim`, `../a`, `C:\\a` and `a.zim` are all refused."""
48
+ if not isinstance(name, str) or not NAME_RE.fullmatch(name) or name.endswith(".zim"):
49
+ raise ZimloreError(
50
+ f"{where}: {name!r} is not a catalogue name",
51
+ "a manifest names catalogue entries (primarysource zim catalog <words> finds them), never URLs or paths",
52
+ )
53
+ return name
54
+
55
+
56
+ def parse_budget(value: object) -> int:
57
+ """`"2GB"`, `"500 MB"`, `"1.5GB"` (decimal units, as the CLI prints sizes) or an integer number of bytes."""
58
+ if isinstance(value, bool):
59
+ raise ZimloreError(f"budget {value!r} is not a size", 'write it as "2GB" or a number of bytes')
60
+ if isinstance(value, int) and value > 0:
61
+ return value
62
+ match = (
63
+ re.fullmatch(r"\s*(\d+(?:\.\d+)?)\s*([KMGT]?B)?\s*", value, re.IGNORECASE) if isinstance(value, str) else None
64
+ )
65
+ if not match or float(match.group(1)) <= 0:
66
+ raise ZimloreError(f"budget {value!r} is not a size", 'write it as "2GB", "500MB" or a number of bytes')
67
+ return int(float(match.group(1)) * UNITS[(match.group(2) or "B").upper()])
68
+
69
+
70
+ def find_manifest(explicit: str = "", cwd: Path | None = None) -> Path | None:
71
+ """--manifest, else `./.zimlore.toml`, else `$ZIMLORE_HOME/libraries.toml`; None when there is none. An
72
+ explicit path that does not exist is an error rather than a silent fall-through to another manifest."""
73
+ if explicit:
74
+ path = Path(explicit).expanduser()
75
+ if not path.is_file():
76
+ raise ZimloreError(f"manifest {path} does not exist", "check the --manifest path")
77
+ return path
78
+ for candidate in ((cwd or Path.cwd()) / MANIFEST_NAME, library.home() / HOME_MANIFEST):
79
+ if candidate.is_file():
80
+ return candidate
81
+ return None
82
+
83
+
84
+ def load_manifest(path: Path) -> Manifest:
85
+ try:
86
+ raw = tomllib.loads(path.read_text(encoding="utf-8"))
87
+ except (OSError, tomllib.TOMLDecodeError) as error:
88
+ raise ZimloreError(f"{path} is not a readable TOML manifest ({error})", f"fix or delete {path}") from error
89
+ unknown = sorted(set(raw) - KEYS)
90
+ if unknown:
91
+ raise ZimloreError(f"{path}: unknown key(s) {unknown}", f"a manifest has only {sorted(KEYS)}")
92
+ libs, named = raw.get("libraries", []), raw.get("groups", [])
93
+ if not isinstance(libs, list) or not isinstance(named, list):
94
+ raise ZimloreError(f"{path}: libraries and groups must be lists of names")
95
+ known = sorted(groups.GROUPS)
96
+ for group in named:
97
+ if group not in groups.GROUPS:
98
+ raise ZimloreError(f"{path}: unknown group {group!r}", f"the groups are {known}")
99
+ age = raw.get("max_age_days")
100
+ if age is not None and (isinstance(age, bool) or not isinstance(age, int) or age < 1):
101
+ raise ZimloreError(f"{path}: max_age_days must be a positive whole number")
102
+ budget = parse_budget(raw["budget"]) if "budget" in raw else None
103
+ return Manifest(
104
+ path,
105
+ tuple(check_name(name, f"{path} libraries") for name in libs),
106
+ tuple(named),
107
+ age,
108
+ budget,
109
+ )
110
+
111
+
112
+ @dataclass
113
+ class Item:
114
+ name: str
115
+ action: str # install | update | current | outdated | unlisted | blocked | unavailable
116
+ installed: str | None = None
117
+ available: str | None = None
118
+ approx_bytes: int = 0
119
+ over_max_age: bool = False
120
+ note: str = ""
121
+ file: str = ""
122
+ entry: catalog.CatalogEntry | None = field(default=None, repr=False)
123
+
124
+
125
+ @dataclass
126
+ class Plan:
127
+ manifest: Path | None
128
+ items: list[Item]
129
+ extras: list[dict[str, Any]]
130
+ download_bytes: int
131
+ installed_bytes: int
132
+ budget: int | None
133
+ max_age_days: int | None
134
+
135
+ @property
136
+ def over_budget(self) -> bool:
137
+ return self.budget is not None and self.installed_bytes + self.download_bytes > self.budget
138
+
139
+ @property
140
+ def to_fetch(self) -> list[Item]:
141
+ return [i for i in self.items if i.action in {"install", "update"}]
142
+
143
+ @property
144
+ def problems(self) -> list[Item]:
145
+ return [i for i in self.items if i.action in {"blocked", "unavailable"}]
146
+
147
+ def as_dict(self) -> dict[str, Any]:
148
+ return {
149
+ "manifest": str(self.manifest) if self.manifest else None,
150
+ "items": [{k: v for k, v in i.__dict__.items() if k != "entry"} for i in self.items],
151
+ "extras": self.extras,
152
+ "download_bytes_approx": self.download_bytes,
153
+ "download_note": "approximate: the catalogue's rounded lengths; the .meta4 has the exact size",
154
+ "installed_bytes": self.installed_bytes,
155
+ "budget": self.budget,
156
+ "over_budget": self.over_budget,
157
+ "max_age_days": self.max_age_days,
158
+ }
159
+
160
+
161
+ def wanted_names(libraries: tuple[str, ...], named_groups: tuple[str, ...]) -> list[str]:
162
+ """Manifest libraries first, then each group's members, each name once."""
163
+ names: dict[str, None] = dict.fromkeys(libraries)
164
+ for group in named_groups:
165
+ if group not in groups.GROUPS:
166
+ raise ZimloreError(f"unknown group {group!r}", f"the groups are {sorted(groups.GROUPS)}")
167
+ names.update(dict.fromkeys(groups.GROUPS[group]))
168
+ return list(names)
169
+
170
+
171
+ def make_plan(
172
+ manifest: Manifest,
173
+ extra_groups: tuple[str, ...] = (),
174
+ *,
175
+ update: bool = False,
176
+ today: date | None = None,
177
+ ) -> Plan:
178
+ """Compare the manifest with what is installed and with the catalogue. Reads the catalogue (one query per
179
+ name, for sizes and dates) and the local library; downloads and writes nothing."""
180
+ names = wanted_names(manifest.libraries, (*manifest.groups, *extra_groups))
181
+ today = today or datetime.now(UTC).date()
182
+ scanned = library.scan()
183
+ directory = library.zim_dirs()[0]
184
+ items: list[Item] = []
185
+ for name in names:
186
+ mine = [b for b in scanned.books if b.name == name]
187
+ newest_book = library.newest(mine)
188
+ have = newest_book.date if newest_book else ""
189
+ newest = catalog.latest(catalog.editions_of(name))
190
+ item = Item(name, "unavailable", installed=have or None)
191
+ if newest_book is not None:
192
+ age = newest_book.age_days(today)
193
+ item.over_max_age = bool(manifest.max_age_days and age is not None and age > manifest.max_age_days)
194
+ if not newest:
195
+ item.note = "the catalogue has no entry with this name"
196
+ if mine:
197
+ item.action, item.note = "unlisted", item.note + "; the installed edition is kept"
198
+ items.append(item)
199
+ continue
200
+ entry = newest[0]
201
+ item.available, item.entry, item.file = entry.date, entry, catalog.target_name(entry)
202
+ if not mine:
203
+ item.action = "install"
204
+ elif (newer := library.is_newer(entry.date, have)) is not False:
205
+ item.action = "update" if update else "outdated"
206
+ item.note = "" if update else "newer edition available; --update stages it beside the installed one"
207
+ if newer is None:
208
+ item.note = "the installed edition has no usable Date, so it may be older; " + item.note
209
+ else:
210
+ item.action = "current"
211
+ if item.action in {"install", "update"}:
212
+ item.approx_bytes = entry.approx_bytes
213
+ if os.path.lexists(directory / item.file):
214
+ item.action, item.approx_bytes = "blocked", 0
215
+ item.note = (
216
+ f"{item.file} is already in {directory} but is not a readable edition of {name}; move it aside"
217
+ )
218
+ items.append(item)
219
+ present = {b.name for b in scanned.books}
220
+ extras = [
221
+ {
222
+ "name": n,
223
+ "editions": sum(b.name == n for b in scanned.books),
224
+ "bytes": sum(b.size for b in scanned.books if b.name == n),
225
+ }
226
+ for n in sorted(present - set(names))
227
+ ]
228
+ return Plan(
229
+ manifest.path,
230
+ items,
231
+ extras,
232
+ sum(i.approx_bytes for i in items),
233
+ sum(b.size for b in scanned.books),
234
+ manifest.budget,
235
+ manifest.max_age_days,
236
+ )
237
+
238
+
239
+ def execute(plan: Plan, *, progress: Callable[[str, int, int], None] | None = None) -> list[dict[str, Any]]:
240
+ """Download what the plan says to fetch. Refuses (before the first byte) when the plan exceeds the budget.
241
+ One failed library does not stop the others: each outcome is reported, and the caller exits non-zero."""
242
+ if plan.over_budget:
243
+ raise ZimloreError(
244
+ f"the sync would grow the library to about {(plan.installed_bytes + plan.download_bytes) / 1e6:.1f} MB "
245
+ f"({plan.installed_bytes / 1e6:.1f} MB installed + about {plan.download_bytes / 1e6:.1f} MB new), "
246
+ f"over the {(plan.budget or 0) / 1e6:.1f} MB budget; nothing was downloaded",
247
+ "raise `budget` in the manifest, drop a library or group, or free space: primarysource zim prune --yes",
248
+ )
249
+ directory = library.zim_dirs()[0]
250
+ library.close_all()
251
+ results: list[dict[str, Any]] = []
252
+ for item in plan.to_fetch:
253
+ if item.entry is None:
254
+ continue
255
+ name = item.name
256
+
257
+ def report(done: int, total: int, name: str = name) -> None:
258
+ if progress:
259
+ progress(name, done, total)
260
+
261
+ try:
262
+ path = catalog.download(item.entry, directory, progress=report, added_by="sync")
263
+ except ZimloreError as error:
264
+ results.append(
265
+ {"name": name, "action": item.action, "ok": False, "error": error.message, "hint": error.cli_hint}
266
+ )
267
+ continue
268
+ results.append({"name": name, "action": item.action, "ok": True, "file": str(path)})
269
+ return results