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/refs.py ADDED
@@ -0,0 +1,87 @@
1
+ """One ref grammar, parameterised by what a document id looks like.
2
+
3
+ The three libraries that have a ref string agree on its shape and differ only in the document id:
4
+
5
+ RFC9000§9.6¶2 X.690§11.6¶2 devdocs_en_flask§blueprints/index#why¶3
6
+
7
+ So the grammar is `<doc> § <section> [#anchor] [¶ n]`, or `<doc> : first-last` for a line range. `::` is the
8
+ ASCII spelling of `§`. A library supplies the pattern for its document ids and, if its sections are not dotted
9
+ numbers, the pattern for those.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import re
15
+ from dataclasses import dataclass
16
+ from typing import Protocol
17
+
18
+ from primarysource.errors import LibError
19
+
20
+ DOTTED_SECTION = r"[0-9A-Za-z]+(?:\.[0-9A-Za-z]+)*"
21
+
22
+
23
+ @dataclass(frozen=True, slots=True)
24
+ class Ref:
25
+ """A parsed ref. Every part but one may be None; `lines` is (first, last), 1-based and inclusive."""
26
+
27
+ doc: str | None = None
28
+ section: str | None = None
29
+ para: int | None = None
30
+ anchor: str | None = None
31
+ lines: tuple[int, int] | None = None
32
+
33
+
34
+ class RefParser(Protocol):
35
+ """What a collection's refs need: text to `Ref` and back. `RefGrammar` is one; a collection whose refs are not
36
+ `<doc>§<section>` (an id-keyed registry such as `usbids:1d6b:0002`) supplies its own object with these two
37
+ methods, raising `LibError` for text that is not a ref."""
38
+
39
+ def parse(self, text: str) -> Ref: ...
40
+
41
+ def format(self, ref: Ref) -> str: ...
42
+
43
+
44
+ class RefGrammar:
45
+ """Parses and formats refs for one library."""
46
+
47
+ def __init__(self, doc_pattern: str, section_pattern: str = DOTTED_SECTION) -> None:
48
+ self._pattern = re.compile(
49
+ rf"\s*(?P<doc>{doc_pattern})?\s*"
50
+ rf"(?:(?:§|::)\s*(?P<section>{section_pattern}))?"
51
+ r"(?:#(?P<anchor>[^\s¶#]+))?"
52
+ r"(?:\s*¶\s*(?P<para>\d+))?"
53
+ r"(?::(?P<first>\d+)-(?P<last>\d+))?\s*"
54
+ )
55
+
56
+ def parse(self, text: str) -> Ref:
57
+ """Parse `text`, or raise LibError. A string that names nothing is refused, never guessed at."""
58
+ match = self._pattern.fullmatch(text)
59
+ if match is None or not any(match.group(name) for name in ("doc", "section", "anchor", "first")):
60
+ raise LibError(f"not a ref: {text!r}", "expected <doc>§<section>[#anchor][¶n] or <doc>:<first>-<last>")
61
+ lines: tuple[int, int] | None = None
62
+ if match.group("first"):
63
+ if match.group("section") or match.group("para") or match.group("anchor"):
64
+ raise LibError(f"not a ref: {text!r}", "a line range cannot be combined with a section")
65
+ lines = (int(match.group("first")), int(match.group("last")))
66
+ if lines[0] < 1 or lines[1] < lines[0]:
67
+ raise LibError(f"not a line range: {text!r}", "lines are 1-based and first <= last")
68
+ para = int(match.group("para")) if match.group("para") else None
69
+ if para is not None and para < 1:
70
+ raise LibError(f"not a ref: {text!r}", "¶ is 1-based")
71
+ if para is not None and not (match.group("section") or match.group("anchor")):
72
+ raise LibError(f"not a ref: {text!r}", "¶ needs a section or an anchor to count within")
73
+ return Ref(match.group("doc"), match.group("section"), para, match.group("anchor"), lines)
74
+
75
+ @staticmethod
76
+ def format(ref: Ref) -> str:
77
+ """The canonical spelling: no spaces, `§` for the section."""
78
+ out = ref.doc or ""
79
+ if ref.lines is not None:
80
+ return f"{out}:{ref.lines[0]}-{ref.lines[1]}"
81
+ if ref.section is not None:
82
+ out += f"§{ref.section}"
83
+ if ref.anchor is not None:
84
+ out += f"#{ref.anchor}"
85
+ if ref.para is not None:
86
+ out += f"¶{ref.para}"
87
+ return out
@@ -0,0 +1,161 @@
1
+ """The collection registry: how the core finds a library without importing it.
2
+
3
+ A collection (`nist`, `itu`, `ieee`, ...) is one corpus with its own database, tool-name prefix, declared unit
4
+ and ref grammar (PLAN.md §5.8). The core never imports a collection by name. It reads two sources of
5
+ `name -> "module:attribute"` targets and imports a target only when that collection is asked for:
6
+
7
+ - `BUILTIN`: the collections that ship in this package as subpackages, each behind its own extra;
8
+ - the `primarysource.collections` entry-point group: a separately installed package that registers itself the
9
+ same way, for a collection with its own licence or release cadence.
10
+
11
+ Either way the target is a module attribute holding a `Collection`. A collection whose extra is not installed
12
+ fails with a `LibError` that names the install command, never with a bare `ImportError`.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import importlib
18
+ import re
19
+ from collections.abc import Mapping
20
+ from dataclasses import dataclass
21
+ from importlib.metadata import entry_points
22
+ from typing import Any
23
+
24
+ from primarysource.errors import LibError
25
+ from primarysource.model import Unit
26
+ from primarysource.refs import RefParser
27
+
28
+ ENTRY_POINT_GROUP = "primarysource.collections"
29
+
30
+ # name -> "module:attribute" or "module:attribute [extra]", the entry-point spelling. A collection is added here
31
+ # when it moves in (PLAN.md §6). Without "[extra]" the extra is the collection's name; two collections that share
32
+ # one extra (posix and ieee802 in `[ieee]`) both name it.
33
+ BUILTIN: dict[str, str] = {
34
+ "rfc": "primarysource.rfc.collection:COLLECTION [rfc]",
35
+ "nist": "primarysource.nist.collection:COLLECTION",
36
+ "usb": "primarysource.usb.collection:COLLECTION",
37
+ "itu": "primarysource.itu.collection:COLLECTION",
38
+ "posix": "primarysource.ieee:POSIX [ieee]",
39
+ "ieee802": "primarysource.ieee:IEEE802 [ieee]",
40
+ "iana": "primarysource.iana:COLLECTION [iana]",
41
+ "cavp": "primarysource.cavp:COLLECTION",
42
+ "usbids": "primarysource.usbids:COLLECTION",
43
+ "draft": "primarysource.draft.collection:COLLECTION [draft]",
44
+ "c2sp": "primarysource.c2sp:C2SP",
45
+ "bbf": "primarysource.bbf:BBF [bbf]",
46
+ "ecma": "primarysource.ecma:ECMA [ecma]",
47
+ "gitdocs": "primarysource.gitdocs:COLLECTION",
48
+ "debpolicy": "primarysource.debpolicy:COLLECTION",
49
+ "rpmdocs": "primarysource.rpmdocs:COLLECTION",
50
+ "pms": "primarysource.pms:COLLECTION",
51
+ "pep": "primarysource.pep:COLLECTION",
52
+ "kconfig": "primarysource.kconfig:COLLECTION",
53
+ "iso": "primarysource.iso:ISO [iso]",
54
+ "zim": "primarysource.zim.collection:COLLECTION [zim]",
55
+ "cve": "primarysource.cve.collection:COLLECTION",
56
+ }
57
+
58
+ _NAME = re.compile(r"[a-z][a-z0-9]*")
59
+ _SPEC = re.compile(r"\s*(?P<target>[\w.]+:[\w.]+)\s*(?:\[\s*(?P<extra>[\w-]+)\s*\])?\s*")
60
+
61
+
62
+ def _split(spec: str) -> tuple[str, str | None]:
63
+ """`module:attribute [extra]` -> (`module:attribute`, `extra` or None)."""
64
+ match = _SPEC.fullmatch(spec)
65
+ if match is None:
66
+ raise LibError(f"malformed collection target {spec!r}", "expected 'module:attribute [extra]'")
67
+ return match["target"], match["extra"]
68
+
69
+
70
+ @dataclass(frozen=True, slots=True)
71
+ class Collection:
72
+ """What the core needs to know to serve a collection. Builders (index, readers, tools) are added as the core
73
+ grows; this is the part every collection has today.
74
+
75
+ `server` and `cli` are "module:attribute" targets, imported only when used, so a collection can be listed and
76
+ described without its extra installed: `server` names the `MCPServer` object, `cli` a `main(argv)` function
77
+ that runs the collection's own commands."""
78
+
79
+ name: str
80
+ title: str
81
+ prefix: str
82
+ unit: Unit
83
+ grammar: RefParser
84
+ server: str = ""
85
+ cli: str = ""
86
+ identify: str = ""
87
+ """A "module:function" taking an `identify.Probe` and returning `identify.Candidate`s: how this collection
88
+ recognises its own documents in a supplied file. Empty when it has none."""
89
+
90
+ def __post_init__(self) -> None:
91
+ if not _NAME.fullmatch(self.name):
92
+ raise ValueError(f"collection name {self.name!r} must be lowercase letters and digits")
93
+ if self.prefix not in ("", f"{self.name}_"):
94
+ raise ValueError(f"tool prefix for {self.name!r} must be {self.name + '_'!r} or empty")
95
+
96
+
97
+ @dataclass(frozen=True, slots=True)
98
+ class _Target:
99
+ spec: str
100
+ install: str
101
+ # the hint when the collection's MCP server is what is missing: its own extra plus primarysource[mcp]
102
+ server_install: str = ""
103
+
104
+
105
+ def _targets() -> dict[str, _Target]:
106
+ """Every registered collection, without importing any of them. A built-in wins over a plugin of the same
107
+ name, so an installed package cannot shadow one shipped here."""
108
+ found: dict[str, _Target] = {}
109
+ for ep in entry_points(group=ENTRY_POINT_GROUP):
110
+ dist = ep.dist.name if ep.dist is not None else ep.name
111
+ target, extra = _split(ep.value)
112
+ package = f"{dist}[{extra}]" if extra else dist
113
+ found[ep.name] = _Target(target, f'pip install "{package}"', f'pip install "{package}" "primarysource[mcp]"')
114
+ for name, spec in BUILTIN.items():
115
+ target, extra = _split(spec)
116
+ found[name] = _Target(
117
+ target,
118
+ f'pip install "primarysource[{extra or name}]"',
119
+ f'pip install "primarysource[{extra or name},mcp]"',
120
+ )
121
+ return found
122
+
123
+
124
+ def available() -> tuple[str, ...]:
125
+ """Names of every registered collection, sorted. Nothing is imported."""
126
+ return tuple(sorted(_targets()))
127
+
128
+
129
+ def load(name: str, targets: Mapping[str, _Target] | None = None) -> Collection:
130
+ """Import and return one collection, or raise a `LibError` that says what to do."""
131
+ known = _targets() if targets is None else targets
132
+ target = known.get(name)
133
+ if target is None:
134
+ names = ", ".join(sorted(known)) or "none"
135
+ raise LibError(f"no collection named {name!r} (registered: {names})", "primarysource collections")
136
+ module_name, _, attribute = target.spec.partition(":")
137
+ try:
138
+ module = importlib.import_module(module_name)
139
+ except ModuleNotFoundError as exc:
140
+ raise LibError(f"collection {name!r} needs {exc.name!r}, which is not installed", target.install) from exc
141
+ collection = getattr(module, attribute, None)
142
+ if not isinstance(collection, Collection) or collection.name != name:
143
+ raise LibError(f"{target.spec} does not hold the {name!r} collection", "")
144
+ return collection
145
+
146
+
147
+ def attribute(collection: Collection, spec: str, what: str) -> Any:
148
+ """Import a collection's `server` or `cli` target, mapping a missing dependency to the install hint."""
149
+ if not spec:
150
+ raise LibError(f"collection {collection.name!r} has no {what}", "")
151
+ module_name, _, name = spec.partition(":")
152
+ try:
153
+ module = importlib.import_module(module_name)
154
+ except ModuleNotFoundError as exc:
155
+ target = _targets().get(collection.name)
156
+ if target is None:
157
+ hint = f'pip install "primarysource[{collection.name}]"'
158
+ else:
159
+ hint = target.server_install if what == "server" and target.server_install else target.install
160
+ raise LibError(f"the {collection.name!r} {what} needs {exc.name!r}, which is not installed", hint) from exc
161
+ return getattr(module, name)
@@ -0,0 +1,49 @@
1
+ """The text shape of a tool result: body first, then footer lines in one fixed order.
2
+
3
+ Decision recorded in PLAN.md: results are text, because the passage dominates the tokens and a JSON string
4
+ escapes every newline in it. What a JSON result carried as fields is carried as `key: value` footer lines, in
5
+ the order below, so a reader (or a regex) finds them in the same place in every library.
6
+
7
+ The `next:` value is exactly what to pass back (PLAN.md §5.11): a ref string for a read, which `read` accepts as
8
+ its `ref`, and `start=<n>` for a list (search, outline, browse, grep), a 0-based offset for the same tool's
9
+ `start` argument. `next_start` and `parse_next_start` are the two halves of the list cursor.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import re
15
+ from collections.abc import Mapping
16
+
17
+ FOOTER_ORDER = ("cite", "unit", "span", "next", "relaxed", "confidence", "partial", "warning")
18
+
19
+ _NEXT_START = re.compile(r"start=(0|[1-9][0-9]*)")
20
+
21
+
22
+ def render(body: str, footer: Mapping[str, str]) -> str:
23
+ """Body, a blank line, then the known footer keys in FOOTER_ORDER. Unknown keys are a bug, not output."""
24
+ unknown = sorted(set(footer) - set(FOOTER_ORDER))
25
+ if unknown:
26
+ raise ValueError(f"unknown footer keys: {', '.join(unknown)}")
27
+ lines = [f"{key}: {footer[key]}" for key in FOOTER_ORDER if footer.get(key)]
28
+ for line in lines:
29
+ if "\n" in line:
30
+ raise ValueError("a footer value must be one line")
31
+ return body.rstrip("\n") + ("\n\n" + "\n".join(lines) if lines else "") + "\n"
32
+
33
+
34
+ def next_start(offset: int | None) -> str:
35
+ """The `next:` value of a list page that continues at the 0-based `offset`: `start=<offset>`. None (the last
36
+ page) gives "", which `render` drops, so a footer with no `next:` line means there is nothing more."""
37
+ if offset is None:
38
+ return ""
39
+ if offset < 0:
40
+ raise ValueError("a list cursor is a 0-based offset")
41
+ return f"start={offset}"
42
+
43
+
44
+ def parse_next_start(value: str) -> int:
45
+ """The offset in a list `next:` value, the inverse of `next_start`. Anything else is a bug in the caller."""
46
+ match = _NEXT_START.fullmatch(value.strip())
47
+ if match is None:
48
+ raise ValueError(f"not a list cursor: {value!r} (expected 'start=<n>')")
49
+ return int(match[1])
@@ -0,0 +1,3 @@
1
+ """rfclib: local RFC library with official RFC Editor sync and MCP interface."""
2
+
3
+ __version__ = "0.2.0"
@@ -0,0 +1,6 @@
1
+ """Allow `python -m primarysource.rfc ...` as an alias for the `rfclib` console script."""
2
+
3
+ from .cli import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
@@ -0,0 +1,204 @@
1
+ """Citation primitives: reference the spec, do not restate it.
2
+
3
+ Why this module exists: prose written *about* an RFC inside a library is a
4
+ second, uncheckable copy of the spec. It drifts when the RFC is updated, it
5
+ drifts when someone misremembers it, and nothing ever notices. A citation —
6
+ document, section, canonical URL, hash of the exact span — stays checkable
7
+ forever, and check() re-verifies it in a lookup.
8
+
9
+ So the policy this pushes toward:
10
+ * cite the section for a rule (`RFC 2658 section 3.2`), never a summary of it;
11
+ * quote verbatim (quote()) only when the exact wording is load-bearing, and
12
+ carry the span hash so an upstream change becomes detectable;
13
+ * do not paraphrase normative text (MUST/SHOULD sentences, ABNF, wire
14
+ formats) into comments, docstrings or identifiers. That is the copy that
15
+ rots silently, so lint() flags near-verbatim prose and uncited normative
16
+ claims instead of pretending they are documentation.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import re
22
+ import sqlite3
23
+ from datetime import datetime, timezone
24
+ from pathlib import Path
25
+
26
+ from . import query as q
27
+ from .index import search as _s
28
+
29
+ NORM_KEYWORDS = ("MUST NOT", "MUST", "SHALL NOT", "SHALL", "SHOULD NOT",
30
+ "SHOULD NOT", "SHOULD", "MAY", "REQUIRED")
31
+ _WORDS = re.compile(r"[A-Za-z0-9][A-Za-z0-9.'\-_]*")
32
+ _MONTHS = ["jan", "feb", "mar", "apr", "may", "jun",
33
+ "jul", "aug", "sep", "oct", "nov", "dec"]
34
+
35
+
36
+ def _now() -> str:
37
+ return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
38
+
39
+
40
+ def label(doc_id: str) -> str:
41
+ """'RFC2167' -> 'RFC 2167' (how a human writes it); 'BCP14' -> 'BCP 14'."""
42
+ m = re.fullmatch(r"(RFC|BCP|STD|FYI)(\d+)", doc_id)
43
+ return f"{m.group(1)} {int(m.group(2))}" if m else doc_id
44
+
45
+
46
+ def errata_note(conn: sqlite3.Connection, doc_id: str, sec: str = "") -> str:
47
+ """Errata status for a doc or one section. Distinguishes 'none' from
48
+ 'nobody looked' — asserting zero verified errata when the service was
49
+ never queried would be a false assurance."""
50
+ rows = conn.execute(
51
+ "SELECT status, section FROM errata WHERE doc_id=?",
52
+ (doc_id,)).fetchall()
53
+ if not rows:
54
+ from .errata import bulk_synced_at
55
+ at = bulk_synced_at(conn)
56
+ return (f"errata: none (as of {at[:10]})" if at
57
+ else f"errata: not cached (errata(ref='{doc_id}'))")
58
+ verified = [r for r in rows if r["status"] == "Verified"]
59
+ if not verified:
60
+ return f"errata: 0 verified ({len(rows)} on file)"
61
+ if sec:
62
+ want = _s.norm_sec(sec)
63
+ hit = [r for r in verified
64
+ if want and want in (r["section"] or "").replace("\n", " ")]
65
+ if hit:
66
+ # the exact call, so an agent that has not loaded the errata
67
+ # tool's schema copies the right argument name
68
+ return (f"errata: {len(hit)} VERIFIED on this section — read "
69
+ f"them before implementing: errata(ref='{doc_id}§{want}')")
70
+ return (f"errata: {len(verified)} verified elsewhere, "
71
+ f"none on this section")
72
+ return f"errata: {len(verified)} verified"
73
+
74
+
75
+ def _span(conn: sqlite3.Connection, doc_id: str, section: str | None,
76
+ lines: str | None) -> dict:
77
+ """Line span + anchor for a section reference, or an explicit 'a-b' line
78
+ range when the caller has no section number to offer."""
79
+ if section:
80
+ if not conn.execute("SELECT 1 FROM chunks WHERE doc_id=? LIMIT 1",
81
+ (doc_id,)).fetchone():
82
+ return {"ok": False, "reason": f"{doc_id} is not section-indexed "
83
+ "(run `rfclib index build`)"}
84
+ span = q._resolve_section_arg(conn, doc_id, section)
85
+ if not span.get("ok"):
86
+ return {"ok": False, "reason": span.get("reason"),
87
+ "suggestions": span.get("suggestions", []),
88
+ "outline": span.get("outline", [])}
89
+ return {"ok": True, "sec_num": span["sec_num"],
90
+ "sec_title": span.get("sec_title", ""),
91
+ "start_line": span["start_line"], "end_line": span["end_line"],
92
+ "frag": span.get("url_frag", "")}
93
+ if lines:
94
+ m = re.fullmatch(r"(\d+)\s*[-:]\s*(\d+)", lines.strip())
95
+ if not m:
96
+ return {"ok": False,
97
+ "reason": f"bad line range {lines!r} (want '1240-1288')"}
98
+ return {"ok": True, "sec_num": "", "sec_title": "",
99
+ "start_line": int(m.group(1)), "end_line": int(m.group(2)),
100
+ "frag": ""}
101
+ return {"ok": True, "sec_num": "", "sec_title": "", "start_line": 1,
102
+ "end_line": 0, "frag": ""}
103
+
104
+
105
+ def span_text(conn: sqlite3.Connection, doc_id: str, *, mirror_root: Path,
106
+ section: str | None = None, lines: str | None = None,
107
+ mode: str = "clean") -> tuple[str, dict]:
108
+ """The exact text of a span plus its resolved anchor metadata.
109
+
110
+ mode='clean' (default) is the canonical form span_hash is defined over;
111
+ mode='raw' is byte-exact against the RFC Editor file.
112
+ """
113
+ from .index.chunker import strip_page_furniture
114
+ text, served, _p = q._served_text(conn, doc_id, "txt", mirror_root)
115
+ sp = _span(conn, doc_id, section, lines)
116
+ if text is None:
117
+ return "", dict(sp, ok=False, reason="no local text copy")
118
+ if not sp.get("ok"):
119
+ return "", sp
120
+ ls = text.splitlines()
121
+ end = sp["end_line"] or len(ls)
122
+ body = "\n".join(ls[sp["start_line"] - 1:end])
123
+ if mode == "clean":
124
+ body = strip_page_furniture(body)
125
+ return body, dict(sp, ok=True, served_via=served, total_lines=len(ls))
126
+
127
+
128
+ def _authors_meta(conn, meta) -> str:
129
+ return " and ".join(meta.get("authors") or [])
130
+
131
+
132
+ def cite(conn: sqlite3.Connection, doc_id: str, section: str | None = None,
133
+ lines: str | None = None, style: str = "verbose", *,
134
+ mirror_root: Path | None = None) -> str:
135
+ """A canonical, checkable reference — the cheap substitute for pasting
136
+ spec text into code.
137
+
138
+ style: verbose (default) | short | markdown | comment | bibtex.
139
+ 'verbose' and 'comment' carry the span hash when mirror_root is given, so
140
+ the citation can later be re-verified with verify().
141
+ """
142
+ row = q.get_doc_row(conn, doc_id)
143
+ meta = q._row_to_meta(conn, row)
144
+ sp = _span(conn, doc_id, section, lines)
145
+ if not sp.get("ok") and style in ("verbose", "short", "comment",
146
+ "markdown"):
147
+ cands = ", ".join(sp.get("suggestions", [])) or "see get_toc"
148
+ return (f"CANNOT CITE {label(doc_id)} \u00a7{section}: {sp['reason']}\n"
149
+ f"candidates: {cands}")
150
+ sec = sp.get("sec_num", "")
151
+ title = meta.get("title") or ""
152
+ date = (meta.get("pub_date") or "")[:7]
153
+ url = q.anchor_url(meta, sp.get("frag") or "", q.resolve(conn, doc_id))
154
+ secpart = f" \u00a7{sec}" + (f' "{sp["sec_title"]}"'
155
+ if sp.get("sec_title") else "")
156
+ linepart = (f" lines {sp['start_line']}-{sp['end_line']}" if lines else "")
157
+ status = meta.get("current_status") or meta.get("status") or ""
158
+ successor = ", ".join(meta["relations"].get("obsoleted_by", []))
159
+ state = "current" if meta["is_current"] else \
160
+ f"OBSOLETE by {successor or 'unknown'}"
161
+
162
+ if style == "short":
163
+ return f"{label(doc_id)}{secpart}{linepart}"
164
+ if style == "markdown":
165
+ return f"[{label(doc_id)}{secpart}{linepart}]({url})"
166
+ if style == "bibtex":
167
+ num = re.sub(r"\D", "", doc_id)
168
+ series = ("Request for Comments" if doc_id.startswith("RFC")
169
+ else {"BCP": "Best Current Practice",
170
+ "STD": "Internet Standards",
171
+ "FYI": "FYI"}.get(doc_id[:3], "Request for Comments"))
172
+ month = (_MONTHS[int(date[5:7]) - 1]
173
+ if len(date) >= 7 and date[5:7].isdigit() else "")
174
+ rows = [f"@misc{{rfc{num},",
175
+ f" series = {{{series}}},",
176
+ f" number = {num},",
177
+ f" howpublished = {{{label(doc_id)}}},",
178
+ " publisher = {RFC Editor},",
179
+ f" doi = {{{meta.get('doi') or ''}}},",
180
+ f" url = {{{meta.get('canonical_url') or ''}}},",
181
+ f" author = {{{_authors_meta(conn, meta) or ''}}},",
182
+ f" title = {{{{{title}}}}},"]
183
+ if year := date[:4]:
184
+ rows.append(f" year = {year},")
185
+ if month:
186
+ rows.append(f" month = {month},")
187
+ rows.append("}")
188
+ return "\n".join(rows)
189
+
190
+ sha = ""
191
+ if mirror_root is not None and (sec or lines):
192
+ body, _ = span_text(conn, doc_id, mirror_root=mirror_root,
193
+ section=section, lines=lines)
194
+ sha = f" | span sha256:{q.span_hash(body)}"
195
+ tail = (f"{label(doc_id)}{secpart}{linepart} — {title} ({date}, "
196
+ f"{status}, {state})\n{url} | "
197
+ f"{errata_note(conn, doc_id, sec)}{sha}")
198
+ if style == "comment":
199
+ return "\n".join(("# " + ln) if ln else "#" for ln in tail.split("\n"))
200
+ if not meta["is_current"]:
201
+ tail += (f"\nWARNING: {label(doc_id)} is obsolete"
202
+ + (f"; cite {successor} instead" if successor else ""))
203
+ return tail
204
+