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,15 @@
1
+ """`pep`: Python Enhancement Proposals, a pinned commit of python/peps, read as reStructuredText. Served under `pep_`.
2
+
3
+ Stdlib only. `mcp` is imported by `server.py` when a server is built; `primarysource pep ...` runs `cli.py`.
4
+ `docs/pep.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.pep.catalogue import SPEC
10
+ from primarysource.pkgdocs.runtime import make_collection
11
+
12
+ COLLECTION = make_collection(SPEC, "primarysource.pep")
13
+ """Refs: `pep:440§Version scheme`; see `pkgdocs.runtime`."""
14
+
15
+ __all__ = ["COLLECTION", "SPEC"]
@@ -0,0 +1,191 @@
1
+ """The `pep` collection: the Python Enhancement Proposals in python/peps, read as reStructuredText.
2
+
3
+ Source, placement and pin (read from the repository on 2026-11-13, see `docs/pep.md`):
4
+
5
+ - the tree is `peps/` of python/peps at one `main` commit, fetched as a GitHub tarball. Each PEP is
6
+ `peps/pep-NNNN.rst`; the few with appendices also have `peps/pep-NNNN/*.rst`;
7
+ - the licence is per document, not per repository (the repository has no LICENSE file at the root). Each PEP's own
8
+ "Copyright" section is the statement, and the build classifies it into the document's `licence` meta. Measured
9
+ at this commit over the 747 PEP files: 246 say "placed in the public domain or under the CC0-1.0-Universal
10
+ license, whichever is more permissive" (`public-domain-or-cc0`), 467 say "public domain" in other words
11
+ (`public-domain`), 6 name the Open Publication License (`open-publication-license`) and 28 carry no statement
12
+ (`unstated`, the oldest); the 14 appendix files are `see-parent-pep`. The import prints none of this, `status`
13
+ does not either: read the meta, or `licence_counts`, when deciding placement per document;
14
+ - placement is **layer** for the collection, with that caveat recorded: the statement is read from the text, the
15
+ repository carries no machine-readable licence for it.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import re
21
+ from collections import Counter
22
+ from collections.abc import Mapping
23
+
24
+ from primarysource.errors import LibError
25
+ from primarysource.model import Unit
26
+ from primarysource.pkgdocs.fetch import Source
27
+ from primarysource.pkgdocs.model import Doc, Sec
28
+ from primarysource.pkgdocs.readers._common import decode
29
+ from primarysource.pkgdocs.readers.rst import read_rst
30
+ from primarysource.pkgdocs.runtime import Build, Licence, Pin, Spec, TreeSpec, matching
31
+
32
+ COMMIT = "155e5bee7d985ef30555b496eb994d24a591f70b"
33
+
34
+ PIN = Pin(
35
+ commit=COMMIT,
36
+ label="main 2026-11-12",
37
+ date="2026-11-12",
38
+ files_sha256="6d4a01026a0a2c4b81ecb18d6e23939cc6ba59f6ffc41b7f3d614238951343e8",
39
+ sources=(
40
+ Source(f"https://codeload.github.com/python/peps/tar.gz/{COMMIT}", strip=1, subdir="peps"),
41
+ Source(
42
+ f"https://github.com/python/peps/archive/{COMMIT}.tar.gz",
43
+ strip=1,
44
+ hosts=("codeload.github.com",),
45
+ subdir="peps",
46
+ ),
47
+ ),
48
+ )
49
+
50
+ LICENCE = Licence(
51
+ spdx="CC0-1.0 OR LicenseRef-public-domain",
52
+ redistribute="layer",
53
+ attribution="each PEP is by its authors; the licence is the one in its own Copyright section",
54
+ evidence="the Copyright section of each pep-NNNN.rst; per-document result in meta `licence`",
55
+ )
56
+
57
+ _FILE = re.compile(r"pep-(\d{4})\.rst")
58
+ _APPENDIX = re.compile(r"pep-(\d{4})/([A-Za-z0-9._-]+)\.rst")
59
+ _KEY = re.compile(r"([A-Za-z][A-Za-z-]*):[ \t]*(.*)")
60
+
61
+
62
+ def _licence_of(text: str) -> str:
63
+ """Which licence statement a PEP carries, from its text: `public-domain-or-cc0` (the standard one),
64
+ `public-domain`, `open-publication-license` or `unstated`."""
65
+ low = text.lower()
66
+ if "placed in the public domain or under the cc0-1.0-universal" in low or "whichever is more permissive" in low:
67
+ return "public-domain-or-cc0"
68
+ if "open publication license" in low:
69
+ return "open-publication-license"
70
+ if "public domain" in low:
71
+ return "public-domain"
72
+ return "unstated"
73
+
74
+
75
+ def split_header(text: str) -> tuple[dict[str, str], str]:
76
+ """The RFC 822 style header at the top of a PEP and the body after it. A text whose first line is not
77
+ `PEP: <n>` has no header: the whole text is the body."""
78
+ head, sep, body = text.partition("\n\n")
79
+ if not head.startswith("PEP:"):
80
+ return {}, text
81
+ fields: dict[str, str] = {}
82
+ last = ""
83
+ for line in head.split("\n"):
84
+ if line[:1] in (" ", "\t") and last:
85
+ fields[last] += " " + line.strip()
86
+ continue
87
+ found = _KEY.fullmatch(line)
88
+ if found:
89
+ last = found.group(1).lower().replace("-", "_")
90
+ fields[last] = found.group(2).strip()
91
+ return fields, body if sep else ""
92
+
93
+
94
+ def read_pep(data: bytes, doc_id: str, number: str) -> Doc:
95
+ """A PEP: its header as block 1 of section `0`, its meta from the header, its body as reStructuredText."""
96
+ text = decode(data, f"PEP {number}")
97
+ fields, body = split_header(text)
98
+ title = f"PEP {int(number)} - {fields['title']}" if fields.get("title") else f"PEP {int(number)}"
99
+ doc = read_rst(body.encode("utf-8"), doc_id, title)
100
+ header = text[: len(text) - len(body)].rstrip() if fields else ""
101
+ sections = doc.sections
102
+ if header:
103
+ if sections and sections[0].id == "0":
104
+ first = sections[0]
105
+ sections = (Sec("0", first.title, first.depth, first.anchor, (header, *first.units)), *sections[1:])
106
+ else:
107
+ sections = (Sec("0", title, 1, "header", (header,)), *sections)
108
+ meta = {k: v for k, v in fields.items() if k in _META_KEYS}
109
+ meta["licence"] = _licence_of(body)
110
+ return Doc(doc_id, title, Unit.BLOCK, sections, meta, doc.warnings)
111
+
112
+
113
+ _META_KEYS = frozenset(
114
+ {
115
+ "title",
116
+ "author",
117
+ "status",
118
+ "type",
119
+ "topic",
120
+ "created",
121
+ "python_version",
122
+ "replaces",
123
+ "superseded_by",
124
+ "requires",
125
+ "resolution",
126
+ "discussions_to",
127
+ "post_history",
128
+ "pep",
129
+ }
130
+ )
131
+
132
+
133
+ def build(files: Mapping[str, bytes], pin: Pin) -> Build:
134
+ docs: list[Doc] = []
135
+ aliases: dict[str, str] = {}
136
+ skipped: Counter[str] = Counter()
137
+ for path in sorted(files):
138
+ main = _FILE.fullmatch(path)
139
+ appendix = _APPENDIX.fullmatch(path)
140
+ try:
141
+ if main:
142
+ number = main.group(1)
143
+ doc_id = str(int(number))
144
+ doc = read_pep(files[path], doc_id, number)
145
+ aliases[f"pep-{number}"] = doc_id
146
+ aliases[number] = doc_id
147
+ elif appendix:
148
+ number = appendix.group(1)
149
+ doc_id = f"{int(number)}/{appendix.group(2)}"
150
+ doc = read_rst(files[path], doc_id, f"PEP {int(number)} appendix {appendix.group(2)}").with_meta(
151
+ licence="see-parent-pep"
152
+ )
153
+ else:
154
+ skipped["not a PEP source"] += 1
155
+ continue
156
+ except LibError:
157
+ skipped["not UTF-8 text"] += 1
158
+ continue
159
+ docs.append(doc.with_meta(path=f"peps/{path}"))
160
+ return Build(tuple(docs), aliases, skipped)
161
+
162
+
163
+ def licence_counts(docs: Mapping[str, Doc] | list[Doc]) -> Counter[str]:
164
+ """How many documents carry each licence statement (for the import report and the tests)."""
165
+ values = docs.values() if isinstance(docs, Mapping) else docs
166
+ return Counter(d.meta.get("licence", "unstated") for d in values)
167
+
168
+
169
+ SPEC = Spec(
170
+ name="pep",
171
+ title="Python Enhancement Proposals",
172
+ licence=LICENCE,
173
+ trees=(
174
+ TreeSpec(
175
+ name="peps",
176
+ title="peps/ of python/peps (reStructuredText)",
177
+ pins=(PIN,),
178
+ include=matching(r"pep-\d{4}\.rst|pep-\d{4}/[A-Za-z0-9._-]+\.rst"),
179
+ build=build,
180
+ ),
181
+ ),
182
+ instructions=(
183
+ "Python Enhancement Proposals (PEP 440 versions, 427/491 wheels, 517/621 builds and metadata, ...) from "
184
+ "the user's imported copy of python/peps at a pinned commit. Loop: pep_search('<words>') -> "
185
+ "pep_outline('440') -> pep_read('<ref>'). A document id is the PEP number without leading zeros ('440'); "
186
+ "'pep-0440' and '0440' work too, and '440/appendix-x' names an appendix. Section 0 holds the header "
187
+ "(status, authors, dates). A section is an id (3.1), an anchor (version-scheme), a heading or a unique "
188
+ "part of one. Each document's licence statement is in its meta and shown by pep_status counts."
189
+ ),
190
+ example_refs=("pep:440§Version scheme", "pep:440@155e5be§3.1¶2", "pep:517§Build backend interface"),
191
+ )
@@ -0,0 +1,12 @@
1
+ """`primarysource pep import|status|docs|outline|read|search|cite`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Sequence
6
+
7
+ from primarysource.pep.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 `pep` (`primarysource mcp --collection pep`). Imports `mcp` when this module loads."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from primarysource.pep.catalogue import SPEC
6
+ from primarysource.pkgdocs.serving import build_server
7
+
8
+ server = build_server(SPEC)
@@ -0,0 +1,19 @@
1
+ """Shared plumbing for the packaging and tooling specification collections: `gitdocs`, `debpolicy`, `rpmdocs`,
2
+ `pms`, `pep` and `kconfig`.
3
+
4
+ Every one of them is published as text in a git repository (AsciiDoc, reStructuredText, Markdown, scdoc, POD,
5
+ LaTeX). They share one shape, so the shape lives here once and each collection directory holds only what is its
6
+ own: the pinned trees, the licence, the choice of files, the document ids and the ref spelling.
7
+
8
+ - `readers/`: minimal, bounded readers, one per format, bytes in and a `Doc` out. Nothing here opens a path a
9
+ document names, runs a command or imports anything outside the standard library. These are the proposed core
10
+ readers (ROADMAP rows `cl.reader_markdown`, `cl.reader_rst`, ...); they are private to this package until two
11
+ collections outside it need one.
12
+ - `fetch.py`: a pinned archive streamed, never extracted to disk (the proposed `cl.fetch_git_tree_pinned`).
13
+ - `store.py`, `index.py`: an installed edition under `$PRIMARYSOURCE_HOME/<collection>/<tree>/<commit>/`, and its
14
+ FTS5 index.
15
+ - `runtime.py`, `serving.py`, `cli.py`: read, outline, search, cite and docs, and the MCP tools and command line
16
+ that expose them under each collection's prefix.
17
+
18
+ No document text is kept in this repository: tests use invented text.
19
+ """
@@ -0,0 +1,356 @@
1
+ """A pinned source archive, streamed: verified, bounded, never extracted to disk.
2
+
3
+ `fetch_tree` opens a gzip or plain tar over HTTPS (or `read_tree` takes any binary stream, which is how a local
4
+ file and a test's in-memory archive come in), walks the members in stream mode with `tarfile` mode `r|`, and keeps
5
+ only the regular files `include` accepts, in memory.
6
+
7
+ What is pinned is **the selected files**, not the archive: `manifest_digest` is the sha256 of one line per kept
8
+ file, `<sha256 of its bytes> <path>`, sorted by path. It is checked before anything is returned, so a damaged,
9
+ truncated or substituted archive yields nothing. It is independent of how the archive was made, which matters:
10
+ a forge tarball, a `git archive` and a gitiles subtree archive of the same commit are three different byte
11
+ streams (gitiles stamps each header with the time of the request, so its tar differs from one fetch to the next,
12
+ measured), and the same selected files give the same manifest from all three. The digests of the `.tar` and the
13
+ `.gz` are reported but are not what a pin means.
14
+
15
+ Bounds, each a `LibError` naming what to do:
16
+
17
+ - compressed bytes, decompressed bytes (a gzip bomb), member count, bytes per kept member, bytes in all;
18
+ - a kept member whose header declares more than the per-member bound is refused before its data is read;
19
+ - links, devices and directories are skipped; a member name that is absolute, has a `..` part, a NUL or a
20
+ backslash is skipped and counted, never joined to a path;
21
+ - a redirect only to a host the source lists;
22
+ - one request per second (`Throttle`), whichever source is tried.
23
+
24
+ This is the interim form of the proposed core function `cl.fetch_git_tree_pinned`. Two things it would take from
25
+ sibling projects rather than own: from `archivekit` (D:\\code\\archivekit) the bounded stream reader for tar and
26
+ gzip members and its safe-name rules; from `gitobj` (D:\\code\\gitobj) a git tree hash, so a pin can name the
27
+ tree object (`git rev-parse <commit>^{tree}`) and the digest of the files read can be checked against it, which
28
+ holds whichever server or compression produced the bytes.
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ import gzip
34
+ import hashlib
35
+ import http.client
36
+ import io
37
+ import re
38
+ import tarfile
39
+ import time
40
+ import urllib.error
41
+ import urllib.request
42
+ import zlib
43
+ from collections import Counter
44
+ from collections.abc import Callable, Mapping, Sequence
45
+ from dataclasses import dataclass, field
46
+ from typing import IO, Any, Protocol
47
+ from urllib.parse import urlsplit
48
+
49
+ from primarysource import __version__
50
+ from primarysource.errors import LibError
51
+
52
+ MIN_INTERVAL = 1.0
53
+ _CHUNK = 1 << 20
54
+ _USER_AGENT = f"primarysource/{__version__} (pinned archive reader)"
55
+
56
+
57
+ class Refused(LibError):
58
+ """A failure that no other source can cure: a digest that is not the pin, a bound exceeded, an unlisted
59
+ redirect. `fetch_tree` raises it at once instead of trying the next mirror."""
60
+
61
+
62
+ @dataclass(frozen=True, slots=True)
63
+ class Source:
64
+ """Where a tree can be fetched. `strip` leading path components are removed from member names (1 for a
65
+ forge tarball's top directory, 0 for a subtree archive). With `subdir`,
66
+ only members below it are read and the prefix is removed, so one tree spec serves a whole-repository tarball
67
+ and a subtree archive alike. `hosts` are the hosts a redirect may lead to, besides the URL's own."""
68
+
69
+ url: str
70
+ strip: int = 1
71
+ hosts: tuple[str, ...] = ()
72
+ subdir: str = ""
73
+
74
+
75
+ @dataclass(frozen=True, slots=True)
76
+ class Limits:
77
+ """Ceilings on what a fetch will accept."""
78
+
79
+ compressed_bytes: int = 150_000_000
80
+ tar_bytes: int = 800_000_000
81
+ members: int = 100_000
82
+ member_bytes: int = 8_000_000
83
+ total_bytes: int = 400_000_000
84
+
85
+
86
+ @dataclass(frozen=True, slots=True)
87
+ class Fetched:
88
+ """The kept files and what was measured while reading them."""
89
+
90
+ files: dict[str, bytes]
91
+ files_sha256: str
92
+ tar_sha256: str
93
+ gz_sha256: str | None
94
+ members: int
95
+ tar_bytes: int
96
+ kept_bytes: int
97
+ skipped: dict[str, int] = field(default_factory=dict)
98
+ url: str = ""
99
+ pinned: bool = True
100
+
101
+
102
+ class Throttle:
103
+ """At most one call per `interval` seconds; the clock and the sleep are injectable for tests."""
104
+
105
+ def __init__(
106
+ self,
107
+ interval: float = MIN_INTERVAL,
108
+ clock: Callable[[], float] = time.monotonic,
109
+ sleep: Callable[[float], None] = time.sleep,
110
+ ) -> None:
111
+ self.interval = interval
112
+ self._clock = clock
113
+ self._sleep = sleep
114
+ self._last: float | None = None
115
+
116
+ def wait(self) -> None:
117
+ now = self._clock()
118
+ if self._last is not None and now - self._last < self.interval:
119
+ self._sleep(self.interval - (now - self._last))
120
+ now = self._clock()
121
+ self._last = now
122
+
123
+
124
+ DEFAULT_THROTTLE = Throttle()
125
+
126
+
127
+ class _Reader(Protocol):
128
+ def read(self, size: int = -1, /) -> bytes: ...
129
+
130
+
131
+ class _Counting:
132
+ """Wraps a stream: counts and hashes what passes through and stops at a ceiling."""
133
+
134
+ def __init__(self, stream: _Reader, ceiling: int, what: str, hint: str) -> None:
135
+ self._stream = stream
136
+ self._ceiling = ceiling
137
+ self._what = what
138
+ self._hint = hint
139
+ self.count = 0
140
+ self.tail = b""
141
+ self.digest = hashlib.sha256()
142
+
143
+ def read(self, size: int = -1, /) -> bytes:
144
+ chunk = self._stream.read(size if size > 0 else _CHUNK)
145
+ self.count += len(chunk)
146
+ if self.count > self._ceiling:
147
+ raise Refused(
148
+ f"{self._what} is over {self._ceiling:,} bytes; this is not the expected archive", self._hint
149
+ )
150
+ self.digest.update(chunk)
151
+ self.tail = (self.tail + chunk)[-1024:]
152
+ return chunk
153
+
154
+
155
+ class _Prefixed:
156
+ """A stream with some bytes already read from it put back in front."""
157
+
158
+ def __init__(self, prefix: bytes, stream: _Reader) -> None:
159
+ self._prefix = prefix
160
+ self._stream = stream
161
+
162
+ def read(self, size: int = -1, /) -> bytes:
163
+ if not self._prefix:
164
+ return self._stream.read(size)
165
+ if size < 0:
166
+ head, self._prefix = self._prefix, b""
167
+ return head + self._stream.read()
168
+ head, self._prefix = self._prefix[:size], self._prefix[size:]
169
+ if len(head) < size:
170
+ head += self._stream.read(size - len(head))
171
+ return head
172
+
173
+
174
+ def _safe_name(name: str, strip: int, subdir: str = "") -> str | None:
175
+ """The member's path inside the tree, or None when it is unsafe; an empty string when it is not below the
176
+ stripped prefix (or `subdir`)."""
177
+ if "\x00" in name or "\\" in name or name.startswith("/") or re.match(r"[A-Za-z]:", name):
178
+ return None
179
+ parts = [p for p in name.split("/") if p not in ("", ".")]
180
+ if ".." in parts:
181
+ return None
182
+ if len(parts) <= strip:
183
+ return ""
184
+ inside = parts[strip:]
185
+ if subdir:
186
+ head = [p for p in subdir.split("/") if p]
187
+ if inside[: len(head)] != head or len(inside) == len(head):
188
+ return ""
189
+ inside = inside[len(head) :]
190
+ return "/".join(inside)
191
+
192
+
193
+ def manifest_digest(files: Mapping[str, bytes]) -> str:
194
+ """The pin of a set of files: sha256 over `<sha256 of the bytes> <path>` lines, sorted by path."""
195
+ lines = "".join(f"{hashlib.sha256(files[path]).hexdigest()} {path}\n" for path in sorted(files))
196
+ return hashlib.sha256(lines.encode("utf-8")).hexdigest()
197
+
198
+
199
+ def read_tree(
200
+ stream: _Reader,
201
+ source: Source,
202
+ *,
203
+ include: Callable[[str], bool],
204
+ expect: str | None = None,
205
+ limits: Limits | None = None,
206
+ ) -> Fetched:
207
+ """Walk one archive stream. Raises `LibError` for a manifest digest that is not `expect` (when given), a bound
208
+ exceeded, or an archive that is truncated or damaged; returns nothing partial."""
209
+ limits = limits or Limits()
210
+ remedy = "fetch the archive again; if it keeps failing, the pin in the catalogue is wrong or the source changed"
211
+ raw = _Counting(stream, limits.compressed_bytes, "the archive", remedy)
212
+ magic = raw.read(2)
213
+ gz = magic == b"\x1f\x8b"
214
+ prefixed: Any = _Prefixed(magic, raw)
215
+ plain: Any = gzip.GzipFile(fileobj=prefixed, mode="rb") if gz else prefixed
216
+ tar_stream = _Counting(plain, limits.tar_bytes, "the decompressed archive", remedy)
217
+ files: dict[str, bytes] = {}
218
+ skipped: Counter[str] = Counter()
219
+ kept = 0
220
+ members = 0
221
+ try:
222
+ with tarfile.open(fileobj=tar_stream, mode="r|") as tar: # type: ignore[call-overload]
223
+ for member in tar:
224
+ members += 1
225
+ if members > limits.members:
226
+ raise Refused(f"the archive has more than {limits.members:,} members", remedy)
227
+ if not member.isreg():
228
+ skipped["link, directory or special file"] += 1
229
+ continue
230
+ name = _safe_name(member.name, source.strip, source.subdir)
231
+ if name is None:
232
+ skipped["unsafe member name"] += 1
233
+ continue
234
+ if not name or not include(name):
235
+ skipped["not selected"] += 1
236
+ continue
237
+ if member.size > limits.member_bytes:
238
+ raise Refused(
239
+ f"refusing {member.name!r}: it declares {member.size:,} bytes, over the "
240
+ f"{limits.member_bytes:,} "
241
+ "limit per file",
242
+ remedy,
243
+ )
244
+ handle = tar.extractfile(member)
245
+ data = handle.read(limits.member_bytes + 1) if handle is not None else b""
246
+ if len(data) > limits.member_bytes:
247
+ raise Refused(f"refusing {member.name!r}: over the per-file limit", remedy)
248
+ kept += len(data)
249
+ if kept > limits.total_bytes:
250
+ raise Refused(f"the selected files total more than {limits.total_bytes:,} bytes", remedy)
251
+ files[name] = data
252
+ while tar_stream.read(_CHUNK): # the trailing padding, and the gzip trailer's CRC check
253
+ pass
254
+ # tarfile treats a header cut short as the end of the archive, so check the end marker ourselves:
255
+ # a whole number of 512-byte blocks whose last block is zero
256
+ if tar_stream.count % 512 or tar_stream.tail[-512:] != bytes(512):
257
+ raise LibError("the archive is truncated (it does not end with the tar end-of-archive marker)", remedy)
258
+ except Refused:
259
+ raise
260
+ except (tarfile.TarError, EOFError, gzip.BadGzipFile, zlib.error, http.client.HTTPException, OSError) as exc:
261
+ raise LibError(f"the archive is truncated or damaged ({type(exc).__name__}: {exc})", remedy) from exc
262
+ digest = manifest_digest(files)
263
+ if expect is not None and digest != expect:
264
+ raise Refused(
265
+ f"the selected files have manifest sha256 {digest}, not the {expect} pinned for them",
266
+ "the commit is not the pinned one, the source changed, or the pin is wrong; check the commit, then "
267
+ "update the catalogue's pin",
268
+ )
269
+ return Fetched(
270
+ files=files,
271
+ files_sha256=digest,
272
+ tar_sha256=tar_stream.digest.hexdigest(),
273
+ gz_sha256=raw.digest.hexdigest() if gz else None,
274
+ members=members,
275
+ tar_bytes=tar_stream.count,
276
+ kept_bytes=kept,
277
+ skipped=dict(skipped),
278
+ url=source.url,
279
+ pinned=expect is not None,
280
+ )
281
+
282
+
283
+ class _Redirects(urllib.request.HTTPRedirectHandler):
284
+ def __init__(self, hosts: frozenset[str]) -> None:
285
+ self._hosts = hosts
286
+
287
+ def redirect_request(self, req: Any, fp: Any, code: int, msg: str, headers: Any, newurl: str) -> Any:
288
+ target = urlsplit(newurl)
289
+ if target.scheme != "https" or (target.hostname or "") not in self._hosts:
290
+ raise Refused(
291
+ f"refusing a redirect to {newurl!r}: its host is not one the catalogue lists for this source",
292
+ "add the host to the source's `hosts` only after checking it is the forge's own download host",
293
+ )
294
+ return super().redirect_request(req, fp, code, msg, headers, newurl)
295
+
296
+
297
+ Opener = Callable[[urllib.request.Request], IO[bytes]]
298
+
299
+
300
+ def _default_opener(source: Source) -> Opener:
301
+ hosts = frozenset({urlsplit(source.url).hostname or "", *source.hosts})
302
+ return urllib.request.build_opener(_Redirects(hosts)).open
303
+
304
+
305
+ def fetch_tree(
306
+ sources: Sequence[Source],
307
+ *,
308
+ include: Callable[[str], bool],
309
+ expect: str | None = None,
310
+ limits: Limits | None = None,
311
+ opener: Opener | None = None,
312
+ throttle: Throttle | None = None,
313
+ timeout: float = 60.0,
314
+ ) -> Fetched:
315
+ """Try each source in order; the first that reads and verifies wins. A digest mismatch is raised at once and
316
+ does not fall through to the next mirror (a mirror that disagrees is a finding, not a retry)."""
317
+ if not sources:
318
+ raise LibError("no source is listed for this edition", "add one to the catalogue")
319
+ wait = throttle or DEFAULT_THROTTLE
320
+ failures: list[str] = []
321
+ for source in sources:
322
+ if not source.url.startswith("https://"):
323
+ raise Refused(f"refusing {source.url!r}: only https sources are read", "")
324
+ wait.wait()
325
+ request = urllib.request.Request(source.url, headers={"User-Agent": _USER_AGENT}) # noqa: S310
326
+ try:
327
+ handle = (opener or _default_opener(source))(request)
328
+ with handle:
329
+ return read_tree(handle, source, include=include, expect=expect, limits=limits)
330
+ except Refused:
331
+ raise
332
+ except LibError as exc:
333
+ failures.append(f"{source.url}: {exc.message}")
334
+ except (urllib.error.URLError, TimeoutError, OSError, http.client.HTTPException) as exc:
335
+ failures.append(f"{source.url}: {exc}")
336
+ raise LibError(
337
+ "no source could be read:\n " + "\n ".join(failures), "check the network, or pass a downloaded archive"
338
+ )
339
+
340
+
341
+ def read_local(
342
+ path: str,
343
+ source: Source,
344
+ *,
345
+ include: Callable[[str], bool],
346
+ expect: str | None = None,
347
+ limits: Limits | None = None,
348
+ ) -> Fetched:
349
+ """The same walk over a file the user downloaded."""
350
+ try:
351
+ with open(path, "rb") as stream: # noqa: PTH123
352
+ return read_tree(io.BufferedReader(stream), source, include=include, expect=expect, limits=limits)
353
+ except FileNotFoundError as exc:
354
+ raise LibError(
355
+ f"no file at {path}", "download the archive first, or omit --archive to fetch the pinned one"
356
+ ) from exc