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,636 @@
1
+ """Managing what is installed: provenance sidecars, verify, remove, prune, du.
2
+
3
+ No network here (that is `catalog.py`). Every function takes the library directories from `library.zim_dirs()`
4
+ and only ever deletes files it found by scanning them: a path typed by a user is never passed to `unlink`.
5
+
6
+ **Provenance.** Each installed ZIM may have a sidecar `<file>.zimlore.json` recording where it came from and the
7
+ SHA-256 it had when it was verified. It is data, not trust: a file without one is `provenance: unknown`, and a
8
+ sidecar written by `verify --adopt` says `added_by: adopt`, leaves `catalogue_name`, `catalogue_date` and
9
+ `fetched_at` empty (nothing observed them) and records only what the file *is* now plus `adopted_at`.
10
+
11
+ **Deleting.** An edition is a ZIM `uuid`: copies of one uuid in two directories are one edition, and two uuids
12
+ sharing a Name and Date are an ambiguity that `remove` and `prune` report and refuse. Editions are ordered by the
13
+ PARSED `Date`; a group with a date that does not parse is never pruned. `prune` deletes only zimlore-managed files
14
+ (`added_by` get, sync or add) unless told `include_unmanaged`, refuses to delete anything while an edition it
15
+ keeps fails verification, and every deleting function returns what it deleted AND what failed.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import hashlib
21
+ import json
22
+ import os
23
+ import shutil
24
+ import time
25
+ from collections.abc import Callable
26
+ from dataclasses import asdict, dataclass, field
27
+ from datetime import UTC, date, datetime
28
+ from functools import partial
29
+ from pathlib import Path
30
+ from typing import Any
31
+
32
+ from . import library
33
+ from .errors import ZimloreError
34
+ from .library import Book
35
+
36
+ SIDECAR_SUFFIX = ".zimlore.json"
37
+ SCHEMA = 1
38
+ MANAGED = frozenset({"get", "sync", "add"})
39
+ """`added_by` values that mean zimlore itself put the file here. `adopt` and no sidecar at all do not."""
40
+ QUIET_SECONDS = 600
41
+ """A `*.zim.part` or `*.zimlore.json.tmp` modified within the last ten minutes may belong to a live download or
42
+ write, so `prune` and `du` leave it alone (as they do a `.part` with a `.part.lock`)."""
43
+
44
+
45
+ @dataclass(frozen=True)
46
+ class Provenance:
47
+ source: str # the URL the bytes came from, "" when unknown
48
+ catalogue_name: str
49
+ catalogue_date: str
50
+ sha256: str
51
+ size: int
52
+ fetched_at: str
53
+ added_by: str # "get" | "add" | "sync" | "adopt"
54
+ schema: int = SCHEMA
55
+ adopted_at: str = "" # only `verify --adopt` sets it: when zimlore first saw the file
56
+
57
+
58
+ def sidecar_path(zim: Path) -> Path:
59
+ return zim.with_name(zim.name + SIDECAR_SUFFIX)
60
+
61
+
62
+ def sha256_of(path: Path) -> str:
63
+ digest = hashlib.sha256()
64
+ with path.open("rb") as handle:
65
+ while chunk := handle.read(1 << 20):
66
+ digest.update(chunk)
67
+ return digest.hexdigest()
68
+
69
+
70
+ def hash_and_size(path: Path) -> tuple[str, int]:
71
+ """SHA-256 and size from ONE open handle: the size is the bytes hashed, and a file whose length changed while
72
+ it was read is an error, so a sidecar can never pair one file's size with another's hash."""
73
+ digest = hashlib.sha256()
74
+ total = 0
75
+ with path.open("rb") as handle:
76
+ while chunk := handle.read(1 << 20):
77
+ digest.update(chunk)
78
+ total += len(chunk)
79
+ end = os.fstat(handle.fileno()).st_size
80
+ if end != total:
81
+ raise ZimloreError(
82
+ f"{path} changed while it was being read", "run the command again when nothing writes to it"
83
+ )
84
+ return digest.hexdigest(), total
85
+
86
+
87
+ def now() -> str:
88
+ return datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
89
+
90
+
91
+ def write_sidecar(zim: Path, record: Provenance) -> Path:
92
+ """Write the sidecar through a temporary file that is flushed to disk before it replaces the old one."""
93
+ path = sidecar_path(zim)
94
+ temporary = path.with_name(path.name + ".tmp")
95
+ try:
96
+ with temporary.open("w", encoding="utf-8", newline="\n") as handle:
97
+ handle.write(json.dumps(asdict(record), indent=2) + "\n")
98
+ handle.flush()
99
+ os.fsync(handle.fileno())
100
+ temporary.replace(path)
101
+ except OSError:
102
+ temporary.unlink(missing_ok=True)
103
+ raise
104
+ return path
105
+
106
+
107
+ def read_sidecar(zim: Path) -> tuple[Provenance | None, str]:
108
+ """(record, problem). `(None, "")` means there is no sidecar; a sidecar that cannot be used is reported in
109
+ `problem` rather than treated as absent, because a corrupt record is not the same as no record."""
110
+ path = sidecar_path(zim)
111
+ if not path.is_file():
112
+ return None, ""
113
+ try:
114
+ raw = json.loads(path.read_text(encoding="utf-8"))
115
+ if not isinstance(raw, dict) or raw.get("schema") != SCHEMA:
116
+ return None, f"{path.name}: unknown schema {raw.get('schema') if isinstance(raw, dict) else raw!r}"
117
+ return Provenance(**raw), ""
118
+ except (OSError, ValueError, TypeError) as error:
119
+ return None, f"{path.name}: {error}"
120
+
121
+
122
+ def provenance_summary(zim: Path) -> dict[str, Any]:
123
+ """What an agent or a citation may say about where a file came from."""
124
+ record, problem = read_sidecar(zim)
125
+ if record is None:
126
+ return {"state": "unrecorded" if not problem else "unreadable", "note": problem or "no sidecar"}
127
+ summary: dict[str, Any] = {
128
+ "state": "recorded",
129
+ "source": record.source or None,
130
+ "sha256": record.sha256,
131
+ "fetched_at": record.fetched_at or None, # an adopted file was never fetched by zimlore
132
+ "added_by": record.added_by,
133
+ }
134
+ if record.adopted_at:
135
+ summary["adopted_at"] = record.adopted_at
136
+ return summary
137
+
138
+
139
+ # --- verify --------------------------------------------------------------------------------------------------
140
+
141
+
142
+ def _selected(name: str) -> list[Book]:
143
+ scanned = library.scan()
144
+ if not scanned.books and not scanned.problems:
145
+ raise library.empty_library_error(scanned)
146
+ if not name:
147
+ return scanned.books
148
+ wanted = library.select(name, scanned)
149
+ if wanted.kind == "newest": # a family named without a version: check every version of it
150
+ return list(wanted.members)
151
+ return [b for b in scanned.books if b.name == wanted.book.name]
152
+
153
+
154
+ def verify(name: str = "", *, hash_files: bool = True, adopt: bool = False) -> dict[str, Any]:
155
+ """Check installed files against their sidecars and libzim's own checksum.
156
+
157
+ status per file: `ok` (sidecar matches and libzim's checksum holds), `changed` (size or SHA-256 differs from
158
+ the sidecar), `corrupt` (libzim's checksum fails), `unrecorded` (no sidecar; the file is only integrity-checked),
159
+ `adopted` (a sidecar was written because `adopt` was set), `adopted-unchecked` (the same, but the ZIM carries no
160
+ checksum of its own, so nothing vouches for it). `unreadable` files are listed by the scan itself.
161
+ """
162
+ rows: list[dict[str, Any]] = []
163
+ for book in _selected(name):
164
+ record, problem = read_sidecar(book.path)
165
+ archive = library.open_archive(book.path)
166
+ intact = bool(archive.check()) if archive.has_checksum else None
167
+ row: dict[str, Any] = {"name": book.name, "edition": book.date or None, "file": book.path.name}
168
+ if record is None:
169
+ row["status"] = "corrupt" if intact is False else "unrecorded"
170
+ if problem:
171
+ row["provenance_problem"] = problem
172
+ if adopt and intact is not False and not problem:
173
+ digest, size = hash_and_size(book.path)
174
+ write_sidecar(book.path, Provenance("", "", "", digest, size, "", "adopt", adopted_at=now()))
175
+ row["status"] = "adopted" if intact else "adopted-unchecked"
176
+ if intact is None:
177
+ row["note"] = "this ZIM has no checksum of its own: only the hash recorded now can vouch for it"
178
+ else:
179
+ if hash_files:
180
+ actual, size = hash_and_size(book.path)
181
+ else:
182
+ actual, size = "", book.path.stat().st_size
183
+ if size != record.size:
184
+ row["status"], row["why"] = "changed", f"size {size} != recorded {record.size}"
185
+ elif hash_files and actual != record.sha256:
186
+ row["status"], row["why"] = "changed", f"sha256 {actual[:12]}... != recorded {record.sha256[:12]}..."
187
+ elif intact is False:
188
+ row["status"], row["why"] = "corrupt", "libzim's own checksum fails"
189
+ else:
190
+ row["status"] = "ok"
191
+ if not hash_files and row["status"] == "ok":
192
+ row["note"] = "size only; SHA-256 not recomputed"
193
+ row["libzim_checksum"] = intact
194
+ rows.append(row)
195
+ problems = library.scan().problems # a file libzim cannot open at all (truncated, trailing bytes) is a failure
196
+ bad = [r for r in rows if r["status"] in {"changed", "corrupt"}]
197
+ return {"files": rows, "problems": problems, "failed": len(bad) + len(problems)}
198
+
199
+
200
+ # --- editions ------------------------------------------------------------------------------------------------
201
+
202
+
203
+ @dataclass
204
+ class Edition:
205
+ """One ZIM (one `uuid`) and every installed copy of it."""
206
+
207
+ uuid: str
208
+ books: list[Book] = field(default_factory=list)
209
+
210
+ @property
211
+ def date_text(self) -> str:
212
+ return self.books[0].date
213
+
214
+ @property
215
+ def when(self) -> date | None:
216
+ return self.books[0].when
217
+
218
+
219
+ def editions_of(books: list[Book]) -> list[Edition]:
220
+ """Group files by `uuid` (copies in two directories are one edition) and order them newest first by parsed
221
+ date, undated last. Ties are broken by uuid only to be deterministic: callers refuse ties before acting."""
222
+ grouped: dict[str, Edition] = {}
223
+ for book in sorted(books, key=lambda b: str(b.path)):
224
+ grouped.setdefault(book.uuid, Edition(book.uuid)).books.append(book)
225
+ return sorted(grouped.values(), key=lambda e: (library.date_key(e.date_text), e.uuid), reverse=True)
226
+
227
+
228
+ def _managed(book: Book) -> bool:
229
+ record, _ = read_sidecar(book.path)
230
+ return record is not None and record.added_by in MANAGED
231
+
232
+
233
+ def _health(book: Book) -> str:
234
+ """'' when the file passes every check available to it, else why not: libzim's checksum and, when a sidecar
235
+ exists, its size and SHA-256. A file nothing can vouch for is not healthy: it is 'unverifiable'."""
236
+ try:
237
+ archive = library.open_archive(book.path)
238
+ record, _ = read_sidecar(book.path)
239
+ checks = 0
240
+ if archive.has_checksum:
241
+ if not archive.check():
242
+ return "fails libzim's own checksum"
243
+ checks += 1
244
+ if record is not None:
245
+ digest, size = hash_and_size(book.path)
246
+ if size != record.size or digest != record.sha256:
247
+ return "no longer matches its provenance record"
248
+ checks += 1
249
+ except (OSError, ZimloreError) as error:
250
+ return f"cannot be checked ({error})"
251
+ return "" if checks else "cannot be verified: it has no checksum of its own and no provenance record"
252
+
253
+
254
+ def _failure(error: OSError) -> str:
255
+ if isinstance(error, PermissionError):
256
+ return "stop any running 'primarysource zim mcp' server or kiwix reader that has it open, then retry"
257
+ return ""
258
+
259
+
260
+ def _delete(book: Book) -> int:
261
+ """Delete one edition file and its sidecar; return the bytes freed. Raises `OSError` (a `FileNotFoundError` for
262
+ a file that is already gone). The sidecar goes FIRST, so a later file of the same name can never inherit it;
263
+ if the ZIM then cannot be deleted (Windows keeps it locked while a handle is open) the sidecar is put back."""
264
+ library.close_path(book.path)
265
+ sidecar = sidecar_path(book.path)
266
+ try:
267
+ saved: bytes | None = sidecar.read_bytes()
268
+ except FileNotFoundError:
269
+ saved = None
270
+ if saved is not None:
271
+ sidecar.unlink()
272
+ try:
273
+ book.path.unlink()
274
+ except FileNotFoundError:
275
+ raise
276
+ except OSError:
277
+ if saved is not None:
278
+ sidecar.write_bytes(saved)
279
+ raise
280
+ return book.size
281
+
282
+
283
+ def _unlink_leftover(path: Path, size: int) -> int:
284
+ path.unlink()
285
+ return size
286
+
287
+
288
+ def _unlink_all(
289
+ books: list[Book], leftovers: list[dict[str, Any]]
290
+ ) -> tuple[list[dict[str, Any]], list[dict[str, Any]], int]:
291
+ """Delete each file on its own: a file that vanished is success, any other `OSError` is recorded and the rest
292
+ still go. Returns (deleted, failed, bytes freed)."""
293
+ deleted: list[dict[str, Any]] = []
294
+ failed: list[dict[str, Any]] = []
295
+ freed = 0
296
+ work: list[tuple[Path, int, Callable[[], int]]] = [(b.path, b.size, partial(_delete, b)) for b in books]
297
+ for item in leftovers:
298
+ path, size = Path(item["file"]), int(item["bytes"])
299
+ work.append((path, size, partial(_unlink_leftover, path, size)))
300
+ for path, size, action in work:
301
+ try:
302
+ freed += action()
303
+ deleted.append({"file": str(path), "bytes": size})
304
+ except FileNotFoundError:
305
+ deleted.append({"file": str(path), "bytes": 0, "already_gone": True})
306
+ except OSError as error:
307
+ failed.append({"file": str(path), "error": str(error), "hint": _failure(error)})
308
+ return deleted, failed, freed
309
+
310
+
311
+ def _result(
312
+ plan: list[dict[str, Any]],
313
+ applied: bool,
314
+ outcome: tuple[list[dict[str, Any]], list[dict[str, Any]], int],
315
+ refused: list[dict[str, Any]],
316
+ skipped: list[dict[str, Any]],
317
+ ) -> dict[str, Any]:
318
+ deleted, failed, freed = outcome
319
+ note = ""
320
+ if failed:
321
+ note = f"{len(failed)} file(s) could NOT be deleted and {len(deleted)} were; see 'failed'"
322
+ elif not applied and plan:
323
+ note = "dry run: nothing was deleted; add --yes to delete"
324
+ return {
325
+ "applied": applied,
326
+ "files": plan,
327
+ "deleted": deleted,
328
+ "failed": failed,
329
+ "refused": refused,
330
+ "skipped": skipped,
331
+ "bytes": sum(item["bytes"] for item in plan),
332
+ "freed": freed,
333
+ "ok": not failed and not (applied and refused),
334
+ "note": note,
335
+ }
336
+
337
+
338
+ def _plan_item(book: Book) -> dict[str, Any]:
339
+ return {"file": str(book.path), "edition": book.date or None, "uuid": book.uuid, "bytes": book.size}
340
+
341
+
342
+ def _leftovers(
343
+ directories: list[Path], *, everywhere: bool = False, moment: float | None = None
344
+ ) -> list[dict[str, Any]]:
345
+ """Files zimlore itself leaves behind: `*.zim.part` (an interrupted download) and `*.zimlore.json.tmp` (a
346
+ sidecar write that died). Only regular files, never a symlink or directory, never one touched in the last
347
+ `QUIET_SECONDS`, never a `.part` with a `.part.lock`, and by default only in the directory zimlore
348
+ downloads to."""
349
+ moment = time.time() if moment is None else moment
350
+ found: list[dict[str, Any]] = []
351
+ for directory in directories if everywhere else directories[:1]:
352
+ if not directory.is_dir():
353
+ continue
354
+ for pattern, kind in (("*.zim.part", "partial download"), ("*.zimlore.json.tmp", "stale sidecar temp file")):
355
+ for path in sorted(directory.glob(pattern)):
356
+ if path.is_symlink() or not path.is_file():
357
+ continue
358
+ if kind == "partial download" and path.with_name(path.name + ".lock").exists():
359
+ continue
360
+ stat = path.stat()
361
+ if moment - stat.st_mtime < QUIET_SECONDS:
362
+ continue
363
+ found.append({"file": str(path), "edition": None, "bytes": stat.st_size, "kind": kind})
364
+ return found
365
+
366
+
367
+ # --- remove / prune / du -------------------------------------------------------------------------------------
368
+
369
+
370
+ def _edition_matches(edition: Edition, text: str) -> bool:
371
+ wanted = library.parse_date(text)
372
+ return text in {edition.uuid, edition.date_text} or (wanted is not None and wanted == edition.when)
373
+
374
+
375
+ def remove(
376
+ name: str, *, edition: str = "", all_editions: bool = False, all_versions: bool = False, apply: bool = False
377
+ ) -> dict[str, Any]:
378
+ """Remove installed editions of one library. Dry run unless `apply`. With several editions installed the
379
+ caller must say which (`edition`: a date or a uuid) or say all (`all_editions`): deleting a user's files is
380
+ never a default. Two different ZIMs sharing the date are an ambiguity, not a pair to delete together. The
381
+ result lists `deleted` and `failed` files even when some deletions fail.
382
+
383
+ A name that means a package with several installed VERSIONS (`cobra`) is refused unless a version is written
384
+ (`cobra v1.8.1`) or `all_versions` says every version goes: 'the newest' is a fine thing to read and a
385
+ surprising thing to delete."""
386
+ scanned = library.scan()
387
+ if not scanned.books:
388
+ raise library.empty_library_error(scanned)
389
+ selected = library.select(name, scanned)
390
+ chosen = selected.book
391
+ if all_versions:
392
+ found = editions_of(list(selected.members))
393
+ elif selected.kind == "newest" and selected.other_versions:
394
+ raise ZimloreError(
395
+ f"{chosen.family} has {len(selected.versions)} versions installed; say which to remove",
396
+ f"write the version (remove '{chosen.family} {chosen.version}'), or --all-versions for every one",
397
+ versions=selected.versions,
398
+ )
399
+ else:
400
+ found = editions_of(library.editions(chosen, scanned.books))
401
+ if edition:
402
+ matching = [e for e in found if _edition_matches(e, edition)]
403
+ if not matching:
404
+ raise ZimloreError(
405
+ f"{chosen.name} has no edition {edition}", "primarysource zim list shows the installed dates"
406
+ )
407
+ if len(matching) > 1:
408
+ raise ZimloreError(
409
+ f"{len(matching)} different ZIMs of {chosen.name} carry the date {edition}; not guessing",
410
+ "name one by its uuid: --edition UUID (candidates are listed); the files are different builds",
411
+ candidates=[{"uuid": e.uuid, "files": [str(b.path) for b in e.books]} for e in matching],
412
+ )
413
+ found = matching
414
+ elif len(found) > 1 and not (all_editions or all_versions):
415
+ raise ZimloreError(
416
+ f"{chosen.name} has {len(found)} editions installed; say which to remove",
417
+ "--edition DATE for one, --all to remove every edition",
418
+ editions=[e.date_text for e in found],
419
+ )
420
+ books = [b for e in found for b in e.books]
421
+ plan = [_plan_item(b) for b in books]
422
+ outcome = _unlink_all(books, []) if apply else ([], [], 0)
423
+ return {"name": chosen.name, **_result(plan, apply, outcome, [], [])}
424
+
425
+
426
+ def prune(
427
+ *, keep: int = 2, apply: bool = False, include_unmanaged: bool = False, moment: float | None = None
428
+ ) -> dict[str, Any]:
429
+ """Delete all but the newest `keep` editions of every library, and zimlore's own stale leftovers. Dry run
430
+ unless `apply`.
431
+
432
+ Refuses (and says why, under `refused`) any library whose editions cannot be ordered safely: a `Date` that does
433
+ not parse, or two different ZIMs sharing one, and any library where an edition it would KEEP fails verification
434
+ (libzim's checksum, the sidecar hash), because the older edition may be the only good copy. Files zimlore did not
435
+ put there (no sidecar, or `added_by: adopt`) are listed under `skipped` and kept unless `include_unmanaged`."""
436
+ if keep < 1:
437
+ raise ZimloreError("keep must be at least 1", "pruning every edition is `primarysource zim remove --all`")
438
+ scanned = library.scan()
439
+ doomed: list[Book] = []
440
+ refused: list[dict[str, Any]] = []
441
+ skipped: list[dict[str, Any]] = []
442
+ for name in sorted({b.name for b in scanned.books}):
443
+ found = editions_of([b for b in scanned.books if b.name == name])
444
+ if len(found) <= keep:
445
+ continue
446
+ files = [str(b.path) for e in found for b in e.books]
447
+ undated = [e for e in found if e.when is None]
448
+ if undated:
449
+ refused.append(
450
+ {
451
+ "name": name,
452
+ "reason": "an edition has no Date that parses, so its age is unknown and nothing is deleted",
453
+ "files": [str(b.path) for e in undated for b in e.books],
454
+ }
455
+ )
456
+ continue
457
+ dates = [e.when for e in found]
458
+ if len(set(dates)) != len(dates):
459
+ refused.append(
460
+ {
461
+ "name": name,
462
+ "reason": "two different ZIMs (different uuid) share a Name and Date; which is older is unknown",
463
+ "files": files,
464
+ }
465
+ )
466
+ continue
467
+ broken = None
468
+ for edition in found[:keep]:
469
+ results = [(b, _health(b)) for b in edition.books]
470
+ if all(why for _, why in results):
471
+ broken = results[0]
472
+ break
473
+ if broken is not None:
474
+ refused.append(
475
+ {
476
+ "name": name,
477
+ "reason": f"{broken[0].path} is kept but {broken[1]}; the older one may be the last good copy",
478
+ "files": [str(broken[0].path)],
479
+ }
480
+ )
481
+ continue
482
+ for edition in found[keep:]:
483
+ for book in edition.books:
484
+ if include_unmanaged or _managed(book):
485
+ doomed.append(book)
486
+ else:
487
+ skipped.append(
488
+ {
489
+ **_plan_item(book),
490
+ "reason": "not installed by zimlore; --include-unmanaged deletes it",
491
+ }
492
+ )
493
+ leftovers = _leftovers(scanned.directories, everywhere=include_unmanaged, moment=moment)
494
+ plan = [_plan_item(b) for b in doomed] + leftovers
495
+ outcome = _unlink_all(doomed, leftovers) if apply else ([], [], 0)
496
+ result = {"keep": keep, **_result(plan, apply, outcome, refused, skipped)}
497
+ if refused and not result["note"]:
498
+ result["note"] = f"{len(refused)} library(ies) were not pruned; see 'refused'"
499
+ elif skipped and not result["note"]:
500
+ result["note"] = f"{len(skipped)} file(s) were left alone because zimlore did not install them; see 'skipped'"
501
+ return result
502
+
503
+
504
+ def du() -> dict[str, Any]:
505
+ """Disk use per library (all editions), partial downloads, and free space on the volume. Partial downloads are
506
+ counted by the same rule `prune` deletes them by: stale `*.zim.part` regular files without a live lock."""
507
+ scanned = library.scan()
508
+ per: dict[str, dict[str, Any]] = {}
509
+ for name in sorted({b.name for b in scanned.books}):
510
+ mine = [b for b in scanned.books if b.name == name]
511
+ newest = library.newest(mine)
512
+ per[name] = {
513
+ "name": name,
514
+ "editions": len(editions_of(mine)),
515
+ "bytes": sum(b.size for b in mine),
516
+ "newest": newest.date if newest else "",
517
+ }
518
+ leftovers = _leftovers(scanned.directories)
519
+ partial = sum(item["bytes"] for item in leftovers if item["kind"] == "partial download")
520
+ where = next((d for d in scanned.directories if d.exists()), None)
521
+ free = shutil.disk_usage(where).free if where else None
522
+ libraries = sorted(per.values(), key=lambda r: -r["bytes"])
523
+ return {
524
+ "libraries": libraries,
525
+ "total_bytes": sum(r["bytes"] for r in libraries),
526
+ "partial_bytes": partial,
527
+ "stale_temp_files": sum(item["kind"] != "partial download" for item in leftovers),
528
+ "free_bytes": free,
529
+ "directories": [str(d) for d in scanned.directories],
530
+ }
531
+
532
+
533
+ # --- add -----------------------------------------------------------------------------------------------------
534
+
535
+
536
+ def _copy_hashed(source: Path, target: Path) -> str:
537
+ digest = hashlib.sha256()
538
+ with source.open("rb") as reader, target.open("wb") as writer:
539
+ while chunk := reader.read(1 << 20):
540
+ writer.write(chunk)
541
+ digest.update(chunk)
542
+ return digest.hexdigest()
543
+
544
+
545
+ def add_local(source: Path, *, link: bool = False, force: bool = False) -> dict[str, Any]:
546
+ """Install a local ZIM into the first library directory, with a provenance sidecar (`added_by: add`).
547
+
548
+ libzim opens the file first, so an unreadable file is an error with nothing copied; its own checksum must
549
+ hold unless `force`. The file is copied by default (safe on Windows, no dangling links, no lock on the
550
+ original); `link` makes a hard link, then a symbolic link, and says so when the OS refuses both. An existing
551
+ file at the target is never overwritten: an identical one means "already installed", a different one is an
552
+ error. Two files carrying one Name and Date are the same edition, so the second is refused naming `remove`.
553
+ """
554
+ source = source.expanduser()
555
+ if not source.is_file():
556
+ raise ZimloreError(f"{source} is not a file", "give the path of a .zim file")
557
+ if source.suffix != ".zim":
558
+ raise ZimloreError(f"{source} does not end in .zim", "primarysource zim add takes a .zim file")
559
+ library.close_all()
560
+ try:
561
+ book = library.read_book(source)
562
+ archive = library.open_archive(source)
563
+ intact = bool(archive.check()) if archive.has_checksum else None
564
+ except ZimloreError as error:
565
+ raise ZimloreError(
566
+ error.message, "nothing was copied; check that the file is a complete .zim (re-download or re-copy it)"
567
+ ) from error
568
+ finally:
569
+ library.close_all() # Windows keeps the file locked while a handle is open
570
+ if intact is False and not force:
571
+ raise ZimloreError(
572
+ f"{source} fails libzim's own checksum: the file is damaged",
573
+ "nothing was copied; re-obtain the file, or --force to install it anyway (verify will keep flagging it)",
574
+ )
575
+ directory = library.zim_dirs()[0]
576
+ if source.resolve().parent in {d.resolve() for d in library.zim_dirs() if d.is_dir()}:
577
+ raise ZimloreError(
578
+ f"{source} is already in the library directory {source.parent}",
579
+ "nothing to add; primarysource zim verify --adopt records a provenance sidecar for it",
580
+ )
581
+ target = directory / source.name
582
+ base = {"name": book.name, "edition": book.date or None, "file": str(target)}
583
+ if os.path.lexists(target):
584
+ digest = sha256_of(source)
585
+ if target.is_file() and sha256_of(target) == digest:
586
+ return {**base, "status": "already-installed", "mode": "none", "bytes": book.size, "sha256": digest}
587
+ raise ZimloreError(
588
+ f"{target} already exists and is a different file; not overwriting it",
589
+ "rename the source, or remove the installed file first: primarysource zim list shows it, "
590
+ "primarysource zim remove deletes it",
591
+ )
592
+ clash = [b for b in library.scan().books if b.name == book.name and (b.date == book.date or b.uuid == book.uuid)]
593
+ library.close_all()
594
+ if clash:
595
+ raise ZimloreError(
596
+ f"{book.name} edition {book.date or 'undated'} is already installed as {clash[0].path.name}",
597
+ f"replace it deliberately: primarysource zim remove {book.name} --edition {book.date} --yes, "
598
+ "then add again",
599
+ )
600
+ directory.mkdir(parents=True, exist_ok=True)
601
+ if link:
602
+ digest = sha256_of(source)
603
+ mode = "hardlink"
604
+ try:
605
+ os.link(source, target)
606
+ except OSError:
607
+ mode = "symlink"
608
+ try:
609
+ os.symlink(source.resolve(), target)
610
+ except OSError as error:
611
+ raise ZimloreError(
612
+ f"the operating system refused both a hard link and a symbolic link to {source} ({error})",
613
+ "omit --link to copy the file instead",
614
+ ) from error
615
+ else:
616
+ mode = "copy"
617
+ staged = target.with_name(target.name + ".add.part")
618
+ try:
619
+ digest = _copy_hashed(source, staged)
620
+ os.replace(staged, target)
621
+ except OSError as error:
622
+ staged.unlink(missing_ok=True)
623
+ raise ZimloreError(
624
+ f"could not copy {source} to {target} ({error})", "free disk space and retry"
625
+ ) from error
626
+ size = target.stat().st_size
627
+ write_sidecar(target, Provenance(str(source.resolve()), book.name, book.date, digest, size, now(), "add"))
628
+ return {
629
+ **base,
630
+ "status": "added",
631
+ "mode": mode,
632
+ "bytes": size,
633
+ "sha256": digest,
634
+ "checksum": intact,
635
+ "forced": bool(force and intact is False),
636
+ }