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
primarysource/fetch.py ADDED
@@ -0,0 +1,360 @@
1
+ """The pinned fetch: polite, bounded, and checked before anything is parsed or kept.
2
+
3
+ - https only, to named hosts, no credentials, the default port; a redirect is followed only to a URL that passes
4
+ the same check;
5
+ - one request per `min_interval` seconds (one per second by default) per `Fetcher`;
6
+ - a byte ceiling on every body; the pinned sha256 is checked (`verify`) before the bytes are parsed or written;
7
+ - the collection's network settings (`primarysource.settings`) are honoured: offline or read-only refuses before a
8
+ request is made;
9
+ - `Fetcher.download` brings a large file to disk as zimlore does: a `<name>.part` written under an `O_EXCL`
10
+ `<name>.part.lock`, resumed with a Range request, verified against the pinned size and sha256, and only then put
11
+ in place with `os.replace`. A failed or interrupted download leaves a `.part`, never a file that looks complete.
12
+
13
+ Lifted from `primarysource/c2sp/_fetch.py` (bbf and ecma import it there, through a re-export) and zimlore's
14
+ `catalog.py` (`_acquire_lock`, `_transfer`, `download_result`). Both transports are injectable, so every test runs
15
+ with no network; the urllib ones are only the defaults.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import contextlib
21
+ import hashlib
22
+ import http.client
23
+ import re
24
+ import time
25
+ import urllib.error
26
+ import urllib.parse
27
+ import urllib.request
28
+ from collections.abc import Callable, Iterator, Mapping, Sequence
29
+ from contextlib import AbstractContextManager
30
+ from http.client import HTTPMessage
31
+ from pathlib import Path
32
+ from typing import IO, Any, Protocol
33
+
34
+ from primarysource.errors import LibError
35
+ from primarysource.fsutil import CHUNK, Lock, replace, sha256, sha256_file, write_lock
36
+ from primarysource.settings import require_network
37
+
38
+ __all__ = [
39
+ "MIN_INTERVAL",
40
+ "PART_LOCK_STALE_SECONDS",
41
+ "TIMEOUT",
42
+ "USER_AGENT",
43
+ "Fetcher",
44
+ "Response",
45
+ "StreamTransport",
46
+ "Transport",
47
+ "check_url",
48
+ "sha256",
49
+ "urllib_stream_transport",
50
+ "urllib_transport",
51
+ "verify",
52
+ ]
53
+
54
+ Transport = Callable[[str, int], bytes]
55
+ """URL and byte ceiling in, the whole body out (at most the ceiling plus one byte)."""
56
+
57
+ MIN_INTERVAL = 1.0
58
+ TIMEOUT = 60.0
59
+ USER_AGENT = "primarysource-importer (+https://github.com/b1tninja-labs/primarysource)"
60
+ PART_LOCK_STALE_SECONDS = 3600.0
61
+ """A `.part.lock` is touched on every chunk, so one untouched this long belongs to a download that died."""
62
+
63
+ _RETRY = "run the same command again: it resumes from the .part"
64
+
65
+
66
+ class Response(Protocol):
67
+ """What a `StreamTransport` yields: a status, headers, and a body read in pieces."""
68
+
69
+ @property
70
+ def status(self) -> int: ...
71
+
72
+ def header(self, name: str) -> str | None: ...
73
+
74
+ def read(self, size: int) -> bytes: ...
75
+
76
+
77
+ StreamTransport = Callable[[str, int], AbstractContextManager[Response]]
78
+ """URL and start offset in (0: the whole file; otherwise a `Range: bytes=<offset>-` request), a response out. A
79
+ 416 is a response with status 416, not an exception; any other failure raises `LibError` or `OSError`."""
80
+
81
+
82
+ def check_url(url: str, hosts: Sequence[str]) -> None:
83
+ """Refuse anything but `https://<host>/...` with a host from `hosts`, no credentials and no odd port."""
84
+ parts = urllib.parse.urlsplit(url)
85
+ if parts.scheme != "https" or parts.hostname not in hosts or parts.username or parts.password:
86
+ raise LibError(f"refusing to fetch {url!r}", f"only https from {', '.join(hosts)} is allowed")
87
+ if parts.port not in (None, 443):
88
+ raise LibError(f"refusing to fetch {url!r}", "only the default https port is allowed")
89
+
90
+
91
+ class _Guard(urllib.request.HTTPRedirectHandler):
92
+ """Follow a redirect only to a URL that passes `check_url`."""
93
+
94
+ def __init__(self, hosts: Sequence[str]) -> None:
95
+ self._hosts = hosts
96
+
97
+ def redirect_request(
98
+ self, req: urllib.request.Request, fp: IO[bytes], code: int, msg: str, headers: HTTPMessage, newurl: str
99
+ ) -> urllib.request.Request | None:
100
+ check_url(newurl, self._hosts)
101
+ return super().redirect_request(req, fp, code, msg, headers, newurl)
102
+
103
+
104
+ def urllib_transport(hosts: Sequence[str]) -> Transport:
105
+ """The real transport: urllib over https, redirects checked, the body read up to the ceiling plus one byte."""
106
+ opener = urllib.request.build_opener(_Guard(hosts))
107
+
108
+ def fetch(url: str, max_bytes: int) -> bytes:
109
+ check_url(url, hosts)
110
+ request = urllib.request.Request(url, headers={"User-Agent": USER_AGENT}) # noqa: S310 - checked above
111
+ try:
112
+ with opener.open(request, timeout=TIMEOUT) as response:
113
+ body: bytes = response.read(max_bytes + 1)
114
+ except urllib.error.HTTPError as exc:
115
+ raise LibError(f"{url}: HTTP {exc.code}", "retry later, or import the file by hand with --file") from exc
116
+ except (urllib.error.URLError, TimeoutError, OSError) as exc:
117
+ raise LibError(f"{url}: {exc}", "check the network, or import the file by hand with --file") from exc
118
+ return body
119
+
120
+ return fetch
121
+
122
+
123
+ class _UrllibResponse:
124
+ def __init__(self, status: int, headers: Any, body: IO[bytes] | None) -> None:
125
+ self.status = status
126
+ self._headers = headers
127
+ self._body = body
128
+
129
+ def header(self, name: str) -> str | None:
130
+ return self._headers.get(name) if self._headers is not None else None
131
+
132
+ def read(self, size: int) -> bytes:
133
+ return self._body.read(size) if self._body is not None else b""
134
+
135
+
136
+ def urllib_stream_transport(hosts: Sequence[str]) -> StreamTransport:
137
+ """The real streaming transport: urllib over https, redirects checked, a Range header when resuming."""
138
+ opener = urllib.request.build_opener(_Guard(hosts))
139
+
140
+ @contextlib.contextmanager
141
+ def stream(url: str, offset: int) -> Iterator[Response]:
142
+ check_url(url, hosts)
143
+ headers = {"User-Agent": USER_AGENT, "Accept-Encoding": "identity"}
144
+ if offset:
145
+ headers["Range"] = f"bytes={offset}-"
146
+ request = urllib.request.Request(url, headers=headers) # noqa: S310 - checked above
147
+ try:
148
+ response = opener.open(request, timeout=TIMEOUT)
149
+ except urllib.error.HTTPError as exc:
150
+ if exc.code == 416:
151
+ yield _UrllibResponse(416, exc.headers, None)
152
+ return
153
+ raise LibError(f"{url}: HTTP {exc.code}", "retry later") from exc
154
+ except (urllib.error.URLError, TimeoutError, OSError) as exc:
155
+ raise LibError(f"{url}: {exc}", "check the network, then run the same command again") from exc
156
+ with response:
157
+ yield _UrllibResponse(getattr(response, "status", 200), response.headers, response)
158
+
159
+ return stream
160
+
161
+
162
+ def _range_start(response: Response) -> int | None:
163
+ match = re.fullmatch(r"bytes (\d+)-\d+/(?:\d+|\*)", response.header("Content-Range") or "", re.ASCII)
164
+ return int(match.group(1)) if match else None
165
+
166
+
167
+ def _content_length(response: Response) -> int | None:
168
+ announced = response.header("Content-Length")
169
+ return int(announced) if announced and announced.isascii() and announced.isdigit() else None
170
+
171
+
172
+ class Fetcher:
173
+ """Fetches at most one URL per `min_interval` seconds, to `hosts` only, while the settings allow the network.
174
+
175
+ `collection` and `legacy_app` choose whose network settings apply (`primarysource.settings`); with neither,
176
+ only `PRIMARYSOURCE_NETWORK` and `PRIMARYSOURCE_READONLY` do. They are read at each request.
177
+ """
178
+
179
+ def __init__(
180
+ self,
181
+ hosts: Sequence[str],
182
+ transport: Transport | None = None,
183
+ *,
184
+ stream: StreamTransport | None = None,
185
+ min_interval: float = MIN_INTERVAL,
186
+ clock: Callable[[], float] = time.monotonic,
187
+ sleep: Callable[[float], None] = time.sleep,
188
+ collection: str | None = None,
189
+ legacy_app: str | None = None,
190
+ environ: Mapping[str, str] | None = None,
191
+ ) -> None:
192
+ self._hosts = tuple(hosts)
193
+ self._transport = transport or urllib_transport(self._hosts)
194
+ self._stream = stream or urllib_stream_transport(self._hosts)
195
+ self._interval = min_interval
196
+ self._clock = clock
197
+ self._sleep = sleep
198
+ self._collection = collection
199
+ self._legacy_app = legacy_app
200
+ self._environ = environ
201
+ self._last: float | None = None
202
+ self.requests = 0
203
+
204
+ def _begin(self, url: str) -> None:
205
+ """Check the URL and the settings, then wait out the interval and count the request."""
206
+ check_url(url, self._hosts)
207
+ require_network(url, self._collection, self._legacy_app, self._environ)
208
+ if self._last is not None:
209
+ wait = self._last + self._interval - self._clock()
210
+ if wait > 0:
211
+ self._sleep(wait)
212
+ self._last = self._clock()
213
+ self.requests += 1
214
+
215
+ def get(self, url: str, max_bytes: int) -> bytes:
216
+ """The body of `url`, or a `LibError` if it is not allowed, fails, or is larger than `max_bytes`."""
217
+ self._begin(url)
218
+ body = self._transport(url, max_bytes)
219
+ if len(body) > max_bytes:
220
+ raise LibError(
221
+ f"{url} is larger than {max_bytes} bytes", "the pinned file is smaller; do not import this"
222
+ )
223
+ return body
224
+
225
+ def download(
226
+ self,
227
+ url: str,
228
+ dest: Path,
229
+ *,
230
+ sha256: str,
231
+ size: int,
232
+ progress: Callable[[int, int], None] | None = None,
233
+ stale_lock_seconds: float = PART_LOCK_STALE_SECONDS,
234
+ ) -> bool:
235
+ """Bring `dest` to the pinned `size` and `sha256`. True if a download put it there, False if it already was.
236
+
237
+ The bytes go to `<dest>.part` under the lock `<dest>.part.lock`; an existing `.part` is resumed. Only a
238
+ file of the pinned size and digest is moved to `dest`, with `os.replace`; a mismatch discards the `.part`
239
+ and raises. An existing `dest` that is not the pinned file is replaced only by a verified download.
240
+ """
241
+ check_url(url, self._hosts)
242
+ if dest.is_file() and dest.stat().st_size == size and sha256_file(dest) == sha256:
243
+ return False
244
+ require_network(url, self._collection, self._legacy_app, self._environ)
245
+ dest.parent.mkdir(parents=True, exist_ok=True)
246
+ part = dest.with_name(dest.name + ".part")
247
+ lock_path = part.with_name(part.name + ".lock")
248
+ with write_lock(lock_path, stale_after=stale_lock_seconds, what=str(part)) as lock:
249
+ done, digest = self._transfer(url, part, lock, size, progress)
250
+ if done != size or digest != sha256:
251
+ part.unlink(missing_ok=True)
252
+ raise LibError(
253
+ f"{url}: downloaded {done} bytes with sha256 {digest}, expected {size} bytes with the pinned "
254
+ f"{sha256}; the download was discarded",
255
+ "retry; if it persists the publisher changed the file or the download was altered",
256
+ )
257
+ try:
258
+ replace(part, dest)
259
+ except OSError as exc:
260
+ raise LibError(
261
+ f"the verified download of {url} could not be moved to {dest} ({exc})",
262
+ f"it is kept as {part}; free {dest} and run the same command again (no re-download is needed)",
263
+ ) from exc
264
+ return True
265
+
266
+ def _transfer(
267
+ self, url: str, part: Path, lock: Lock, size: int, progress: Callable[[int, int], None] | None
268
+ ) -> tuple[int, str]:
269
+ """Bring `part` to `size` bytes and return (bytes, sha256 of the whole file).
270
+
271
+ An existing `.part` is re-hashed and resumed with a Range request. A server that refuses the range (200
272
+ instead of 206, a wrong Content-Range, 416) makes the download start over from byte zero, never splice.
273
+ Never more than `size` bytes reach the disk.
274
+ """
275
+ for _ in range(2):
276
+ digest = hashlib.sha256()
277
+ offset = part.stat().st_size if part.exists() else 0
278
+ if offset > size: # longer than the file can be: not a prefix of it
279
+ part.unlink()
280
+ offset = 0
281
+ if offset:
282
+ with part.open("rb") as prefix:
283
+ while chunk := prefix.read(CHUNK):
284
+ digest.update(chunk)
285
+ lock.touch()
286
+ if offset == size:
287
+ return offset, digest.hexdigest() # a complete .part: the digest check decides
288
+ self._begin(url)
289
+ try:
290
+ with self._stream(url, offset) as response:
291
+ status = response.status
292
+ if status == 416:
293
+ if not offset:
294
+ raise LibError(f"{url}: HTTP 416 for the whole file", "retry later")
295
+ part.unlink(missing_ok=True) # the server's file is shorter than our part
296
+ continue
297
+ if status not in (200, 206):
298
+ raise LibError(f"{url}: HTTP {status}", "retry later")
299
+ if offset and not (status == 206 and _range_start(response) == offset):
300
+ part.unlink(missing_ok=True)
301
+ if status != 200:
302
+ continue
303
+ digest, offset = hashlib.sha256(), 0 # range refused: this 200 is the whole file
304
+ elif not offset and status != 200:
305
+ raise LibError(f"{url}: HTTP {status} for a request without a range", "retry later")
306
+ return self._receive(url, response, part, lock, size, offset, digest, progress)
307
+ except (OSError, http.client.HTTPException, ValueError) as exc:
308
+ raise LibError(f"download of {url} failed ({exc})", _RETRY) from exc
309
+ raise LibError(f"the server for {url} kept refusing to serve the file", "retry later")
310
+
311
+ @staticmethod
312
+ def _receive(
313
+ url: str,
314
+ response: Response,
315
+ part: Path,
316
+ lock: Lock,
317
+ size: int,
318
+ offset: int,
319
+ digest: hashlib._Hash,
320
+ progress: Callable[[int, int], None] | None,
321
+ ) -> tuple[int, str]:
322
+ promised = _content_length(response)
323
+ if promised is not None and promised > size - offset:
324
+ part.unlink(missing_ok=True)
325
+ raise LibError(
326
+ f"{url}: the server announces {promised} bytes where the pin allows {size - offset}",
327
+ "the server is serving a different file; nothing was kept",
328
+ )
329
+ done = offset
330
+ try:
331
+ with part.open("ab" if offset else "wb") as out:
332
+ while chunk := response.read(min(CHUNK, size - done + 1)):
333
+ if done + len(chunk) > size:
334
+ out.close()
335
+ part.unlink(missing_ok=True)
336
+ raise LibError(
337
+ f"{url}: the server sent more than the pinned {size} bytes; discarded",
338
+ "the server is serving a different file",
339
+ )
340
+ out.write(chunk)
341
+ digest.update(chunk)
342
+ done += len(chunk)
343
+ lock.touch()
344
+ if progress:
345
+ progress(done, size)
346
+ except (OSError, http.client.HTTPException, ValueError) as exc:
347
+ raise LibError(f"download of {url} was interrupted at {done} of {size} bytes ({exc})", _RETRY) from exc
348
+ if done < size:
349
+ raise LibError(f"download of {url} was cut off at {done} of {size} bytes", _RETRY)
350
+ return done, digest.hexdigest()
351
+
352
+
353
+ def verify(data: bytes, expected: str, name: str) -> None:
354
+ """Raise unless `data` hashes to `expected`; the message names both digests."""
355
+ actual = sha256(data)
356
+ if actual != expected:
357
+ raise LibError(
358
+ f"{name}: sha256 {actual} does not match the pinned {expected}",
359
+ "the publisher changed the file or the download was altered; nothing was written",
360
+ )
@@ -0,0 +1,150 @@
1
+ """Filesystem helpers that behave the same on Windows, macOS and Linux: atomic write, sha256, a write lock.
2
+
3
+ Lifted from rfclib's `fsutil.py` (`atomic_write_bytes`, `bulk_lock`) and zimlore's `catalog.py::_acquire_lock`.
4
+
5
+ - `atomic_write`: a temp file in the destination directory, then `os.replace`, so a reader never sees half a file
6
+ and a crash leaves no torn one. On Windows `os.replace` fails while another process holds the target open
7
+ (antivirus, search indexers, a concurrent reader), so it retries briefly.
8
+ - `write_lock`: an `O_EXCL` lock file, which works on every OS and on network filesystems. A lock whose last touch
9
+ is older than `stale_after` belonged to a process that died, and is taken over. A long holder calls
10
+ `Lock.touch()` as it works so it is never mistaken for a dead one.
11
+ - `bulk_lock`: one long-running writer per library (`<home>/.write.lock`); short writes rely on SQLite's locking.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import contextlib
17
+ import hashlib
18
+ import os
19
+ import tempfile
20
+ import time
21
+ from collections.abc import Callable, Iterator
22
+ from dataclasses import dataclass
23
+ from pathlib import Path
24
+
25
+ from primarysource.errors import LibError
26
+
27
+ STALE_LOCK_SECONDS = 6 * 3600
28
+ REPLACE_RETRIES = 10
29
+ CHUNK = 1 << 20
30
+
31
+
32
+ def replace(
33
+ source: Path, target: Path, retries: int = REPLACE_RETRIES, sleep: Callable[[float], None] = time.sleep
34
+ ) -> None:
35
+ """`os.replace(source, target)`, retried while Windows reports the target in use."""
36
+ for attempt in range(retries):
37
+ try:
38
+ os.replace(source, target)
39
+ return
40
+ except PermissionError:
41
+ if attempt == retries - 1:
42
+ raise
43
+ sleep(0.05 * (attempt + 1))
44
+
45
+
46
+ def atomic_write(path: Path, data: bytes) -> None:
47
+ """Write `data` to `path` through a temp file in the same directory and `os.replace`.
48
+
49
+ A reader sees the old file or the new one, never a partial one. itulib's index rebuild unlinks first and a
50
+ concurrent reader then sees "no index"; this is the replacement for that.
51
+ """
52
+ path.parent.mkdir(parents=True, exist_ok=True)
53
+ handle, temp_name = tempfile.mkstemp(dir=path.parent, prefix=path.name + ".", suffix=".part")
54
+ try:
55
+ with os.fdopen(handle, "wb") as stream:
56
+ stream.write(data)
57
+ stream.flush()
58
+ os.fsync(stream.fileno())
59
+ replace(Path(temp_name), path)
60
+ except BaseException:
61
+ Path(temp_name).unlink(missing_ok=True)
62
+ raise
63
+
64
+
65
+ def sha256(data: bytes) -> str:
66
+ """The lowercase hex sha256 of `data`."""
67
+ return hashlib.sha256(data).hexdigest()
68
+
69
+
70
+ def sha256_file(path: Path) -> str:
71
+ """The lowercase hex sha256 of the file at `path`, read in chunks."""
72
+ digest = hashlib.sha256()
73
+ with path.open("rb") as stream:
74
+ while chunk := stream.read(CHUNK):
75
+ digest.update(chunk)
76
+ return digest.hexdigest()
77
+
78
+
79
+ class LockHeld(LibError):
80
+ """Another live process holds the lock."""
81
+
82
+
83
+ @dataclass(frozen=True)
84
+ class Lock:
85
+ """A held lock. `touch()` marks the holder alive; a failed touch only risks the lock being called stale."""
86
+
87
+ path: Path
88
+
89
+ def touch(self) -> None:
90
+ with contextlib.suppress(OSError):
91
+ os.utime(self.path)
92
+
93
+
94
+ def _create(lock: Path) -> int | None:
95
+ try:
96
+ return os.open(lock, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
97
+ except FileExistsError:
98
+ return None
99
+
100
+
101
+ @contextlib.contextmanager
102
+ def write_lock(
103
+ lock: Path,
104
+ *,
105
+ stale_after: float = STALE_LOCK_SECONDS,
106
+ what: str = "this library",
107
+ clock: Callable[[], float] = time.time,
108
+ ) -> Iterator[Lock]:
109
+ """Hold the lock file `lock` (created with `O_EXCL`) for the body, and remove it after.
110
+
111
+ A lock untouched for more than `stale_after` seconds is taken over. A younger one raises `LockHeld`, naming
112
+ the holder the lock file records. Two processes that find the same stale lock at the same instant can both
113
+ take it over; after `stale_after` seconds of silence that window is accepted, as rfclib accepted it.
114
+ """
115
+ lock.parent.mkdir(parents=True, exist_ok=True)
116
+ descriptor: int | None = None
117
+ for _ in range(3):
118
+ descriptor = _create(lock)
119
+ if descriptor is not None:
120
+ break
121
+ try:
122
+ age = clock() - lock.stat().st_mtime
123
+ except OSError:
124
+ continue # removed between the create and the stat: try again
125
+ if age <= stale_after:
126
+ holder = ""
127
+ with contextlib.suppress(OSError):
128
+ holder = lock.read_text("utf-8").strip()
129
+ raise LockHeld(
130
+ f"another process is writing {what} ({holder or 'holder unknown'}; lock {lock})",
131
+ f"wait for it to finish; if none is running, delete {lock}",
132
+ )
133
+ with contextlib.suppress(OSError):
134
+ lock.unlink() # stale: its holder died
135
+ if descriptor is None:
136
+ raise LockHeld(f"could not take {lock}", f"if no other process is running, delete {lock}")
137
+ try:
138
+ with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
139
+ handle.write(f"pid {os.getpid()} since {time.strftime('%Y-%m-%d %H:%M:%S')}\n")
140
+ yield Lock(lock)
141
+ finally:
142
+ with contextlib.suppress(OSError):
143
+ lock.unlink()
144
+
145
+
146
+ @contextlib.contextmanager
147
+ def bulk_lock(home: Path, name: str = "write", *, stale_after: float = STALE_LOCK_SECONDS) -> Iterator[Lock]:
148
+ """One long-running writer per library (init, sync, index build): the lock `<home>/.<name>.lock`."""
149
+ with write_lock(home / f".{name}.lock", stale_after=stale_after, what=str(home)) as held:
150
+ yield held
@@ -0,0 +1,15 @@
1
+ """`gitdocs`: git's Documentation/ tree, a pinned release, read as AsciiDoc and served under `gitdocs_`.
2
+
3
+ Stdlib only. `mcp` is imported by `server.py` when a server is built; `primarysource gitdocs ...` runs `cli.py`.
4
+ `docs/gitdocs.md` is the description; `catalogue.py` holds the pin, the licence and the build.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from primarysource.gitdocs.catalogue import SPEC
10
+ from primarysource.pkgdocs.runtime import make_collection
11
+
12
+ COLLECTION = make_collection(SPEC, "primarysource.gitdocs")
13
+ """Refs: `gitdocs:gitformat-pack@03b4152§pack-*.idx files have the following format`; see `pkgdocs.runtime`."""
14
+
15
+ __all__ = ["COLLECTION", "SPEC"]
@@ -0,0 +1,116 @@
1
+ """The `gitdocs` collection: the `Documentation/` tree of git/git, read as AsciiDoc.
2
+
3
+ Source, placement and pin (read from the repository on 2026-11-13, see `docs/gitdocs.md`):
4
+
5
+ - the tree is `Documentation/` at the commit of the tag `v2.56.1`. It is fetched as a gitiles subtree archive from
6
+ kernel.googlesource.com (a few MB), because the whole-repository tarball from GitHub is two orders larger for
7
+ the same files; the GitHub tarball is the second source and must give the same manifest digest;
8
+ - the licence is GPL-2.0-only, from `COPYING` at the repository root (it says "the only valid version of the GPL
9
+ as far as this project is concerned is _this_ particular version", i.e. v2). `COPYING` is not inside the
10
+ `Documentation/` subtree, so it is named as evidence rather than read by the importer;
11
+ - placement is **layer**: the text may be baked into an image. GPL-2.0 asks that the licence and the source stay
12
+ available to the recipient, which a layer that carries `gitdocs status` and the pin satisfies.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import re
18
+ from collections import Counter
19
+ from collections.abc import Mapping
20
+
21
+ from primarysource.errors import LibError
22
+ from primarysource.pkgdocs.fetch import Source
23
+ from primarysource.pkgdocs.model import Doc
24
+ from primarysource.pkgdocs.readers.asciidoc import read_asciidoc
25
+ from primarysource.pkgdocs.runtime import Build, Licence, Pin, Spec, TreeSpec, matching
26
+
27
+ COMMIT = "03b41523c576f26ad34085378717a0c1b287470f"
28
+
29
+ PIN = Pin(
30
+ commit=COMMIT,
31
+ label="v2.56.1",
32
+ date="2026-10-23",
33
+ files_sha256="3a6d1e943dd8fe4aff27ffb5c4c0949a5a0f170b35a1413aecf4ae62e208a9bb",
34
+ sources=(
35
+ Source(
36
+ f"https://kernel.googlesource.com/pub/scm/git/git/+archive/{COMMIT}/Documentation.tar.gz",
37
+ strip=0,
38
+ ),
39
+ Source(
40
+ f"https://codeload.github.com/git/git/tar.gz/{COMMIT}",
41
+ strip=1,
42
+ hosts=("codeload.github.com",),
43
+ subdir="Documentation",
44
+ ),
45
+ ),
46
+ )
47
+
48
+ LICENCE = Licence(
49
+ spdx="GPL-2.0-only",
50
+ redistribute="layer",
51
+ attribution="Copyright the Git contributors",
52
+ evidence="COPYING at git/git " + COMMIT[:12],
53
+ )
54
+
55
+ _MAN_TITLE = re.compile(r"[\w.+-]+\(\d\)")
56
+
57
+
58
+ def _manpage_title(doc: Doc) -> Doc:
59
+ """`git-tag(1)` plus its NAME line's description: `git-tag(1) - Create, list, delete or verify tags`."""
60
+ if not _MAN_TITLE.fullmatch(doc.title):
61
+ return doc
62
+ for section in doc.sections:
63
+ if section.anchor == "name" and section.units:
64
+ _, sep, description = section.units[0].partition(" - ")
65
+ if sep:
66
+ return Doc(
67
+ doc.id,
68
+ f"{doc.title} - {' '.join(description.split())}",
69
+ doc.unit,
70
+ doc.sections,
71
+ doc.meta,
72
+ doc.warnings,
73
+ )
74
+ return doc
75
+
76
+
77
+ def build(files: Mapping[str, bytes], pin: Pin) -> Build:
78
+ docs: list[Doc] = []
79
+ skipped: Counter[str] = Counter()
80
+ for path in sorted(files):
81
+ try:
82
+ doc = read_asciidoc(files[path], path.removesuffix(".adoc"))
83
+ except LibError:
84
+ skipped["not UTF-8 text"] += 1
85
+ continue
86
+ docs.append(_manpage_title(doc).with_meta(path=f"Documentation/{path}"))
87
+ return Build(tuple(docs), {}, skipped)
88
+
89
+
90
+ SPEC = Spec(
91
+ name="gitdocs",
92
+ title="git Documentation",
93
+ licence=LICENCE,
94
+ trees=(
95
+ TreeSpec(
96
+ name="documentation",
97
+ title="Documentation/ of git/git (AsciiDoc)",
98
+ pins=(PIN,),
99
+ include=matching(r"[A-Za-z0-9][A-Za-z0-9._+/-]*\.adoc"),
100
+ build=build,
101
+ ),
102
+ ),
103
+ instructions=(
104
+ "git's own Documentation/ (manual pages, gitformat-*, gitprotocol-*, technical/ notes, release notes) from "
105
+ "the user's imported copy at a pinned release. Loop: gitdocs_search('<words>') or gitdocs_docs('gitformat') "
106
+ "-> gitdocs_outline('<doc>') -> gitdocs_read('<ref>'). A document id is its path without .adoc "
107
+ "(gitformat-pack, technical/commit-graph, RelNotes/2.50.0). A section is an id (4.2), an anchor or a heading "
108
+ "or a unique part of one. include:: lines are not expanded; they read '[include] <path>' and the included "
109
+ "file is its own document. If nothing is imported the error says how."
110
+ ),
111
+ example_refs=(
112
+ "gitdocs:gitformat-pack§pack-*.idx files have the following format",
113
+ "gitdocs:gitformat-pack@03b4152§5.4¶2",
114
+ "gitdocs:technical/commit-graph§Chunk Format",
115
+ ),
116
+ )
@@ -0,0 +1,12 @@
1
+ """`primarysource gitdocs import|status|docs|outline|read|search|cite`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Sequence
6
+
7
+ from primarysource.gitdocs.catalogue import SPEC
8
+ from primarysource.pkgdocs.serving import run
9
+
10
+
11
+ def main(argv: Sequence[str]) -> int:
12
+ return run(SPEC, argv)
@@ -0,0 +1,8 @@
1
+ """The MCP server for `gitdocs` (`primarysource mcp --collection gitdocs`). Imports `mcp` when this module loads."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from primarysource.gitdocs.catalogue import SPEC
6
+ from primarysource.pkgdocs.serving import build_server
7
+
8
+ server = build_server(SPEC)