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
+ """`rpmdocs`: rpm's manual and manual pages, a pinned release, read as Markdown and scdoc. Served under `rpmdocs_`.
2
+
3
+ Stdlib only. `mcp` is imported by `server.py` when a server is built; `primarysource rpmdocs ...` runs `cli.py`.
4
+ `docs/rpmdocs.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.pkgdocs.runtime import make_collection
10
+ from primarysource.rpmdocs.catalogue import SPEC
11
+
12
+ COLLECTION = make_collection(SPEC, "primarysource.rpmdocs")
13
+ """Refs: `rpmdocs:format_v6§Header`; see `pkgdocs.runtime`."""
14
+
15
+ __all__ = ["COLLECTION", "SPEC"]
@@ -0,0 +1,117 @@
1
+ """The `rpmdocs` collection: rpm's manual (Markdown) and manual pages (scdoc) from rpm-software-management/rpm.
2
+
3
+ Source, placement and pin (read from the repository on 2026-11-13, see `docs/rpmdocs.md`):
4
+
5
+ - the tree is `docs/` at the commit of the tag `rpm-6.1.0-release`: `docs/manual/*.md` (the format documents
6
+ `format_v4`, `format_v6`, `tags`, `spec`, `macros`, `dependencies`, ...) and `docs/man/*.scd` (rpm(8),
7
+ rpm-macros(7), rpm-queryformat(7), ...), fetched as a GitHub tarball (1.2 MB);
8
+ - the licence is GPL-2.0-or-later: `COPYING` at the repository root says "In SPDX terms, the entire code base is
9
+ licensed GPL-2.0-or-later", with LGPL-2.0-or-later as an alternative for `lib/` and `rpmio/` only, which are
10
+ not the documentation. The files under `docs/` carry no licence of their own;
11
+ - placement is **layer**.
12
+
13
+ Ids: a manual page is `<name>.<section>` (`rpm-macros.7`, `rpm.8`; `rpm-macros` alone also works), a manual
14
+ document is its file name without `.md` (`format_v6`, `tags`).
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import re
20
+ from collections import Counter
21
+ from collections.abc import Mapping
22
+
23
+ from primarysource.errors import LibError
24
+ from primarysource.pkgdocs.fetch import Source
25
+ from primarysource.pkgdocs.model import Doc
26
+ from primarysource.pkgdocs.readers.markdown import read_markdown
27
+ from primarysource.pkgdocs.readers.scdoc import read_scdoc
28
+ from primarysource.pkgdocs.runtime import Build, Licence, Pin, Spec, TreeSpec, matching
29
+
30
+ COMMIT = "f832c7e69945248dd5bcfcacfd10c176ee300fa2"
31
+
32
+ PIN = Pin(
33
+ commit=COMMIT,
34
+ label="rpm-6.1.0-release",
35
+ date="2026-08-20",
36
+ files_sha256="610c31cafa342d40c7dd3e507749c1fc90fa32c3550d6b8a3e440f45b4a037b7",
37
+ sources=(
38
+ Source(f"https://codeload.github.com/rpm-software-management/rpm/tar.gz/{COMMIT}", strip=1, subdir="docs"),
39
+ Source(
40
+ f"https://github.com/rpm-software-management/rpm/archive/{COMMIT}.tar.gz",
41
+ strip=1,
42
+ hosts=("codeload.github.com",),
43
+ subdir="docs",
44
+ ),
45
+ ),
46
+ )
47
+
48
+ LICENCE = Licence(
49
+ spdx="GPL-2.0-or-later",
50
+ redistribute="layer",
51
+ attribution="RPM Package Manager contributors",
52
+ evidence="COPYING at rpm-software-management/rpm " + COMMIT[:12] + ' ("In SPDX terms ... GPL-2.0-or-later")',
53
+ )
54
+
55
+ _MAN = re.compile(r"man/([a-z0-9._+-]+\.\d)\.scd")
56
+ _MANUAL = re.compile(r"manual/([A-Za-z0-9_.+-]+)\.md")
57
+
58
+
59
+ def build(files: Mapping[str, bytes], pin: Pin) -> Build:
60
+ docs: list[Doc] = []
61
+ aliases: dict[str, str] = {}
62
+ skipped: Counter[str] = Counter()
63
+ for path in sorted(files):
64
+ man = _MAN.fullmatch(path)
65
+ manual = _MANUAL.fullmatch(path)
66
+ try:
67
+ if man:
68
+ page = man.group(1)
69
+ doc = read_scdoc(files[path], page)
70
+ aliases[page.rsplit(".", 1)[0]] = page
71
+ name, section = page.rsplit(".", 1)
72
+ body_title = _name_line(doc)
73
+ title = f"{name}({section})" + (f" - {body_title}" if body_title else "")
74
+ doc = Doc(doc.id, title, doc.unit, doc.sections, doc.meta, doc.warnings)
75
+ elif manual:
76
+ doc = read_markdown(files[path], manual.group(1))
77
+ else:
78
+ skipped["not a manual or man page source"] += 1
79
+ continue
80
+ except LibError:
81
+ skipped["not UTF-8 text"] += 1
82
+ continue
83
+ docs.append(doc.with_meta(path=f"docs/{path}", licence="GPL-2.0-or-later (COPYING, no file tag)"))
84
+ return Build(tuple(docs), aliases, skipped)
85
+
86
+
87
+ def _name_line(doc: Doc) -> str:
88
+ """The description in the NAME section's `*name* - description` line."""
89
+ for section in doc.sections:
90
+ if section.anchor == "name" and section.units:
91
+ _, sep, description = section.units[0].partition(" - ")
92
+ return " ".join(description.split()) if sep else ""
93
+ return ""
94
+
95
+
96
+ SPEC = Spec(
97
+ name="rpmdocs",
98
+ title="RPM documentation",
99
+ licence=LICENCE,
100
+ trees=(
101
+ TreeSpec(
102
+ name="docs",
103
+ title="docs/ of rpm-software-management/rpm (Markdown, scdoc)",
104
+ pins=(PIN,),
105
+ include=matching(r"manual/[A-Za-z0-9_.+-]+\.md|man/[a-z0-9._+-]+\.\d\.scd"),
106
+ build=build,
107
+ ),
108
+ ),
109
+ instructions=(
110
+ "rpm's manual (file formats format_v4/format_v6, tags, spec, macros, dependencies, ...) and manual pages "
111
+ "(rpm.8, rpm-macros.7, rpm-queryformat.7, ...) from the user's imported copy at a pinned release. Loop: "
112
+ "rpmdocs_search('<words>') -> rpmdocs_outline('format_v6') -> rpmdocs_read('<ref>'). A manual document's id "
113
+ "is its file name without .md; a man page's id is name.section ('rpm-macros.7', or just 'rpm-macros'). A "
114
+ "section is an id, an anchor, a heading or a unique part of one."
115
+ ),
116
+ example_refs=("rpmdocs:format_v6§Header", "rpmdocs:tags§Tag types", "rpmdocs:rpm-macros.7§DESCRIPTION"),
117
+ )
@@ -0,0 +1,12 @@
1
+ """`primarysource rpmdocs import|status|docs|outline|read|search|cite`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Sequence
6
+
7
+ from primarysource.pkgdocs.serving import run
8
+ from primarysource.rpmdocs.catalogue import SPEC
9
+
10
+
11
+ def main(argv: Sequence[str]) -> int:
12
+ return run(SPEC, argv)
@@ -0,0 +1,8 @@
1
+ """The MCP server for `rpmdocs` (`primarysource mcp --collection rpmdocs`). Imports `mcp` when this module loads."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from primarysource.pkgdocs.serving import build_server
6
+ from primarysource.rpmdocs.catalogue import SPEC
7
+
8
+ server = build_server(SPEC)
@@ -0,0 +1,223 @@
1
+ """The MCP scaffold: how a collection's tools are registered, and how `primarysource mcp` serves collections.
2
+
3
+ Three rules from PLAN.md §5, enforced here so no collection has to remember them:
4
+
5
+ - **Errors** (§5.10). `guard` turns a `LibError` into the SDK's `ToolError`, which reaches the client as an
6
+ `isError` result. Its text is the `error:`, `hint:` and `action:` lines of `LibError.as_text`. The SDK puts
7
+ `Error executing tool <name>:` in front of every tool error's text, so the lines start after a newline: each
8
+ field is then at the start of its own line, where a reader or a regex finds it.
9
+ - **Text only** (§5.9). Every tool is registered with `structured_output=False`, so a result is never sent twice
10
+ (as text and again as `structuredContent`) and no tool advertises an output schema.
11
+ - **Prefixed names** (§5.12). `compose` builds the server `primarysource mcp` runs, from one or more collections'
12
+ own servers, naming every tool and prompt `<collection>_<name>` (a name that already carries the prefix keeps
13
+ it). `bare=True` keeps each collection's own names, for the old single-collection commands. A name, resource
14
+ URI or HTTP route that two collections both want is refused at startup, never silently shadowed.
15
+
16
+ This module imports only the standard library and the core at import time; `mcp` is imported when a server is
17
+ built, so the core and every collection import without the `[mcp]` extra (§5.13).
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import functools
23
+ import inspect
24
+ from collections.abc import Awaitable, Callable, Iterable, Sequence
25
+ from dataclasses import dataclass
26
+ from typing import Any, cast
27
+
28
+ from primarysource.errors import LibError
29
+
30
+
31
+ def tool_error(exc: LibError) -> Exception:
32
+ """The SDK `ToolError` for `exc`: the fixed error lines, each at the start of a line of the result text."""
33
+ from mcp.server.mcpserver.exceptions import ToolError
34
+
35
+ error: Exception = ToolError("\n" + exc.as_text())
36
+ return error
37
+
38
+
39
+ def guard[F: Callable[..., Any]](fn: F) -> F:
40
+ """Wrap a tool so a `LibError` becomes `tool_error(exc)`. Sync and async tools both keep their signature,
41
+ which the SDK reads to build the input schema."""
42
+ if getattr(fn, "__primarysource_guarded__", False):
43
+ return fn
44
+ if inspect.iscoroutinefunction(fn):
45
+ async_fn = cast(Callable[..., Awaitable[Any]], fn)
46
+
47
+ @functools.wraps(fn)
48
+ async def guarded_async(*args: Any, **kwargs: Any) -> Any:
49
+ try:
50
+ return await async_fn(*args, **kwargs)
51
+ except LibError as exc:
52
+ raise tool_error(exc) from None
53
+
54
+ wrapped: Any = guarded_async
55
+ else:
56
+
57
+ @functools.wraps(fn)
58
+ def guarded(*args: Any, **kwargs: Any) -> Any:
59
+ try:
60
+ return fn(*args, **kwargs)
61
+ except LibError as exc:
62
+ raise tool_error(exc) from None
63
+
64
+ wrapped = guarded
65
+ wrapped.__primarysource_guarded__ = True
66
+ return cast(F, wrapped)
67
+
68
+
69
+ def add_tools(server: Any, tools: Iterable[Callable[..., Any]], *, prefix: str = "", annotations: Any = None) -> None:
70
+ """Register `tools` on an `MCPServer` the way every collection must: guarded, text only, and named
71
+ `prefix + fn.__name__` unless the function's name already starts with `prefix`."""
72
+ for fn in tools:
73
+ name = fn.__name__ if fn.__name__.startswith(prefix) else prefix + fn.__name__
74
+ server.add_tool(guard(fn), name=name, annotations=annotations, structured_output=False)
75
+
76
+
77
+ @dataclass(frozen=True, slots=True)
78
+ class Served:
79
+ """One collection to serve: its registry name and title, and the `MCPServer` its own module built."""
80
+
81
+ name: str
82
+ title: str
83
+ server: Any
84
+
85
+
86
+ def public_name(collection: str, name: str, *, bare: bool = False) -> str:
87
+ """The name `primarysource mcp` gives a collection's tool or prompt: `<collection>_<name>`, or `name` as it
88
+ is when it already starts with that prefix or when `bare` keeps the collection's own names."""
89
+ prefix = f"{collection}_"
90
+ return name if bare or name.startswith(prefix) else prefix + name
91
+
92
+
93
+ def _clash(kind: str, key: str, first: str, second: str, bare: bool) -> LibError:
94
+ if first == second:
95
+ return LibError(f"collection {first!r} defines the {kind} {key!r} twice", "")
96
+ if bare:
97
+ hint = "drop --bare-names, which suits one collection only, or serve them from separate processes"
98
+ else:
99
+ hint = "serve them from separate processes, or rename one of them in its collection"
100
+ return LibError(f"the {kind} {key!r} is wanted by both {first!r} and {second!r}", hint)
101
+
102
+
103
+ def _instructions(served: Sequence[Served], bare: bool) -> str:
104
+ if bare and len(served) == 1:
105
+ return str(served[0].server.instructions or "")
106
+ parts = []
107
+ for item in served:
108
+ text = str(item.server.instructions or "").strip()
109
+ head = f"[{item.name}] {item.title}." + ("" if bare else f" Tools are named {item.name}_<tool>.")
110
+ parts.append(f"{head} {text}" if text else head)
111
+ return "\n\n".join(parts)
112
+
113
+
114
+ def compose(served: Sequence[Served], *, bare: bool = False) -> Any:
115
+ """One `MCPServer` serving every tool, prompt, resource and HTTP route of `served`, renamed by `public_name`.
116
+
117
+ Each tool is rebuilt from its function with `structured_output=False` and behind `guard`, so the contract
118
+ holds whatever the collection registered. Each collection keeps its own state (database, home directory,
119
+ loaded corpus): its functions are reused, not copied. A collection's completion handler is carried over: a
120
+ request for a renamed prompt reaches it under the prompt's own name, and one for a resource template reaches
121
+ the collection that owns the template. A clash raises `LibError` before anything runs.
122
+ """
123
+ if not served:
124
+ raise LibError("no collection to serve", "primarysource mcp --collection <name>")
125
+ from mcp.server.mcpserver import MCPServer
126
+ from mcp.server.mcpserver.tools import Tool
127
+
128
+ tools: dict[str, tuple[str, Any]] = {}
129
+ prompts: dict[str, tuple[str, Any]] = {}
130
+ prompt_names: dict[str, str] = {} # public prompt name -> the name its collection gave it
131
+ completers: dict[str, Any] = {} # collection -> its own `completion/complete` handler
132
+ resources: dict[str, tuple[str, Any]] = {}
133
+ templates: dict[str, tuple[str, Any]] = {}
134
+ routes: dict[str, tuple[str, Any]] = {}
135
+
136
+ def claim(table: dict[str, tuple[str, Any]], kind: str, key: str, owner: str, value: Any) -> None:
137
+ if key in table:
138
+ raise _clash(kind, key, table[key][0], owner, bare)
139
+ table[key] = (owner, value)
140
+
141
+ for item in served:
142
+ source = item.server
143
+ for tool in source._tool_manager.list_tools():
144
+ name = public_name(item.name, tool.name, bare=bare)
145
+ rebuilt = Tool.from_function(
146
+ guard(tool.fn),
147
+ name=name,
148
+ title=tool.title,
149
+ description=tool.description,
150
+ annotations=tool.annotations,
151
+ icons=tool.icons,
152
+ meta=tool.meta,
153
+ structured_output=False,
154
+ )
155
+ claim(tools, "tool", name, item.name, rebuilt)
156
+ for prompt in source._prompt_manager.list_prompts():
157
+ name = public_name(item.name, prompt.name, bare=bare)
158
+ claim(prompts, "prompt", name, item.name, prompt.model_copy(update={"name": name}))
159
+ prompt_names[name] = prompt.name
160
+ for resource in source._resource_manager.list_resources():
161
+ claim(resources, "resource", str(resource.uri), item.name, resource)
162
+ for template in source._resource_manager.list_templates():
163
+ claim(templates, "resource template", template.uri_template, item.name, template)
164
+ for route in source._custom_starlette_routes:
165
+ claim(routes, "HTTP route", f"{route.path} {sorted(route.methods or ())}", item.name, route)
166
+ entry = source._lowlevel_server.get_request_handler("completion/complete")
167
+ if entry is not None:
168
+ completers[item.name] = entry.handler
169
+
170
+ name = served[0].server.name if len(served) == 1 else "primarysource"
171
+ server = MCPServer(
172
+ name,
173
+ version=str(served[0].server.version or "") if len(served) == 1 else _version(),
174
+ instructions=_instructions(served, bare),
175
+ tools=[tool for _, tool in tools.values()],
176
+ )
177
+ for _, prompt in prompts.values():
178
+ server.add_prompt(prompt)
179
+ for _, resource in resources.values():
180
+ server.add_resource(resource)
181
+ for _, template in templates.values():
182
+ server._resource_manager._templates[template.uri_template] = template
183
+ server._custom_starlette_routes.extend(route for _, route in routes.values())
184
+ if completers:
185
+ _carry_completions(server, completers, prompts, prompt_names, templates)
186
+ return server
187
+
188
+
189
+ def _carry_completions(
190
+ server: Any,
191
+ completers: dict[str, Any],
192
+ prompts: dict[str, tuple[str, Any]],
193
+ prompt_names: dict[str, str],
194
+ templates: dict[str, tuple[str, Any]],
195
+ ) -> None:
196
+ """Register one `completion/complete` handler on `server` that hands each request to the collection owning
197
+ the prompt or resource template it names, with the prompt's public name turned back into the collection's.
198
+ A request for something no collection completes gets no values, as the SDK answers when a handler returns
199
+ nothing."""
200
+ from mcp.types import CompleteRequestParams, CompleteResult, Completion, PromptReference
201
+
202
+ async def complete(ctx: Any, params: CompleteRequestParams) -> CompleteResult:
203
+ ref = params.ref
204
+ if isinstance(ref, PromptReference):
205
+ owner = prompts.get(ref.name, ("", None))[0]
206
+ if owner in completers:
207
+ original = ref.model_copy(update={"name": prompt_names[ref.name]})
208
+ params = params.model_copy(update={"ref": original})
209
+ else:
210
+ owner = templates.get(ref.uri, ("", None))[0]
211
+ handler = completers.get(owner)
212
+ if handler is None:
213
+ return CompleteResult(completion=Completion(values=[]))
214
+ result: CompleteResult = await handler(ctx, params)
215
+ return result
216
+
217
+ server._lowlevel_server.add_request_handler("completion/complete", CompleteRequestParams, complete)
218
+
219
+
220
+ def _version() -> str:
221
+ from primarysource import __version__
222
+
223
+ return __version__
primarysource/serve.py ADDED
@@ -0,0 +1,75 @@
1
+ """Run an MCP server on stdio or as one shared HTTP server: the transport switch every collection uses.
2
+
3
+ Lifted from rfclib's `rfclib mcp` (rfclib PR #1). stdio starts one server per client. http starts one long-lived
4
+ server that every client reaches by URL (MCP streamable HTTP at `/mcp`), stateless and with JSON responses, so a
5
+ restart loses no session. There is no authentication: the default bind is loopback, where the SDK checks the
6
+ Host header itself. A non-loopback bind gets the same check only when allowed hosts are named; without them it
7
+ warns, because anyone who can reach the port can use the server.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import logging
13
+ import sys
14
+ from collections.abc import Sequence
15
+ from typing import Any
16
+
17
+ from primarysource.errors import LibError
18
+
19
+ MCP_HTTP_PORT = 8765
20
+ LOOPBACK = ("127.0.0.1", "localhost", "::1")
21
+ TRANSPORTS = {"stdio": "stdio", "http": "http", "streamable-http": "http", "streamable_http": "http"}
22
+
23
+
24
+ def normalise_transport(transport: str) -> str:
25
+ """`stdio` or `http`; the SDK's own spellings of streamable HTTP are accepted."""
26
+ found = TRANSPORTS.get(transport.strip().lower())
27
+ if found is None:
28
+ raise LibError(f"unknown transport {transport!r}", "use --transport stdio or --transport http")
29
+ return found
30
+
31
+
32
+ def transport_security(host: str, allowed_hosts: Sequence[str]) -> Any:
33
+ """The DNS-rebinding settings for a bind address: None on loopback (the SDK enables its own check there),
34
+ an allow-list when hosts are named, else None with a warning on stderr."""
35
+ if host in LOOPBACK:
36
+ return None
37
+ if not allowed_hosts:
38
+ print(
39
+ f"warning: listening on {host} with no authentication and no Host check; anyone who can reach the "
40
+ "port can use this server. Keep it behind a firewall or an authenticating reverse proxy.",
41
+ file=sys.stderr,
42
+ )
43
+ return None
44
+ from mcp.server.transport_security import TransportSecuritySettings
45
+
46
+ return TransportSecuritySettings(
47
+ enable_dns_rebinding_protection=True,
48
+ allowed_hosts=list(allowed_hosts),
49
+ allowed_origins=[f"{scheme}://{h}" for h in allowed_hosts for scheme in ("http", "https")],
50
+ )
51
+
52
+
53
+ def run(
54
+ server: Any,
55
+ transport: str = "stdio",
56
+ host: str = "127.0.0.1",
57
+ port: int = MCP_HTTP_PORT,
58
+ allowed_hosts: Sequence[str] = (),
59
+ ) -> None:
60
+ """Serve `server` (an `mcp.server.mcpserver.MCPServer`) until interrupted."""
61
+ kind = normalise_transport(transport)
62
+ # stdout is the JSON-RPC channel on stdio; per-request client logs would flood stderr either way
63
+ for name in ("httpx", "httpcore", "huggingface_hub"):
64
+ logging.getLogger(name).setLevel(logging.WARNING)
65
+ if kind == "stdio":
66
+ server.run(transport="stdio")
67
+ return
68
+ server.run(
69
+ transport="streamable-http",
70
+ host=host,
71
+ port=port,
72
+ stateless_http=True,
73
+ json_response=True,
74
+ transport_security=transport_security(host, allowed_hosts),
75
+ )
@@ -0,0 +1,152 @@
1
+ """Per-collection settings read from the environment: the network mode and the read-only switch.
2
+
3
+ Generalised from rfclib's `config.py` (`RFCLIB_NETWORK`, `RFCLIB_READONLY`, `require_network`). One process may
4
+ serve several collections (PLAN.md §5.8), so each setting is looked up per collection, most specific first, the
5
+ same way `home.collection_home` finds the home:
6
+
7
+ 1. `<LEGACY_APP>_<KEY>`, e.g. `RFCLIB_NETWORK`: a setting from before the move keeps working;
8
+ 2. `PRIMARYSOURCE_<COLLECTION>_<KEY>`, e.g. `PRIMARYSOURCE_NIST_NETWORK`: one collection of a shared process;
9
+ 3. `PRIMARYSOURCE_<KEY>`: every collection.
10
+
11
+ The first variable that is set and not blank decides; a more specific `0` overrides a general `1`. A value that is
12
+ not understood is an error naming the variable, never a silent fallback: `RFCLIB_NETWORK=ofline` must not fetch.
13
+
14
+ Network modes:
15
+
16
+ - `auto` (the default): fetch what is asked for, and refresh catalogues when they are stale;
17
+ - `on-demand`: fetch only when a document is asked for;
18
+ - `offline`: never touch the network; answer from what is stored.
19
+
20
+ A read-only process (`<PREFIX>_READONLY=1`) must not write its library, so it is always offline: a fetch would
21
+ have to be stored.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import os
27
+ from collections.abc import Mapping
28
+ from dataclasses import dataclass
29
+ from pathlib import Path
30
+
31
+ from primarysource.errors import LibError
32
+ from primarysource.home import collection_home
33
+
34
+ NETWORK_MODES = ("auto", "on-demand", "offline")
35
+ DEFAULT_NETWORK = "auto"
36
+ _TRUE = ("1", "true", "yes", "on")
37
+ _FALSE = ("0", "false", "no", "off")
38
+
39
+
40
+ class NetworkDisabled(LibError):
41
+ """A fetch was needed and the settings forbid it (offline, or read-only)."""
42
+
43
+
44
+ def prefixes(collection: str | None = None, legacy_app: str | None = None) -> tuple[str, ...]:
45
+ """The variable prefixes consulted for `collection`, most specific first."""
46
+ found: list[str] = []
47
+ if legacy_app:
48
+ found.append(legacy_app.upper())
49
+ if collection:
50
+ found.append(f"PRIMARYSOURCE_{collection.upper()}")
51
+ found.append("PRIMARYSOURCE")
52
+ return tuple(found)
53
+
54
+
55
+ def _lookup(key: str, names: tuple[str, ...], env: Mapping[str, str]) -> tuple[str, str] | None:
56
+ """(variable, value) of the first `<prefix>_<key>` that is set and not blank, or None."""
57
+ for prefix in names:
58
+ variable = f"{prefix}_{key}"
59
+ value = (env.get(variable) or "").strip()
60
+ if value:
61
+ return variable, value
62
+ return None
63
+
64
+
65
+ @dataclass(frozen=True)
66
+ class Settings:
67
+ """One collection's settings, read once from the environment by `settings()`."""
68
+
69
+ collection: str | None
70
+ home: Path | None
71
+ network: str
72
+ read_only: bool
73
+ network_variable: str
74
+ """The variable that set `network` (or forced it, for a read-only process), for messages."""
75
+ read_only_variable: str
76
+
77
+ def require_network(self, what: str) -> None:
78
+ """Return if `what` may be fetched now; raise `NetworkDisabled` naming the variable that forbids it."""
79
+ if self.read_only:
80
+ raise NetworkDisabled(
81
+ f"{what} is not stored here and this process is read-only ({self.read_only_variable})",
82
+ "ask the shared server that writes this library, or run the import without "
83
+ f"{self.read_only_variable}",
84
+ )
85
+ if self.network == "offline":
86
+ raise NetworkDisabled(
87
+ f"{what} is not stored here and {self.network_variable}=offline forbids fetching it",
88
+ f"run the import while online, or unset {self.network_variable}",
89
+ )
90
+
91
+
92
+ def settings(
93
+ collection: str | None = None,
94
+ legacy_app: str | None = None,
95
+ environ: Mapping[str, str] | None = None,
96
+ platform: str | None = None,
97
+ ) -> Settings:
98
+ """Read `collection`'s settings. With no collection only the `PRIMARYSOURCE_*` variables apply and `home` is
99
+ None. Raises `LibError` for a value it does not understand."""
100
+ env = os.environ if environ is None else environ
101
+ names = prefixes(collection, legacy_app)
102
+
103
+ read_only, read_only_variable = False, f"{names[0]}_READONLY"
104
+ found = _lookup("READONLY", names, env)
105
+ if found:
106
+ read_only_variable, value = found
107
+ if value.lower() in _TRUE:
108
+ read_only = True
109
+ elif value.lower() not in _FALSE:
110
+ raise LibError(
111
+ f"{read_only_variable}={value!r} is not a yes or a no", f"set {read_only_variable} to 1 or 0"
112
+ )
113
+
114
+ network, network_variable = DEFAULT_NETWORK, f"{names[0]}_NETWORK"
115
+ found = _lookup("NETWORK", names, env)
116
+ if found:
117
+ network_variable, value = found
118
+ network = value.lower()
119
+ if network not in NETWORK_MODES:
120
+ raise LibError(
121
+ f"{network_variable}={value!r} is not a network mode",
122
+ f"set {network_variable} to one of {', '.join(NETWORK_MODES)}",
123
+ )
124
+ if read_only:
125
+ network, network_variable = "offline", read_only_variable
126
+
127
+ home = collection_home(collection, legacy_app, env, platform) if collection else None
128
+ return Settings(collection, home, network, read_only, network_variable, read_only_variable)
129
+
130
+
131
+ def network_mode(
132
+ collection: str | None = None, legacy_app: str | None = None, environ: Mapping[str, str] | None = None
133
+ ) -> str:
134
+ """`auto`, `on-demand` or `offline` for `collection`; always `offline` when it is read-only."""
135
+ return settings(collection, legacy_app, environ).network
136
+
137
+
138
+ def read_only(
139
+ collection: str | None = None, legacy_app: str | None = None, environ: Mapping[str, str] | None = None
140
+ ) -> bool:
141
+ """True when `collection` must not write its library."""
142
+ return settings(collection, legacy_app, environ).read_only
143
+
144
+
145
+ def require_network(
146
+ what: str,
147
+ collection: str | None = None,
148
+ legacy_app: str | None = None,
149
+ environ: Mapping[str, str] | None = None,
150
+ ) -> None:
151
+ """Raise `NetworkDisabled` unless `collection` may fetch `what` now. Read at each call, not cached."""
152
+ settings(collection, legacy_app, environ).require_network(what)
@@ -0,0 +1,5 @@
1
+ """USB-IF specifications: corpus index, search, and citation."""
2
+
3
+ from primarysource.usb.models import Document, Section
4
+
5
+ __all__ = ["Document", "Section"]
@@ -0,0 +1,37 @@
1
+ """USB-IF specifications as a primarysource collection. Importing this needs nothing beyond the core."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Sequence
6
+
7
+ from primarysource.model import Unit
8
+ from primarysource.pdfcorpus.tools import GRAMMAR
9
+ from primarysource.registry import Collection
10
+
11
+ # The server registers the bare names (search, read, outline, info, cite); `primarysource mcp` serves them as
12
+ # usb_search, ... and `--bare-names` keeps usblib's names. Refs: `<doc_id>§<section>` and `<doc_id>:<first>-<last>`.
13
+ COLLECTION = Collection(
14
+ name="usb",
15
+ title="USB-IF specifications (CCID and others), fetched by the user",
16
+ prefix="usb_",
17
+ unit=Unit.BLOCK,
18
+ grammar=GRAMMAR,
19
+ server="primarysource.usb.server.mcp:mcp",
20
+ cli="primarysource.usb.collection:main",
21
+ )
22
+
23
+
24
+ def main(argv: Sequence[str]) -> int:
25
+ """`primarysource usb fetch ...` (download, extract and index the specifications in sources.toml) and
26
+ `primarysource usb index` (rebuild the search index from the fetched corpus)."""
27
+ if argv and argv[0] == "index":
28
+ from primarysource.pdfcorpus import fts
29
+ from primarysource.usb.spec import SPEC
30
+
31
+ return fts.main(SPEC, argv[1:])
32
+ if not argv or argv[0] != "fetch":
33
+ print("usage: primarysource usb {fetch,index} [args]", flush=True)
34
+ return 2
35
+ from primarysource.usb.ingest import fetch
36
+
37
+ return int(fetch.main(list(argv[1:])))