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,380 @@
1
+ """Read, outline, search and cite imported Broadband Forum documents. Local files only; nothing here fetches.
2
+
3
+ Two kinds of document share one ref shape, `bbf:<doc>§<section>¶n` (the `bbf:` prefix is optional):
4
+
5
+ - a data-model file, `bbf:tr-181-2-22-0-ip`: `§` is the full path of an object, parameter, command, event,
6
+ profile or data type, `¶n` the nth paragraph of its description (see `datamodel.py`);
7
+ - a Technical Report, `bbf:tr-069-1-6-1`: `§` is a clause number as the PDF's bookmarks give it (`3.5`,
8
+ `A.3.2.3`), `A` for `Annex A.`, `Appendix-I` for `Appendix I.`, or the slug of an unnumbered bookmark
9
+ (`normative-references`); `¶n` is the nth sentence of the clause's span (its own text, then each subsection
10
+ as a `§3.5.1 Title` line and its text).
11
+
12
+ Every result's `unit:` footer says which unit `¶n` counts.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import functools
18
+ import json
19
+ import re
20
+ from dataclasses import dataclass
21
+ from pathlib import Path
22
+
23
+ from primarysource import budget, cursor
24
+ from primarysource.bbf import datamodel
25
+ from primarysource.bbf.catalogue import DOC_ID
26
+ from primarysource.bbf.catalogue import catalogue as packaged_catalogue
27
+ from primarysource.bbf.importer import read_record
28
+ from primarysource.c2sp._fetch import sha256
29
+ from primarysource.ecma import _pdf
30
+ from primarysource.errors import LibError
31
+ from primarysource.home import collection_home
32
+ from primarysource.model import span_hash
33
+ from primarysource.refs import Ref, RefGrammar
34
+ from primarysource.render import render
35
+
36
+ PREFIX = re.compile(r"^bbf:", re.IGNORECASE)
37
+ MAX_LISTING = 400
38
+
39
+
40
+ def grammar() -> RefGrammar:
41
+ from primarysource.bbf import BBF_DOC, BBF_SECTION
42
+
43
+ return RefGrammar(BBF_DOC, BBF_SECTION)
44
+
45
+
46
+ @dataclass(frozen=True, slots=True)
47
+ class LoadedModel:
48
+ id: str
49
+ model: datamodel.DataModel
50
+ commit: str
51
+
52
+
53
+ @dataclass(frozen=True, slots=True)
54
+ class LoadedReport:
55
+ id: str
56
+ designation: str
57
+ title: str
58
+ edition: str
59
+ document: _pdf.PdfDocument
60
+
61
+
62
+ def default_home() -> Path:
63
+ return collection_home("bbf")
64
+
65
+
66
+ @functools.lru_cache(maxsize=8)
67
+ def _parse_model(digest: str, path: str) -> datamodel.DataModel:
68
+ """Parse an installed file, cached by its verified digest so a search over many files parses each once."""
69
+ return datamodel.parse(Path(path).read_bytes())
70
+
71
+
72
+ def parse_ref(text: str) -> Ref:
73
+ """Parse a bbf ref, accepting the optional `bbf:` prefix and `<doc>¶n` (unit n of a whole-report read)."""
74
+ body, doc_para = cursor.split_doc_para(PREFIX.sub("", text.strip(), count=1))
75
+ ref = grammar().parse(body)
76
+ if doc_para is not None:
77
+ ref = Ref(ref.doc, None, doc_para, None, None)
78
+ if ref.lines is not None or ref.anchor is not None:
79
+ raise LibError(f"not a bbf ref: {text!r}", "bbf refs are <doc>[§<section>][¶n]")
80
+ if ref.doc is None:
81
+ raise LibError(f"not a bbf ref: {text!r}", "start with the document, e.g. tr-069-1-6-1 or tr-181-2-22-0-ip")
82
+ return ref
83
+
84
+
85
+ def _doc(ref: Ref) -> str:
86
+ """The document of a parsed ref; `parse_ref` has already refused a ref without one."""
87
+ if ref.doc is None:
88
+ raise LibError("the ref names no document", "start with the document id")
89
+ return ref.doc
90
+
91
+
92
+ def load(token: str, home: Path | None = None) -> LoadedModel | LoadedReport:
93
+ """Load one installed document by id, verifying the stored file's sha256."""
94
+ home = home or default_home()
95
+ if not DOC_ID.fullmatch(token):
96
+ raise LibError(f"not a Broadband Forum document id: {token!r}", "bbf_docs lists what is catalogued")
97
+ record = read_record(home)
98
+ if token in record["models"]:
99
+ path = home / "models" / f"{token}.xml"
100
+ try:
101
+ digest = sha256(path.read_bytes())
102
+ except OSError as exc:
103
+ raise LibError(
104
+ f"cannot read {path.name}: {exc}", "", f"re-import it: primarysource bbf import-models {token}"
105
+ ) from exc
106
+ if digest != record["models"][token]:
107
+ raise LibError(
108
+ f"{token}.xml does not match its recorded sha256",
109
+ "",
110
+ f"re-import it: primarysource bbf import-models {token}",
111
+ )
112
+ return LoadedModel(token, _parse_model(digest, str(path)), str(record.get("commit", "")))
113
+ if token in record["reports"]:
114
+ pdf = home / "reports" / f"{token}.pdf"
115
+ stored = home / "reports" / f"{token}.json"
116
+ pin = record["reports"][token].get("sha256")
117
+ try:
118
+ if not isinstance(pin, str) or sha256(pdf.read_bytes()) != pin:
119
+ raise LibError(
120
+ f"{token}.pdf does not match its recorded sha256",
121
+ "",
122
+ f"re-import it: primarysource bbf import-report {token}",
123
+ )
124
+ raw = json.loads(stored.read_text(encoding="utf-8"))
125
+ except (OSError, ValueError) as exc:
126
+ raise LibError(
127
+ f"cannot read the import of {token}: {exc}",
128
+ "",
129
+ f"re-import it: primarysource bbf import-report {token}",
130
+ ) from exc
131
+ document = _pdf.PdfDocument.from_json(raw)
132
+ return LoadedReport(
133
+ token,
134
+ str(raw.get("designation", token.upper())),
135
+ str(raw.get("title", "")),
136
+ str(raw.get("edition", "")),
137
+ document,
138
+ )
139
+ have = ", ".join(sorted({*record["models"], *record["reports"]})[:12]) or "nothing"
140
+ raise LibError(
141
+ f"{token} is not imported (have: {have})",
142
+ "bbf_docs lists what is catalogued and imported",
143
+ f"primarysource bbf import-models {token} (a data model) or primarysource bbf import-report {token}",
144
+ )
145
+
146
+
147
+ def _blob_url(model: LoadedModel) -> str:
148
+ return f"https://github.com/BroadbandForum/cwmp-data-models/blob/{model.commit}/{model.id}.xml"
149
+
150
+
151
+ def _cite_model(doc: LoadedModel, node: datamodel.Node | None, para: int | str | None) -> str:
152
+ where = f", {node.kind} {node.id}" if node else ""
153
+ if para:
154
+ where += f" ¶{para}"
155
+ return (
156
+ f"Broadband Forum, {doc.id}.xml ({doc.model.title}), cwmp-data-models commit {doc.commit[:7]}{where}. "
157
+ f"BSD-3-Clause. {_blob_url(doc)}"
158
+ )
159
+
160
+
161
+ def _cite_report(doc: LoadedReport, section: _pdf.PdfSection | None, para: int | str | None) -> str:
162
+ where = f", §{section.id} {section.title}" if section else ""
163
+ if para:
164
+ where += f" ¶{para}"
165
+ url = next(
166
+ (r.page_url for r in packaged_catalogue().reports if r.id == doc.id), "https://www.broadband-forum.org/"
167
+ )
168
+ return f"Broadband Forum, {doc.designation} {doc.title}, {doc.edition}{where}. {url}"
169
+
170
+
171
+ def _report_units(doc: LoadedReport, ref: Ref) -> tuple[_pdf.PdfSection | None, tuple[str, ...], str]:
172
+ if ref.section is None:
173
+ units: list[str] = []
174
+ for section in doc.document.sections:
175
+ units.append(f"§{section.id} {section.title}")
176
+ units.extend(section.sentences)
177
+ return None, tuple(units), ""
178
+ index, warning = doc.document.find(ref.section)
179
+ return doc.document.sections[index], doc.document.span(index), warning
180
+
181
+
182
+ def _overview(doc: LoadedModel) -> str:
183
+ counts: dict[str, int] = {}
184
+ for node in doc.model.nodes:
185
+ counts[node.kind] = counts.get(node.kind, 0) + 1
186
+ summary = ", ".join(f"{n} {k}" for k, n in sorted(counts.items()))
187
+ lines = [f"{doc.model.title}", f"spec {doc.model.spec}", f"file {doc.model.file}", f"nodes: {summary or 'none'}"]
188
+ if doc.model.imports:
189
+ lines.append("imports: " + ", ".join(doc.model.imports))
190
+ lines.append(f"bbf_outline('{doc.id}') lists the nodes; read one as '{doc.id}§<path>'")
191
+ return "\n".join(lines)
192
+
193
+
194
+ def read(ref_text: str, max_tokens: int = 2000, home: Path | None = None) -> str:
195
+ """The text of a ref, cut at unit boundaries within `max_tokens`. A `¶n` ref starts at unit n and runs to the end
196
+ of its scope; the `next:` footer is the ref that reads the next page."""
197
+ ref = parse_ref(ref_text)
198
+ doc = load(_doc(ref), home)
199
+ if isinstance(doc, LoadedModel):
200
+ if ref.section is None:
201
+ if ref.para is not None:
202
+ raise LibError(f"{ref_text}: ¶ needs a node", "name one after §, e.g. §Device.IP.Interface.{i}")
203
+ return render(_overview(doc), {"cite": _cite_model(doc, None, None), "unit": "paragraph"})
204
+ node, warning = doc.model.find(ref.section)
205
+ paragraphs = node.paragraphs
206
+ first = cursor.first_unit(ref.para, len(paragraphs), "paragraphs", ref_text)
207
+ chosen = budget.page(paragraphs, start=first, max_tokens=max_tokens) if paragraphs else None
208
+ parts = [] if ref.para is not None else [node.header()]
209
+ contents = doc.model.children(node)
210
+ if contents and ref.para is None:
211
+ names = [c.id.rsplit(".", 1)[-1] for c in contents[:80]]
212
+ more = f" (+{len(contents) - 80} more)" if len(contents) > 80 else ""
213
+ parts.append("contains: " + ", ".join(names) + more)
214
+ if chosen is not None:
215
+ parts.extend(chosen.units)
216
+ body = "\n\n".join(parts)
217
+ footer = {
218
+ "cite": _cite_model(doc, node, cursor.para_label(ref.para, chosen) if chosen else None),
219
+ "unit": "paragraph",
220
+ "span": span_hash(body),
221
+ "next": cursor.read_next(f"bbf:{doc.id}§{node.id}", chosen) if chosen else "",
222
+ "warning": warning,
223
+ }
224
+ return render(body, footer)
225
+ section, units, warning = _report_units(doc, ref)
226
+ if not units:
227
+ raise LibError(f"{ref_text}: that clause has no text of its own", "read its subsections, see bbf_outline")
228
+ first = cursor.first_unit(ref.para, len(units), "sentences", ref_text)
229
+ chosen = budget.page(units, start=first, max_tokens=max_tokens)
230
+ body = "\n".join(chosen.units)
231
+ scope = f"bbf:{doc.id}" + (f"§{section.id}" if section else "")
232
+ footer = {
233
+ "cite": _cite_report(doc, section, cursor.para_label(ref.para, chosen)),
234
+ "unit": "sentence",
235
+ "span": span_hash(body),
236
+ "next": cursor.read_next(scope, chosen),
237
+ "warning": warning,
238
+ }
239
+ return render(body, footer)
240
+
241
+
242
+ def outline(token: str, under: str = "", limit: int = 200, start: int = 0, home: Path | None = None) -> str:
243
+ """A document's structure. A report: its bookmarked clauses with refs and pages. A data model: its objects,
244
+ commands, events, profiles and data types, indented by path depth; `under` (a node path) lists everything
245
+ below that node, parameters included. Both are paged by `limit` and the 0-based `start`."""
246
+ doc = load(token, home)
247
+ if isinstance(doc, LoadedReport):
248
+ head = f"{doc.designation} {doc.title}, {doc.edition} [{doc.id}, {doc.document.pages} pages]"
249
+ clauses = [
250
+ f"{' ' * section.depth}{section.id} {section.title} bbf:{doc.id}§{section.id} (p. {section.page})"
251
+ for section in doc.document.sections
252
+ ]
253
+ shown, nxt = cursor.window(clauses, start, limit)
254
+ return render("\n".join([head, *shown]), {"next": nxt})
255
+ prefix = under.strip().rstrip(".")
256
+ rows = []
257
+ for node in doc.model.nodes:
258
+ if prefix:
259
+ if not (node.id == prefix or node.id.startswith((prefix + ".", prefix + "()"))):
260
+ continue
261
+ elif node.kind == "parameter":
262
+ continue
263
+ depth = len([part for part in node.id.split(".") if part and part != "{i}"]) - 1
264
+ rows.append(f"{' ' * max(0, min(depth, 12))}{node.kind} {node.id}")
265
+ if not rows:
266
+ raise LibError(f"nothing under {under!r} in {doc.id}", "omit under to list the objects")
267
+ shown, nxt = cursor.window(rows, start, limit)
268
+ header = f"{doc.model.title} [{doc.id}] refs: bbf:{doc.id}§<path>"
269
+ return render("\n".join([header, *shown]), {"next": nxt})
270
+
271
+
272
+ def _documents(doc: str | None, home: Path) -> list[str]:
273
+ record = read_record(home)
274
+ if doc:
275
+ return [doc]
276
+ ids = [*sorted(record["reports"]), *sorted(record["models"])]
277
+ if not ids:
278
+ raise LibError(
279
+ "no Broadband Forum documents are imported",
280
+ "bbf_docs lists the catalogue",
281
+ "primarysource bbf import-models",
282
+ )
283
+ return ids
284
+
285
+
286
+ def search(query: str, doc: str | None = None, limit: int = 10, start: int = 0, home: Path | None = None) -> str:
287
+ """Units containing every query word (else at least half, flagged `relaxed`), best first: one line per hit,
288
+ `<ref>: <snippet>`. A data-model node whose path matches is a hit with no ¶."""
289
+ terms = _pdf.query_terms(query)
290
+ home = home or default_home()
291
+ tokens = _documents(doc, home)
292
+ loaded = [load(token, home) for token in tokens]
293
+ scored: list[tuple[float, int, str, str]] = []
294
+ relaxed = False
295
+ for need_all in (True, False):
296
+ for order, item in enumerate(loaded):
297
+ if isinstance(item, LoadedReport):
298
+ for section in item.document.sections:
299
+ for score, number in _pdf.score_units(section.sentences, terms, section.title, need_all):
300
+ ref = f"bbf:{item.id}§{section.id}¶{number}"
301
+ scored.append((-score, order, ref, _pdf.snippet(section.sentences[number - 1], terms)))
302
+ continue
303
+ for node in item.model.nodes:
304
+ units = (node.header(), *node.paragraphs)
305
+ for score, number in _pdf.score_units(units, terms, node.id, need_all):
306
+ ref = f"bbf:{item.id}§{node.id}" + (f"¶{number - 1}" if number > 1 else "")
307
+ scored.append((-score, order, ref, _pdf.snippet(units[number - 1], terms)))
308
+ if scored:
309
+ relaxed = not need_all
310
+ break
311
+ if not scored:
312
+ return render("no matches", {})
313
+ scored.sort()
314
+ shown, nxt = cursor.window(scored, start, limit)
315
+ footer = {"next": nxt, "relaxed": "only some query words matched" if relaxed else ""}
316
+ return render("\n".join(f"{ref}: {snip}" for _, _, ref, snip in shown), footer)
317
+
318
+
319
+ def cite(ref_text: str, home: Path | None = None) -> str:
320
+ """The citation string for a ref, with the span hash of the exact text cited."""
321
+ ref = parse_ref(ref_text)
322
+ doc = load(_doc(ref), home)
323
+ if isinstance(doc, LoadedModel):
324
+ if ref.section is None:
325
+ return render(
326
+ doc.id, {"cite": _cite_model(doc, None, None), "unit": "paragraph", "span": span_hash(_overview(doc))}
327
+ )
328
+ node, warning = doc.model.find(ref.section)
329
+ text = "\n\n".join(node.paragraphs)
330
+ if ref.para is not None:
331
+ if ref.para > len(node.paragraphs):
332
+ raise LibError(
333
+ f"{ref_text}: the description has {len(node.paragraphs)} paragraphs", "use a ¶ within range"
334
+ )
335
+ text = node.paragraphs[ref.para - 1]
336
+ footer = {
337
+ "cite": _cite_model(doc, node, ref.para),
338
+ "unit": "paragraph",
339
+ "span": span_hash(text),
340
+ "warning": warning,
341
+ }
342
+ return render(f"{doc.id}§{node.id}", footer)
343
+ section, units, warning = _report_units(doc, ref)
344
+ if ref.para is not None:
345
+ if ref.para > len(units):
346
+ raise LibError(f"{ref_text}: the span has {len(units)} sentences", f"use ¶1 to ¶{len(units)}")
347
+ units = (units[ref.para - 1],)
348
+ footer = {
349
+ "cite": _cite_report(doc, section, ref.para),
350
+ "unit": "sentence",
351
+ "span": span_hash("\n".join(units)),
352
+ "warning": warning,
353
+ }
354
+ return render(f"{doc.id}" + (f"§{section.id}" if section else ""), footer)
355
+
356
+
357
+ def docs(filter_text: str = "", home: Path | None = None) -> str:
358
+ """The catalogue and what is imported: reports first, then data-model files (at most `MAX_LISTING` lines)."""
359
+ home = home or default_home()
360
+ cat = packaged_catalogue()
361
+ record = read_record(home)
362
+ needle = filter_text.casefold()
363
+ lines = [
364
+ f"reports: {cat.report_terms.licence}, placement {cat.report_terms.placement}",
365
+ ]
366
+ for report in cat.reports:
367
+ if needle in report.id or needle in report.title.casefold():
368
+ state = "imported" if report.id in record["reports"] else "not imported"
369
+ lines.append(f"{state:<13} {report.id} {report.designation} {report.title}, {report.issue}")
370
+ lines.append(f"models: {cat.model_terms.licence}, placement {cat.model_terms.placement}, commit {cat.commit[:7]}")
371
+ shown = 0
372
+ for model in cat.models:
373
+ if needle in model.id or needle in model.title.casefold():
374
+ if shown >= MAX_LISTING:
375
+ lines.append("... (narrow with a filter)")
376
+ break
377
+ state = "imported" if model.id in record["models"] else "not imported"
378
+ lines.append(f"{state:<13} {model.id} {model.title}")
379
+ shown += 1
380
+ return "\n".join(lines)
@@ -0,0 +1,72 @@
1
+ """MCP server for the bbf collection: `primarysource mcp --collection bbf`.
2
+
3
+ Imported only when served, so `mcp` (the `[mcp]` extra) is needed only then. Tools carry the `bbf_` prefix,
4
+ answer in text with the fixed footer, and turn a `LibError` into an MCP tool error naming the remedy. Every tool
5
+ reads local files only; none writes or fetches.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Any
11
+
12
+ from mcp.server.mcpserver import MCPServer
13
+ from mcp.types import ToolAnnotations
14
+
15
+ from primarysource import __version__
16
+ from primarysource.bbf import library
17
+ from primarysource.scaffold import add_tools
18
+
19
+ READ_ONLY = ToolAnnotations(read_only_hint=True, idempotent_hint=True, destructive_hint=False, open_world_hint=False)
20
+
21
+ INSTRUCTIONS = (
22
+ "Broadband Forum documents from the user's own imports: Technical Reports (TR-069 Amendment 6 Corrigendum 1, "
23
+ "CWMP 1.4) and the TR-181 data-model XML. Loop: bbf_search('<words>') -> bbf_read('<ref>'). Refs: "
24
+ "'bbf:tr-069-1-6-1§3.5' (a report clause as numbered in the PDF; '¶2' is its 2nd sentence) and "
25
+ "'bbf:tr-181-2-22-0-ip§Device.IP.Interface.{i}.Enable' (a data-model node by full path; '¶1' is the 1st "
26
+ "paragraph of its description, BBF {{macros}} left unexpanded). bbf_outline('<doc>') lists clauses or nodes; "
27
+ "bbf_cite('<ref>') gives the citation and the hash of the cited text. If nothing is imported, the error says how."
28
+ )
29
+
30
+
31
+ def bbf_read(ref: str, max_tokens: int = 2000) -> str:
32
+ """Read Broadband Forum text by ref, cut at unit boundaries within max_tokens. A 'next: <ref>' footer line is
33
+ the ref that reads the next page: pass it back as ref.
34
+
35
+ ref: 'tr-069-1-6-1§3.5' (report clause, subsections included) | 'tr-069-1-6-1§3.5¶2' (from its 2nd sentence) |
36
+ 'tr-181-2-22-0-ip§Device.IP.Interface.{i}' (a data-model object: header, contained names, description) |
37
+ '...{i}.Enable¶1' (from its 1st description paragraph). 'tr-181-2-22-0-ip' alone gives the file's overview.
38
+ bbf_cite gives exactly one unit."""
39
+ return library.read(ref, max_tokens=max_tokens)
40
+
41
+
42
+ def bbf_search(query: str, doc: str | None = None, limit: int = 10, start: int = 0) -> str:
43
+ """Find units containing the query words (all of them, else at least half, flagged 'relaxed'). One line per
44
+ hit: '<ref>: <snippet>'. doc restricts to one document id. A 'next: start=N' footer line means call again with
45
+ start=N (a 0-based offset)."""
46
+ return library.search(query, doc=doc, limit=limit, start=start)
47
+
48
+
49
+ def bbf_outline(doc: str, under: str = "", limit: int = 200, start: int = 0) -> str:
50
+ """A report's clauses with refs and pages, or a data model's objects (indented by depth). under: a node path
51
+ to list everything below it, parameters included, e.g. 'Device.IP.Interface.{i}'. A 'next: start=N' footer
52
+ line means call again with start=N."""
53
+ return library.outline(doc, under=under, limit=limit, start=start)
54
+
55
+
56
+ def bbf_cite(ref: str) -> str:
57
+ """The citation string for a ref, with the sha256 prefix of the exact text cited (cite: and span: lines)."""
58
+ return library.cite(ref)
59
+
60
+
61
+ def bbf_docs(filter: str = "") -> str: # noqa: A002 - the tool's argument name
62
+ """List the catalogued reports and data-model files and whether each is imported; filter narrows by id."""
63
+ return library.docs(filter)
64
+
65
+
66
+ def _build() -> Any:
67
+ built = MCPServer("primarysource-bbf", version=__version__, instructions=INSTRUCTIONS)
68
+ add_tools(built, (bbf_read, bbf_search, bbf_outline, bbf_cite, bbf_docs), annotations=READ_ONLY)
69
+ return built
70
+
71
+
72
+ server = _build()
@@ -0,0 +1,63 @@
1
+ """Token-budgeted paging over units.
2
+
3
+ rfclib, itulib and zimlore each define `CHARS_PER_TOKEN = 4` and each wrote this loop. The rules they agree on:
4
+ a unit is never split, a page always makes progress even when one unit exceeds the budget, and the result says
5
+ where to continue.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from collections.abc import Sequence
11
+ from dataclasses import dataclass
12
+
13
+ CHARS_PER_TOKEN = 4
14
+
15
+
16
+ def tokens(text: str) -> int:
17
+ """The estimate all three libraries use: characters divided by four, rounded up."""
18
+ return -(-len(text) // CHARS_PER_TOKEN)
19
+
20
+
21
+ @dataclass(frozen=True, slots=True)
22
+ class Page:
23
+ """Units `first..last` (1-based, inclusive) of `total`. `next_start` is None on the last page."""
24
+
25
+ units: tuple[str, ...]
26
+ first: int
27
+ last: int
28
+ total: int
29
+ next_start: int | None
30
+ oversize: bool
31
+
32
+
33
+ def page(units: Sequence[str], start: int = 1, max_tokens: int = 2000) -> Page:
34
+ """Take whole units from `start` (1-based) until the next one would exceed `max_tokens`.
35
+
36
+ The first unit is always taken, so a caller following `next_start` terminates; `oversize` reports that the
37
+ page went over budget for that reason. A `start` past the end gives an empty page, not an error: the caller
38
+ asked for a position, and "nothing there" is the true answer.
39
+ """
40
+ if start < 1:
41
+ raise ValueError("start is 1-based")
42
+ if max_tokens < 1:
43
+ raise ValueError("max_tokens must be positive")
44
+ total = len(units)
45
+ taken: list[str] = []
46
+ used = 0
47
+ index = start - 1
48
+ while index < total:
49
+ cost = tokens(units[index])
50
+ if taken and used + cost > max_tokens:
51
+ break
52
+ taken.append(units[index])
53
+ used += cost
54
+ index += 1
55
+ last = start + len(taken) - 1
56
+ return Page(
57
+ units=tuple(taken),
58
+ first=start if taken else 0,
59
+ last=last if taken else 0,
60
+ total=total,
61
+ next_start=index + 1 if index < total else None,
62
+ oversize=used > max_tokens,
63
+ )
@@ -0,0 +1,53 @@
1
+ """C2SP: the Community Cryptography Specification Project's specifications (CC BY 4.0), as one collection.
2
+
3
+ The source is Markdown in https://github.com/C2SP/C2SP, pinned to one commit (`catalogue.json`) and imported with
4
+ `primarysource c2sp import`. Because the text is CC BY 4.0 it may also be placed in an image layer; the importer
5
+ itself only writes `$PRIMARYSOURCE_HOME/c2sp/<commit>/`. `docs/c2sp.md` is the user-facing description.
6
+
7
+ Importing this package needs only the standard library; `mcp` (the `[mcp]` extra) only when a collection is
8
+ served (`primarysource mcp --collection c2sp`). `primarysource c2sp ...` runs `cli.py`.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from primarysource.model import Unit
14
+ from primarysource.refs import Ref, RefGrammar
15
+ from primarysource.registry import Collection
16
+
17
+ C2SP_DOC = r"[A-Za-z0-9][A-Za-z0-9-]*(?:@[0-9a-f]{7,40})?"
18
+ """A specification by file name, case as in the repository, optionally pinned to a commit prefix:
19
+ `static-ct-api`, `tlog-tiles@09f3e51`, `BLAKE3`."""
20
+
21
+ C2SP_SECTION = r"[^#¶:§\s][^#¶:§]*"
22
+ """A heading's text (`Log Entries`) or its slug (`log-entries`). May contain spaces, never `:`, `#`, `¶` or `§`."""
23
+
24
+
25
+ class _Refs:
26
+ """The collection's ref parser: `library.parse_ref` (which also takes the `c2sp:` prefix and refuses what the
27
+ collection has no meaning for), imported on first use so the collection loads without its reader."""
28
+
29
+ _grammar = RefGrammar(C2SP_DOC, C2SP_SECTION)
30
+
31
+ def parse(self, text: str) -> Ref:
32
+ from primarysource.c2sp.library import parse_ref
33
+
34
+ return parse_ref(text)
35
+
36
+ def format(self, ref: Ref) -> str:
37
+ return self._grammar.format(ref)
38
+
39
+
40
+ C2SP = Collection(
41
+ name="c2sp",
42
+ title="C2SP specifications (Community Cryptography Specification Project)",
43
+ prefix="c2sp_",
44
+ unit=Unit.BLOCK,
45
+ grammar=_Refs(),
46
+ server="primarysource.c2sp.server:server",
47
+ cli="primarysource.c2sp.cli:main",
48
+ )
49
+ """Refs: `c2sp:static-ct-api@09f3e51§Log Entries¶2`. `§` names a heading by its text or its slug; `¶n` counts
50
+ blank-line-separated blocks (a paragraph, a list, a fenced code block) in that heading's span, subsections included,
51
+ its own heading excluded. The `c2sp:` prefix and the `@commit` are optional."""
52
+
53
+ __all__ = ["C2SP", "C2SP_DOC", "C2SP_SECTION"]
@@ -0,0 +1,5 @@
1
+ """Moved to `primarysource.fetch` (ROADMAP `cl.fetch_pinned`); re-exported for c2sp, bbf, ecma."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from primarysource.fetch import * # noqa: F403