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,137 @@
1
+ """The Broadband Forum catalogue: data-model XML pinned to a commit, and Technical Reports pinned by sha256.
2
+
3
+ Two kinds of source, two licences, two placements (`terms`):
4
+ - `models`: XML files of https://github.com/BroadbandForum/cwmp-data-models at one pinned commit. Each file
5
+ carries a BSD-3-Clause-style notice (redistribution with notice permitted), so placement may be an image layer.
6
+ - `reports`: Technical Report PDFs (TR-069 Amendment 6 Corrigendum 1 so far). The Forum's terms of use allow
7
+ use for implementing and distribution only as a whole copy with the notice, and forbid modification or
8
+ derivative works, so they are fetched by the user into a volume.
9
+
10
+ Metadata only, never text. `catalogue.json` is package data.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import re
17
+ from dataclasses import dataclass
18
+ from importlib import resources
19
+ from typing import Any
20
+
21
+ from primarysource.errors import LibError
22
+
23
+ DOC_ID = re.compile(r"[a-z][a-z0-9]*(?:-[a-z0-9]+)*")
24
+ COMMIT = re.compile(r"[0-9a-f]{40}")
25
+ SHA256 = re.compile(r"[0-9a-f]{64}")
26
+ HOSTS = ("raw.githubusercontent.com", "www.broadband-forum.org")
27
+
28
+
29
+ @dataclass(frozen=True, slots=True)
30
+ class Terms:
31
+ licence: str
32
+ placement: str
33
+ terms_url: str
34
+ terms_read: str
35
+ summary: str
36
+
37
+
38
+ @dataclass(frozen=True, slots=True)
39
+ class Model:
40
+ """One data-model XML file at the pinned commit."""
41
+
42
+ id: str
43
+ path: str
44
+ title: str
45
+ size: int
46
+ sha256: str
47
+
48
+
49
+ @dataclass(frozen=True, slots=True)
50
+ class Report:
51
+ """One Technical Report PDF."""
52
+
53
+ id: str
54
+ designation: str
55
+ title: str
56
+ issue: str
57
+ page_url: str
58
+ source_url: str
59
+ size: int
60
+ sha256: str
61
+
62
+
63
+ @dataclass(frozen=True, slots=True)
64
+ class Catalogue:
65
+ repo: str
66
+ commit: str
67
+ raw_base: str
68
+ model_terms: Terms
69
+ report_terms: Terms
70
+ models: tuple[Model, ...]
71
+ reports: tuple[Report, ...]
72
+
73
+ def model(self, doc_id: str) -> Model:
74
+ for found in self.models:
75
+ if found.id == doc_id:
76
+ return found
77
+ raise LibError(f"no BBF data model {doc_id!r} in the catalogue", "bbf_docs lists what is catalogued")
78
+
79
+ def report(self, doc_id: str) -> Report:
80
+ for found in self.reports:
81
+ if found.id == doc_id:
82
+ return found
83
+ raise LibError(f"no BBF report {doc_id!r} in the catalogue", "bbf_docs lists what is catalogued")
84
+
85
+ def model_url(self, model: Model) -> str:
86
+ return f"{self.raw_base}{self.commit}/{model.path}"
87
+
88
+
89
+ def _terms(raw: dict[str, Any]) -> Terms:
90
+ terms = Terms(raw["licence"], raw["placement"], raw["terms_url"], raw["terms_read"], raw["summary"])
91
+ if terms.placement not in ("layer", "volume"):
92
+ raise ValueError("terms.placement must be 'layer' or 'volume'")
93
+ return terms
94
+
95
+
96
+ def parse_catalogue(raw: dict[str, Any]) -> Catalogue:
97
+ """Validate a catalogue document. Raises `ValueError`: a malformed catalogue is a packaging bug."""
98
+ if not COMMIT.fullmatch(raw["commit"]):
99
+ raise ValueError("catalogue commit must be 40 lowercase hex digits")
100
+ models = tuple(Model(m["id"], m["path"], m["title"], int(m["size"]), m["sha256"]) for m in raw["models"])
101
+ reports = tuple(
102
+ Report(
103
+ r["id"],
104
+ r["designation"],
105
+ r["title"],
106
+ r["issue"],
107
+ r["page_url"],
108
+ r["source_url"],
109
+ int(r["size"]),
110
+ r["sha256"],
111
+ )
112
+ for r in raw["reports"]
113
+ )
114
+ entries: list[Model | Report] = [*models, *reports]
115
+ for entry in entries:
116
+ if not DOC_ID.fullmatch(entry.id) or not SHA256.fullmatch(entry.sha256):
117
+ raise ValueError(f"catalogue entry {entry.id!r} is malformed")
118
+ for model in models:
119
+ if model.path != f"{model.id}.xml":
120
+ raise ValueError(f"catalogue entry {model.id!r}: path must be <id>.xml")
121
+ ids = [e.id for e in entries]
122
+ if len(set(ids)) != len(ids):
123
+ raise ValueError("duplicate ids in the catalogue")
124
+ return Catalogue(
125
+ raw["repo"],
126
+ raw["commit"],
127
+ raw["raw_base"],
128
+ _terms(raw["terms"]["models"]),
129
+ _terms(raw["terms"]["reports"]),
130
+ models,
131
+ reports,
132
+ )
133
+
134
+
135
+ def catalogue() -> Catalogue:
136
+ text = resources.files("primarysource.bbf").joinpath("catalogue.json").read_text(encoding="utf-8")
137
+ return parse_catalogue(json.loads(text))
@@ -0,0 +1,79 @@
1
+ """`primarysource bbf ...`: the collection's own commands.
2
+
3
+ primarysource bbf import-models [ID ...]
4
+ primarysource bbf import-report <ID> [--file PDF]
5
+ primarysource bbf docs [FILTER]
6
+ primarysource bbf outline <doc> [--under PATH] [--limit N] [--start N]
7
+ primarysource bbf read <ref> [--max-tokens N]
8
+ primarysource bbf search <query> [--doc ID] [--limit N] [--start N]
9
+ primarysource bbf cite <ref>
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import argparse
15
+ import sys
16
+ from collections.abc import Sequence
17
+ from pathlib import Path
18
+
19
+ from primarysource.bbf import importer, library
20
+ from primarysource.errors import LibError
21
+
22
+
23
+ def _parser() -> argparse.ArgumentParser:
24
+ parser = argparse.ArgumentParser(prog="primarysource bbf", description="Broadband Forum data models and reports")
25
+ commands = parser.add_subparsers(dest="command", required=True)
26
+ models = commands.add_parser("import-models", help="fetch and verify data-model XML (one request per second)")
27
+ models.add_argument("ids", nargs="*", help="file stems to import (default: every catalogued file)")
28
+ report = commands.add_parser(
29
+ "import-report", help="fetch (or read a file), verify and extract a Technical Report"
30
+ )
31
+ report.add_argument("id", help="e.g. tr-069-1-6-1")
32
+ report.add_argument("--file", type=Path, help="the PDF you downloaded yourself, instead of fetching it")
33
+ commands.add_parser("docs", help="the catalogue and what is imported").add_argument(
34
+ "filter", nargs="?", default=""
35
+ )
36
+ outline = commands.add_parser("outline", help="clauses of a report, or nodes of a data model")
37
+ outline.add_argument("doc")
38
+ outline.add_argument("--under", default="")
39
+ outline.add_argument("--limit", type=int, default=200)
40
+ outline.add_argument("--start", type=int, default=0)
41
+ read = commands.add_parser("read", help="read by ref")
42
+ read.add_argument("ref")
43
+ read.add_argument("--max-tokens", type=int, default=2000)
44
+ search = commands.add_parser("search", help="find units containing words")
45
+ search.add_argument("query")
46
+ search.add_argument("--doc")
47
+ search.add_argument("--limit", type=int, default=10)
48
+ search.add_argument("--start", type=int, default=0)
49
+ commands.add_parser("cite", help="the citation for a ref").add_argument("ref")
50
+ return parser
51
+
52
+
53
+ def _out(text: str) -> int:
54
+ sys.stdout.write(text if text.endswith("\n") else text + "\n")
55
+ return 0
56
+
57
+
58
+ def main(argv: Sequence[str]) -> int:
59
+ args = _parser().parse_args(list(argv))
60
+ home = library.default_home()
61
+ try:
62
+ if args.command == "import-models":
63
+ result = importer.import_models(home, args.ids)
64
+ return _out(f"imported {len(result.ids)} data-model files, {result.requests} request(s), into {home}")
65
+ if args.command == "import-report":
66
+ result = importer.import_report(home, args.id, file=args.file)
67
+ return _out(f"imported {args.id}, {result.requests} request(s), into {home}")
68
+ if args.command == "docs":
69
+ return _out(library.docs(args.filter))
70
+ if args.command == "outline":
71
+ return _out(library.outline(args.doc, under=args.under, limit=args.limit, start=args.start))
72
+ if args.command == "read":
73
+ return _out(library.read(args.ref, max_tokens=args.max_tokens))
74
+ if args.command == "search":
75
+ return _out(library.search(args.query, doc=args.doc, limit=args.limit, start=args.start))
76
+ return _out(library.cite(args.ref))
77
+ except LibError as exc:
78
+ print(exc.as_text(), file=sys.stderr)
79
+ return 1
@@ -0,0 +1,281 @@
1
+ """Read a Broadband Forum data-model XML file (CWMP and USP `dm:document`) into addressable nodes.
2
+
3
+ A node is an object, a parameter, a command, an event, a profile, a data type or a model. Its address (the part
4
+ of a ref after `§`) is the full path: `Device.IP.Interface.{i}` for an object (written without its trailing dot;
5
+ `Device.IP.Interface.{i}.` also resolves), `Device.IP.Interface.{i}.Enable` for a parameter,
6
+ `Device.Reboot()` for a command, `Device.Boot!` for an event, `Baseline:1` for a profile, `IPAddress` for a data
7
+ type, `Root:2.22` for a model. A command's arguments are `Device.Reboot().Input.Delay` and
8
+ `Device.Reboot().Output.X`.
9
+
10
+ `¶n` counts the paragraphs of a node's description, in order. The description is kept as the file gives it: BBF
11
+ template macros (`{{param}}`, `{{enum}}`, `{{numentries}}`, `{{object}}`) are NOT expanded; they are the
12
+ publisher's own markup and expanding them would be this code's interpretation, not the standard's text.
13
+ A profile's units are its description paragraphs and then one line per object or parameter it requires.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import re
19
+ import xml.etree.ElementTree as ET # noqa: S405 - elements come from the hardened parser in `_xml`
20
+ from dataclasses import dataclass, field
21
+
22
+ from primarysource.bbf import _xml
23
+ from primarysource.errors import LibError
24
+
25
+ KINDS = ("model", "object", "parameter", "command", "event", "profile", "datatype")
26
+ MAX_NODES = 400_000
27
+ MAX_ENUM = 200
28
+ _PARAGRAPH_BREAK = re.compile(r"\n[ \t]*\n")
29
+ _TITLE = re.compile(rb"<!--\s*\n?\s*([^\n]+)")
30
+
31
+
32
+ @dataclass(frozen=True, slots=True)
33
+ class Node:
34
+ """One addressable item. `props` is the ordered attribute summary shown in the header line."""
35
+
36
+ kind: str
37
+ id: str
38
+ props: tuple[tuple[str, str], ...] = ()
39
+ paragraphs: tuple[str, ...] = ()
40
+ component: str = ""
41
+
42
+ def header(self) -> str:
43
+ shown = " ".join(f"{k}={v}" for k, v in self.props if v != "")
44
+ text = f"{self.kind} {self.id}" + (f" [{shown}]" if shown else "")
45
+ return text + (f" (component {self.component})" if self.component else "")
46
+
47
+
48
+ @dataclass(frozen=True, slots=True)
49
+ class DataModel:
50
+ """A parsed file. `nodes` are in document order; `imports` are the files it imports."""
51
+
52
+ title: str
53
+ spec: str
54
+ file: str
55
+ imports: tuple[str, ...]
56
+ nodes: tuple[Node, ...]
57
+ index: dict[str, int] = field(default_factory=dict)
58
+
59
+ def find(self, wanted: str) -> tuple[Node, str]:
60
+ """The node `wanted` names and a warning ("" if none): exact id, then without a trailing dot, then with
61
+ case ignored. Raises `LibError` with close matches when nothing fits."""
62
+ text = wanted.strip()
63
+ for candidate in (text, text.rstrip(".") if text.endswith(".") else text):
64
+ if candidate in self.index:
65
+ return self._answer(candidate)
66
+ low = text.rstrip(".").casefold()
67
+ for key in self.index:
68
+ if key.casefold() == low:
69
+ return self._answer(key)
70
+ import difflib
71
+
72
+ close = difflib.get_close_matches(text.rstrip("."), list(self.index), n=3, cutoff=0.6)
73
+ hint = f"did you mean: {', '.join(close)}" if close else "bbf_outline lists the nodes of a data model"
74
+ raise LibError(f"no node {text!r} in {self.file or 'this file'}", hint)
75
+
76
+ def _answer(self, key: str) -> tuple[Node, str]:
77
+ same = [n for n in self.nodes if n.id == key]
78
+ warning = f"{len(same)} definitions of {key}; this is the first" if len(same) > 1 else ""
79
+ return self.nodes[self.index[key]], warning
80
+
81
+ def children(self, node: Node) -> tuple[Node, ...]:
82
+ """The parameters, commands and events directly under an object."""
83
+ if node.kind != "object":
84
+ return ()
85
+ prefix = node.id + "."
86
+ out: list[Node] = []
87
+ for other in self.nodes:
88
+ if other.kind in ("parameter", "command", "event") and other.id.startswith(prefix):
89
+ rest = other.id[len(prefix) :]
90
+ if "." not in rest.replace("().", "()#"):
91
+ out.append(other)
92
+ return tuple(out)
93
+
94
+
95
+ def _paragraphs(element: ET.Element | None) -> tuple[str, ...]:
96
+ """The text of a `<description>`: blank-line-separated paragraphs, whitespace collapsed inside each."""
97
+ if element is None:
98
+ return ()
99
+ text = "".join(element.itertext()).strip("\n")
100
+ return tuple(" ".join(p.split()) for p in _PARAGRAPH_BREAK.split(text) if p.strip())
101
+
102
+
103
+ def _syntax(parameter: ET.Element) -> list[tuple[str, str]]:
104
+ """The type and restrictions of a parameter as header props."""
105
+ syntax = parameter.find("syntax")
106
+ if syntax is None:
107
+ return []
108
+ props: list[tuple[str, str]] = []
109
+ is_list = syntax.find("list")
110
+ holder = is_list if is_list is not None else syntax # a list holds the element type, and its own bounds
111
+ type_el = next(
112
+ (c for c in holder if c.tag not in ("list", "default", "description", "hidden", "command", "size")), None
113
+ )
114
+ scope = type_el if type_el is not None else syntax
115
+ name = ""
116
+ if type_el is not None:
117
+ name = type_el.get("ref") or type_el.tag
118
+ extras = {
119
+ k: v
120
+ for k, v in type_el.attrib.items()
121
+ if k in ("refType", "targetParent", "targetType", "targetParentScope")
122
+ }
123
+ if extras:
124
+ name += "(" + ",".join(f"{k}={v}" for k, v in sorted(extras.items())) + ")"
125
+ bounds: list[str] = []
126
+ for restriction in [*scope, *(list(is_list.findall("size")) if is_list is not None else [])]:
127
+ if restriction.tag == "size":
128
+ bounds.append(f"size={restriction.get('minLength', '')}..{restriction.get('maxLength', '')}")
129
+ elif restriction.tag == "range":
130
+ bounds.append("range=" + restriction.get("minInclusive", "") + ".." + restriction.get("maxInclusive", ""))
131
+ elif restriction.tag == "pattern" and restriction.get("value"):
132
+ bounds.append(f"pattern={restriction.get('value')}")
133
+ elif restriction.tag == "units":
134
+ bounds.append(f"units={restriction.get('value', '')}")
135
+ if is_list is not None:
136
+ name = f"list of {name}" if name else "list"
137
+ bounds = [f"items={is_list.get('minItems', '')}..{is_list.get('maxItems', '')}", *bounds]
138
+ props.append(("type", name + (f"({', '.join(bounds)})" if bounds else "")))
139
+ values = [e.get("value", "") for e in scope.iter("enumeration")][:MAX_ENUM]
140
+ if values:
141
+ props.append(("enum", "|".join(values)))
142
+ default = syntax.find("default")
143
+ if default is not None and default.get("value") is not None:
144
+ props.append(("default", repr(default.get("value"))))
145
+ return props
146
+
147
+
148
+ _COMMON = (
149
+ "access",
150
+ "version",
151
+ "status",
152
+ "mandatory",
153
+ "activeNotify",
154
+ "forcedInform",
155
+ "async",
156
+ "minEntries",
157
+ "maxEntries",
158
+ )
159
+
160
+
161
+ def _props(element: ET.Element, extra: list[tuple[str, str]] | None = None) -> tuple[tuple[str, str], ...]:
162
+ found = [(k, element.get(k, "")) for k in _COMMON if element.get(k) is not None]
163
+ return tuple(found + (extra or []))
164
+
165
+
166
+ def parse(data: bytes) -> DataModel:
167
+ """Parse one file. Raises `LibError` if it is not a well-formed BBF data-model document."""
168
+ root = _xml.parse(data)
169
+ if root.tag != "document":
170
+ raise LibError(
171
+ f"the root element is <{root.tag}>, not a data-model document", "this is not a BBF data-model file"
172
+ )
173
+ title_match = _TITLE.search(data[:4096])
174
+ title = title_match.group(1).decode("utf-8", errors="replace").strip() if title_match else ""
175
+ nodes: list[Node] = []
176
+
177
+ def add(node: Node) -> None:
178
+ if len(nodes) >= MAX_NODES:
179
+ raise LibError("the data model has too many nodes", "")
180
+ nodes.append(node)
181
+
182
+ def walk_object(obj: ET.Element, component: str) -> None:
183
+ path = (obj.get("name") or obj.get("ref") or "").strip()
184
+ if not path:
185
+ return
186
+ oid = path.rstrip(".")
187
+ keys = [p.get("ref", "") for k in obj.findall("uniqueKey") for p in k.findall("parameter")]
188
+ extra = [("uniqueKey", ",".join(keys))] if keys else []
189
+ if obj.get("numEntriesParameter"):
190
+ extra.append(("numEntriesParameter", obj.get("numEntriesParameter", "")))
191
+ add(Node("object", oid, _props(obj, extra), _paragraphs(obj.find("description")), component))
192
+ for child in obj:
193
+ if child.tag == "parameter" and child.get("name"):
194
+ pid = f"{oid}.{child.get('name')}"
195
+ add(
196
+ Node(
197
+ "parameter",
198
+ pid,
199
+ _props(child, _syntax(child)),
200
+ _paragraphs(child.find("description")),
201
+ component,
202
+ )
203
+ )
204
+ elif child.tag in ("command", "event") and child.get("name"):
205
+ name = child.get("name", "")
206
+ cid = f"{oid}.{name}" if child.tag == "event" or name.endswith("()") else f"{oid}.{name}()"
207
+ add(Node(child.tag, cid, _props(child), _paragraphs(child.find("description")), component))
208
+ for part in ("input", "output"):
209
+ for arg in child.findall(f"{part}/parameter"):
210
+ if arg.get("name"):
211
+ aid = f"{cid}.{part.capitalize()}.{arg.get('name')}"
212
+ add(
213
+ Node(
214
+ "parameter",
215
+ aid,
216
+ _props(arg, _syntax(arg)),
217
+ _paragraphs(arg.find("description")),
218
+ component,
219
+ )
220
+ )
221
+ if child.tag == "event":
222
+ for arg in child.findall("parameter"):
223
+ if arg.get("name"):
224
+ add(
225
+ Node(
226
+ "parameter",
227
+ f"{cid}.{arg.get('name')}",
228
+ _props(arg, _syntax(arg)),
229
+ _paragraphs(arg.find("description")),
230
+ component,
231
+ )
232
+ )
233
+
234
+ def walk_profile(profile: ET.Element, component: str) -> None:
235
+ name = profile.get("name")
236
+ if not name:
237
+ return
238
+ lines: list[str] = []
239
+ for member in profile:
240
+ if member.tag == "object" and member.get("ref"):
241
+ lines.append(f"object {member.get('ref')} requires {member.get('requirement', '')}")
242
+ lines.extend(
243
+ f"parameter {member.get('ref')}{p.get('ref')} requires {p.get('requirement', '')}"
244
+ for p in member.findall("parameter")
245
+ if p.get("ref")
246
+ )
247
+ extra = [(k, profile.get(k, "")) for k in ("base", "extends") if profile.get(k)]
248
+ add(Node("profile", name, tuple(extra), (*_paragraphs(profile.find("description")), *lines), component))
249
+
250
+ def walk_container(container: ET.Element, component: str) -> None:
251
+ for child in container:
252
+ if child.tag == "object":
253
+ walk_object(child, component)
254
+ elif child.tag == "profile":
255
+ walk_profile(child, component)
256
+
257
+ imports: list[str] = []
258
+ for child in root:
259
+ if child.tag == "import" and child.get("file"):
260
+ imports.append(child.get("file", ""))
261
+ elif child.tag == "dataType" and child.get("name"):
262
+ add(
263
+ Node(
264
+ "datatype",
265
+ child.get("name", ""),
266
+ _props(child, _syntax(child)),
267
+ _paragraphs(child.find("description")),
268
+ )
269
+ )
270
+ elif child.tag == "component":
271
+ walk_container(child, child.get("name", ""))
272
+ elif child.tag == "model" and child.get("name"):
273
+ refs = [c.get("ref", "") for c in child.findall("component")]
274
+ extra = [("components", ",".join(refs))] if refs else []
275
+ extra += [(k, child.get(k, "")) for k in ("base", "isService") if child.get(k)]
276
+ add(Node("model", child.get("name", ""), tuple(extra), _paragraphs(child.find("description"))))
277
+ walk_container(child, "")
278
+ index: dict[str, int] = {}
279
+ for position, node in enumerate(nodes):
280
+ index.setdefault(node.id, position)
281
+ return DataModel(title, root.get("spec", ""), root.get("file", ""), tuple(imports), tuple(nodes), index)
@@ -0,0 +1,151 @@
1
+ """Import Broadband Forum data models and Technical Reports into `$PRIMARYSOURCE_HOME/bbf/`.
2
+
3
+ - `import_models`: each XML file is fetched from `raw.githubusercontent.com/BroadbandForum/cwmp-data-models/<commit>/`
4
+ (one request per second), checked against its pinned sha256, parsed once with the hardened parser (a file that
5
+ does not parse is not installed) and written to `models/<id>.xml`.
6
+ - `import_report`: a Technical Report PDF is fetched from broadband-forum.org (or taken from a file the user
7
+ downloaded), checked against its sha256, stored as `reports/<id>.pdf`, and read once into `reports/<id>.json`
8
+ with the private PDF reader. Reports go on a volume: the Forum's terms do not allow redistribution of
9
+ derivatives, and this code only writes to the user's own home.
10
+
11
+ `install.json` is written last, after every file it names. Run as `primarysource bbf import-models [ID ...]` and
12
+ `primarysource bbf import-report <ID> [--file PDF]`.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ import os
19
+ from collections.abc import Sequence
20
+ from dataclasses import dataclass
21
+ from datetime import UTC, datetime
22
+ from pathlib import Path
23
+ from typing import Any
24
+
25
+ from primarysource.bbf import _xml, datamodel
26
+ from primarysource.bbf.catalogue import DOC_ID, HOSTS, Catalogue, Model
27
+ from primarysource.bbf.catalogue import catalogue as packaged_catalogue
28
+ from primarysource.c2sp._fetch import Fetcher, Transport, sha256, verify
29
+ from primarysource.ecma import _pdf
30
+ from primarysource.errors import LibError
31
+ from primarysource.home import atomic_write
32
+
33
+ INSTALL_RECORD = "install.json"
34
+ MAX_PDF = 64 * 1024 * 1024
35
+
36
+
37
+ @dataclass(frozen=True, slots=True)
38
+ class ImportResult:
39
+ ids: tuple[str, ...]
40
+ requests: int
41
+
42
+
43
+ def read_record(home: Path) -> dict[str, Any]:
44
+ """The install record, or an empty one. Always has `models` and `reports` mappings."""
45
+ try:
46
+ loaded = json.loads((home / INSTALL_RECORD).read_text(encoding="utf-8"))
47
+ except (OSError, ValueError):
48
+ loaded = {}
49
+ if not isinstance(loaded, dict):
50
+ loaded = {}
51
+ for key in ("models", "reports"):
52
+ if not isinstance(loaded.get(key), dict):
53
+ loaded[key] = {}
54
+ return loaded
55
+
56
+
57
+ def _write_record(home: Path, record: dict[str, Any]) -> None:
58
+ record["collection"] = "bbf"
59
+ atomic_write(home / INSTALL_RECORD, (json.dumps(record, indent=2) + "\n").encode("utf-8"))
60
+
61
+
62
+ def import_models(
63
+ home: Path,
64
+ only: Sequence[str] = (),
65
+ *,
66
+ cat: Catalogue | None = None,
67
+ transport: Transport | None = None,
68
+ fetcher: Fetcher | None = None,
69
+ ) -> ImportResult:
70
+ """Fetch, verify, parse and install the catalogued data-model files (all, or those named in `only`)."""
71
+ cat = cat or packaged_catalogue()
72
+ chosen = [cat.model(name) for name in only] if only else list(cat.models)
73
+ fetcher = fetcher or Fetcher(HOSTS, transport)
74
+ record = read_record(home)
75
+ done: list[str] = []
76
+ for model in chosen:
77
+ data = fetcher.get(cat.model_url(model), min(_xml.MAX_BYTES, max(model.size * 2, 4096)))
78
+ verify(data, model.sha256, model.id)
79
+ datamodel.parse(data)
80
+ atomic_write(home / "models" / model.path, data)
81
+ record["models"][model.id] = model.sha256
82
+ done.append(model.id)
83
+ record["commit"] = cat.commit
84
+ record["models_licence"] = cat.model_terms.licence
85
+ record["imported"] = datetime.now(UTC).isoformat(timespec="seconds")
86
+ _write_record(home, record)
87
+ return ImportResult(tuple(done), fetcher.requests)
88
+
89
+
90
+ def import_report(
91
+ home: Path,
92
+ report_id: str,
93
+ *,
94
+ file: Path | None = None,
95
+ cat: Catalogue | None = None,
96
+ transport: Transport | None = None,
97
+ fetcher: Fetcher | None = None,
98
+ ) -> ImportResult:
99
+ """Fetch (or read `file`), verify, store and extract one Technical Report PDF."""
100
+ cat = cat or packaged_catalogue()
101
+ report = cat.report(report_id)
102
+ requests = 0
103
+ if file is not None:
104
+ try:
105
+ data = file.read_bytes()
106
+ except OSError as exc:
107
+ raise LibError(f"cannot read {file}: {exc}", "pass the PDF you downloaded from the Forum") from exc
108
+ if len(data) > MAX_PDF:
109
+ raise LibError(f"{file} is larger than {MAX_PDF} bytes", "")
110
+ else:
111
+ fetcher = fetcher or Fetcher(HOSTS, transport)
112
+ data = fetcher.get(report.source_url, MAX_PDF)
113
+ requests = fetcher.requests
114
+ verify(data, report.sha256, report.designation)
115
+ if not data.startswith(b"%PDF-"):
116
+ raise LibError(f"{report.designation}: not a PDF", "the pinned file is a PDF; nothing was written")
117
+ folder = home / "reports"
118
+ folder.mkdir(parents=True, exist_ok=True)
119
+ part = folder / f"{report.id}.pdf.part"
120
+ part.write_bytes(data)
121
+ try:
122
+ extraction = _pdf.extract(data, part)
123
+ except BaseException:
124
+ part.unlink(missing_ok=True)
125
+ raise
126
+ os.replace(part, folder / f"{report.id}.pdf")
127
+ extraction.update(
128
+ {"title": report.title, "designation": report.designation, "edition": report.issue, "sha256": report.sha256}
129
+ )
130
+ atomic_write(folder / f"{report.id}.json", (json.dumps(extraction, ensure_ascii=False) + "\n").encode("utf-8"))
131
+ record = read_record(home)
132
+ record["reports"][report.id] = {
133
+ "sha256": report.sha256,
134
+ "extractor": extraction["extractor"],
135
+ "imported": datetime.now(UTC).isoformat(timespec="seconds"),
136
+ }
137
+ _write_record(home, record)
138
+ return ImportResult((report.id,), requests)
139
+
140
+
141
+ def pin_models(raw_base: str, commit: str, names: Sequence[str], fetcher: Fetcher) -> list[Model]:
142
+ """Maintainer step: fetch `names` (file stems) at `commit` and return catalogue entries with size and sha256.
143
+ The result is what goes in `catalogue.json`; it is a review step, never run by the importer."""
144
+ pinned: list[Model] = []
145
+ for name in names:
146
+ if not DOC_ID.fullmatch(name):
147
+ raise LibError(f"not a data-model file stem: {name!r}", "")
148
+ data = fetcher.get(f"{raw_base}{commit}/{name}.xml", _xml.MAX_BYTES)
149
+ parsed = datamodel.parse(data)
150
+ pinned.append(Model(name, f"{name}.xml", parsed.title or name, len(data), sha256(data)))
151
+ return pinned