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,480 @@
1
+ """OSCAL catalogues as a second, structured source: ``python -m primarysource.nist.ingest.oscal``.
2
+
3
+ NIST publishes machine-readable catalogues in the ``usnistgov/oscal-content`` repository. Each catalogue is
4
+ rendered to a plain-text file with one section per control (the control id is the section id, the control
5
+ title its heading) and indexed through the same writer as the PDF pipeline, under a doc_id ending in
6
+ ``-oscal`` so the two versions of a publication cannot collide.
7
+
8
+ The catalogues to fetch are the ``[[oscal]]`` tables of data/sources.toml, each pinned to a commit and to the
9
+ git blob sha of the file. The PDF pipeline ignores those tables. This module touches the network only for those URLs.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from primarysource.nist.paths import data_dir as default_data_dir, sources_file
15
+
16
+ import argparse
17
+ import hashlib
18
+ import json
19
+ import os
20
+ import re
21
+ import sys
22
+ import time
23
+ import tomllib
24
+ import urllib.error
25
+ import urllib.request
26
+ import xml.etree.ElementTree as ET
27
+ from collections.abc import Sequence
28
+ from dataclasses import dataclass
29
+ from pathlib import Path
30
+
31
+ from primarysource.nist.ingest.index import read_index, sha256_bytes, write_index
32
+ from primarysource.nist.spec import SPEC
33
+ from primarysource.pdfcorpus.fts import build_after_fetch
34
+ from primarysource.nist.models import Document, Section
35
+
36
+ NAMESPACE = "http://csrc.nist.gov/ns/oscal/1.0"
37
+ USER_AGENT = "nistlib-oscal/0.1 (corpus fetcher; one request per second)"
38
+ TIMEOUT_SECONDS = 120
39
+ MAX_BYTES = 50 * 1024 * 1024
40
+ THROTTLE_SECONDS = 1.0
41
+ DOC_ID_SUFFIX = "-oscal"
42
+ ALLOWED_HOST_PREFIX = "https://raw.githubusercontent.com/usnistgov/oscal-content/"
43
+
44
+ _DATE = re.compile(r"^(\d{4}-(?:0[1-9]|1[0-2]))")
45
+ _DOC_ID = re.compile(r"^[a-z0-9][a-z0-9._-]*$")
46
+ _HEX40 = re.compile(r"^[0-9a-f]{40}$")
47
+ _REQUIRED = ("doc_id", "url", "number", "title", "date", "status", "commit", "blob")
48
+ _SKIPPED_CHILDREN = frozenset({"prop", "link", "title", "param", "label"})
49
+
50
+
51
+ class OscalError(RuntimeError):
52
+ """Raised when a catalogue cannot be fetched, verified or parsed."""
53
+
54
+
55
+ def _q(name: str) -> str:
56
+ return f"{{{NAMESPACE}}}{name}"
57
+
58
+
59
+ def _local(element: ET.Element) -> str:
60
+ tag = element.tag
61
+ if not isinstance(tag, str):
62
+ return ""
63
+ prefix = f"{{{NAMESPACE}}}"
64
+ return tag[len(prefix) :] if tag.startswith(prefix) else tag
65
+
66
+
67
+ def _norm(text: str) -> str:
68
+ return " ".join(text.split())
69
+
70
+
71
+ # -- sources -----------------------------------------------------------------------------------------------
72
+
73
+
74
+ @dataclass(frozen=True, slots=True)
75
+ class OscalSource:
76
+ """One ``[[oscal]]`` table of sources.toml."""
77
+
78
+ doc_id: str
79
+ url: str
80
+ number: str
81
+ title: str
82
+ date: str
83
+ status: str
84
+ commit: str
85
+ blob: str
86
+
87
+
88
+ def parse_oscal_sources(text: str) -> tuple[OscalSource, ...]:
89
+ try:
90
+ data = tomllib.loads(text)
91
+ except tomllib.TOMLDecodeError as error:
92
+ raise OscalError(f"sources.toml is not valid TOML: {error}") from error
93
+ entries = data.get("oscal", [])
94
+ if not isinstance(entries, list):
95
+ raise OscalError("sources.toml: oscal must be an array of tables")
96
+ result: list[OscalSource] = []
97
+ seen: set[str] = set()
98
+ for position, entry in enumerate(entries, start=1):
99
+ label = f"oscal #{position}"
100
+ if not isinstance(entry, dict):
101
+ raise OscalError(f"{label}: not a table")
102
+ missing = [
103
+ k
104
+ for k in _REQUIRED
105
+ if not isinstance(entry.get(k), str) or not entry[k].strip()
106
+ ]
107
+ if missing:
108
+ raise OscalError(
109
+ f"{label}: missing or empty field(s): {', '.join(missing)}"
110
+ )
111
+ unknown = sorted(set(entry) - set(_REQUIRED))
112
+ if unknown:
113
+ raise OscalError(f"{label}: unknown field(s): {', '.join(unknown)}")
114
+ doc_id = entry["doc_id"]
115
+ label = f"oscal {doc_id!r}"
116
+ if not _DOC_ID.match(doc_id) or not doc_id.endswith(DOC_ID_SUFFIX):
117
+ raise OscalError(
118
+ f"{label}: doc_id must be a file-safe slug ending in {DOC_ID_SUFFIX}"
119
+ )
120
+ if doc_id in seen:
121
+ raise OscalError(f"{label}: duplicate doc_id")
122
+ seen.add(doc_id)
123
+ if not entry["url"].startswith(ALLOWED_HOST_PREFIX):
124
+ raise OscalError(f"{label}: url must be under {ALLOWED_HOST_PREFIX}")
125
+ if not _DATE.fullmatch(entry["date"]):
126
+ raise OscalError(f"{label}: date must be YYYY-MM")
127
+ for key in ("commit", "blob"):
128
+ if not _HEX40.match(entry[key]):
129
+ raise OscalError(f"{label}: {key} must be 40 lowercase hex digits")
130
+ if entry["commit"] not in entry["url"]:
131
+ raise OscalError(f"{label}: url is not pinned to the recorded commit")
132
+ result.append(OscalSource(**{k: entry[k] for k in _REQUIRED}))
133
+ return tuple(result)
134
+
135
+
136
+ def load_oscal_sources(path: Path) -> tuple[OscalSource, ...]:
137
+ try:
138
+ return parse_oscal_sources(path.read_text(encoding="utf-8"))
139
+ except OSError as error:
140
+ raise OscalError(f"cannot read {path}: {error}") from error
141
+
142
+
143
+ # -- rendering ---------------------------------------------------------------------------------------------
144
+
145
+
146
+ def _inline(element: ET.Element, params: dict[str, str]) -> str:
147
+ """Plain text of a mixed-content element. ``insert`` becomes its parameter's label, ``q`` is quoted."""
148
+ pieces: list[str] = [element.text or ""]
149
+ for child in element:
150
+ name = _local(child)
151
+ if name == "insert":
152
+ ref = child.get("id-ref", "")
153
+ pieces.append(f"[{params.get(ref) or ref}]")
154
+ elif name == "q":
155
+ pieces.append(f'"{_inline(child, params)}"')
156
+ else:
157
+ pieces.append(_inline(child, params))
158
+ pieces.append(child.tail or "")
159
+ return _norm("".join(pieces))
160
+
161
+
162
+ def _part_label(part: ET.Element) -> str:
163
+ for prop in part.findall(_q("prop")):
164
+ if prop.get("name") == "label":
165
+ return _norm(prop.get("value", ""))
166
+ return ""
167
+
168
+
169
+ def _part_lines(part: ET.Element, params: dict[str, str], depth: int) -> list[str]:
170
+ """Lines for one part: its prose, list items and nested parts, indented by nesting depth."""
171
+ indent = " " * depth
172
+ out: list[str] = []
173
+ pending = _part_label(part)
174
+
175
+ def emit(text: str) -> None:
176
+ nonlocal pending
177
+ if not text:
178
+ return
179
+ out.append(f"{indent}{pending + ' ' if pending else ''}{text}")
180
+ pending = ""
181
+
182
+ for child in part:
183
+ name = _local(child)
184
+ if name == "p":
185
+ emit(_inline(child, params))
186
+ elif name in ("ul", "ol"):
187
+ for item in child.findall(_q("li")):
188
+ emit("- " + _inline(item, params))
189
+ elif name == "part":
190
+ if pending:
191
+ out.append(f"{indent}{pending}")
192
+ pending = ""
193
+ out.extend(_part_lines(child, params, depth + 1))
194
+ elif name not in _SKIPPED_CHILDREN:
195
+ emit(_inline(child, params))
196
+ if pending:
197
+ out.append(f"{indent}{pending}")
198
+ return out
199
+
200
+
201
+ def _top_part_lines(part: ET.Element, params: dict[str, str]) -> list[str]:
202
+ """A control's top-level part. Everything but the statement gets a heading line naming it."""
203
+ name = part.get("name", "")
204
+ if name == "statement":
205
+ return _part_lines(part, params, 0)
206
+ title = (
207
+ _norm(part.findtext(_q("title")) or "") or name.replace("-", " ").capitalize()
208
+ )
209
+ return [f"{title}:", *_part_lines(part, params, 1)]
210
+
211
+
212
+ def _select_text(param: ET.Element) -> str:
213
+ """A parameter without a label may offer choices: ``one-or-more: a | b``, else ``a | b``."""
214
+ select = param.find(_q("select"))
215
+ if select is None:
216
+ return ""
217
+ choices = " | ".join(
218
+ _norm("".join(c.itertext())) for c in select.findall(_q("choice"))
219
+ )
220
+ how_many = select.get("how-many")
221
+ return f"{how_many}: {choices}" if how_many and choices else choices
222
+
223
+
224
+ def _collect_params(root: ET.Element) -> dict[str, str]:
225
+ """Parameter id -> label, so a prose ``insert`` can name what it stands for."""
226
+ params: dict[str, str] = {}
227
+ for param in root.iter(_q("param")):
228
+ pid = param.get("id")
229
+ if pid:
230
+ params[pid] = _norm(param.findtext(_q("label")) or "") or _select_text(
231
+ param
232
+ )
233
+ return params
234
+
235
+
236
+ def render_catalog(root: ET.Element) -> tuple[str, tuple[Section, ...]]:
237
+ """Render a parsed OSCAL catalogue to (text, sections), one section per control, in document order.
238
+
239
+ Groups are walked, nested controls become sections of their own right after their parent, and a blank
240
+ line separates sections. A section's line range covers its heading and its own prose only.
241
+ """
242
+ if root.tag != _q("catalog"):
243
+ raise OscalError(
244
+ f"root element is {root.tag!r}, expected {{{NAMESPACE}}}catalog"
245
+ )
246
+ params = _collect_params(root)
247
+ lines: list[str] = []
248
+ sections: list[Section] = []
249
+ seen: set[str] = set()
250
+
251
+ def add_control(control: ET.Element) -> None:
252
+ cid = (control.get("id") or "").strip()
253
+ if not cid:
254
+ raise OscalError("a control has no id")
255
+ if cid in seen:
256
+ raise OscalError(f"duplicate control id {cid!r}")
257
+ seen.add(cid)
258
+ title = _norm(control.findtext(_q("title")) or "") or cid
259
+ body = [f"{cid} {title}"]
260
+ for part in control.findall(_q("part")):
261
+ body.extend(_top_part_lines(part, params))
262
+ start = len(lines) + 1
263
+ lines.extend(body)
264
+ sections.append(Section(cid, title, start, len(lines)))
265
+ lines.append("")
266
+ for nested in control.findall(_q("control")):
267
+ add_control(nested)
268
+
269
+ def walk(container: ET.Element) -> None:
270
+ for child in container:
271
+ name = _local(child)
272
+ if name == "group":
273
+ walk(child)
274
+ elif name == "control":
275
+ add_control(child)
276
+
277
+ walk(root)
278
+ return "\n".join(lines) + "\n", tuple(sections)
279
+
280
+
281
+ def catalog_metadata(root: ET.Element) -> tuple[str, str, str]:
282
+ """(title, date YYYY-MM, status) as the catalogue itself states them.
283
+
284
+ Status is the ``publication-status`` prop, lowercased, or ``unspecified`` when the catalogue has none.
285
+ """
286
+ meta = root.find(_q("metadata"))
287
+ if meta is None:
288
+ raise OscalError("catalogue has no metadata")
289
+ title = _norm(meta.findtext(_q("title")) or "")
290
+ modified = _DATE.match((meta.findtext(_q("last-modified")) or "").strip())
291
+ if not title or modified is None:
292
+ raise OscalError("metadata lacks a title or a last-modified date")
293
+ status = "unspecified"
294
+ for prop in meta.findall(_q("prop")):
295
+ if prop.get("name") == "publication-status" and prop.get("value"):
296
+ status = prop.get("value", "").strip().lower()
297
+ return title, modified.group(1), status
298
+
299
+
300
+ def parse_catalog(data: bytes) -> ET.Element:
301
+ """Parse catalogue bytes with the standard library, refusing DTDs and entity declarations."""
302
+ if b"<!DOCTYPE" in data or b"<!ENTITY" in data:
303
+ raise OscalError("catalogue declares a DTD or entities, refusing to parse it")
304
+ try:
305
+ return ET.fromstring(data)
306
+ except ET.ParseError as error:
307
+ raise OscalError(f"catalogue is not well-formed XML: {error}") from error
308
+
309
+
310
+ # -- fetching and indexing ---------------------------------------------------------------------------------
311
+
312
+
313
+ def git_blob_sha(data: bytes) -> str:
314
+ """The sha1 git assigns to a file's content, which the GitHub contents API reports as ``sha``."""
315
+ return hashlib.sha1( # noqa: S324 - git object id, not a security digest
316
+ b"blob %d\0" % len(data) + data
317
+ ).hexdigest()
318
+
319
+
320
+ def download(url: str, *, timeout: float = TIMEOUT_SECONDS) -> bytes:
321
+ request = urllib.request.Request(url, headers={"User-Agent": USER_AGENT}) # noqa: S310
322
+ try:
323
+ with urllib.request.urlopen(request, timeout=timeout) as response: # noqa: S310
324
+ body: bytes = response.read(MAX_BYTES + 1)
325
+ except (urllib.error.URLError, TimeoutError, OSError) as error:
326
+ raise OscalError(f"download failed for {url}: {error}") from error
327
+ if len(body) > MAX_BYTES:
328
+ raise OscalError(f"{url}: larger than {MAX_BYTES} bytes, refusing")
329
+ return body
330
+
331
+
332
+ def build_document(source: OscalSource, corpus: Path, data: bytes) -> Document:
333
+ """Verify, parse and render ``data`` for ``source``; write ``.xml`` and ``.txt`` and return the entry."""
334
+ if git_blob_sha(data) != source.blob:
335
+ raise OscalError(
336
+ f"{source.doc_id}: blob sha does not match the pinned {source.blob}"
337
+ )
338
+ root = parse_catalog(data)
339
+ title, date, status = catalog_metadata(root)
340
+ for field, actual, recorded in (
341
+ ("title", title, source.title),
342
+ ("date", date, source.date),
343
+ ("status", status, source.status),
344
+ ):
345
+ if actual != recorded:
346
+ raise OscalError(
347
+ f"{source.doc_id}: catalogue {field} is {actual!r}, sources.toml says {recorded!r}"
348
+ )
349
+ text, sections = render_catalog(root)
350
+ if not sections:
351
+ raise OscalError(f"{source.doc_id}: catalogue has no controls")
352
+ (corpus / f"{source.doc_id}.xml").write_bytes(data)
353
+ (corpus / f"{source.doc_id}.txt").write_text(text, encoding="utf-8", newline="")
354
+ return Document(
355
+ doc_id=source.doc_id,
356
+ number=source.number,
357
+ title=source.title,
358
+ date=source.date,
359
+ status=source.status,
360
+ url=source.url,
361
+ sha256=sha256_bytes(data),
362
+ text_file=f"{source.doc_id}.txt",
363
+ sections=sections,
364
+ )
365
+
366
+
367
+ def merge_into_index(index_path: Path, new: list[Document]) -> None:
368
+ """Add or replace ``new`` in the index, leaving every other entry exactly as it is."""
369
+ existing: list[Document] = []
370
+ if index_path.exists():
371
+ try:
372
+ raw = json.loads(index_path.read_text(encoding="utf-8"))
373
+ except (OSError, ValueError) as error:
374
+ raise OscalError(
375
+ f"{index_path} is unreadable, not overwriting it: {error}"
376
+ ) from error
377
+ if not isinstance(raw, dict) or raw.get("schema") != 1:
378
+ raise OscalError(
379
+ f"{index_path} is not a schema 1 index, not overwriting it"
380
+ )
381
+ existing = read_index(index_path)
382
+ if len(existing) != len(raw.get("documents", [])):
383
+ raise OscalError(
384
+ f"{index_path} has entries that do not parse, not overwriting it"
385
+ )
386
+ replaced = {d.doc_id for d in new}
387
+ by_id = {d.doc_id: d for d in new}
388
+ merged = [by_id.get(d.doc_id, d) for d in existing]
389
+ merged.extend(d for d in new if d.doc_id not in {e.doc_id for e in existing})
390
+ assert {d.doc_id for d in merged} >= replaced
391
+ write_index(index_path, merged)
392
+
393
+
394
+ def run(
395
+ data_dir: Path,
396
+ from_files: dict[str, Path],
397
+ only: Sequence[str],
398
+ *,
399
+ sleep: float = THROTTLE_SECONDS,
400
+ ) -> int:
401
+ sources = load_oscal_sources(sources_file(data_dir))
402
+ known = {s.doc_id for s in sources}
403
+ unknown = sorted((set(from_files) | set(only)) - known)
404
+ if unknown:
405
+ raise OscalError(
406
+ f"not an [[oscal]] entry in sources.toml: {', '.join(unknown)}"
407
+ )
408
+ corpus = data_dir / "corpus"
409
+ corpus.mkdir(parents=True, exist_ok=True)
410
+ wanted = set(only) | set(from_files) if (only or from_files) else known
411
+ documents: list[Document] = []
412
+ failures = 0
413
+ requests = 0
414
+ for source in sources:
415
+ if source.doc_id not in wanted:
416
+ continue
417
+ try:
418
+ if source.doc_id in from_files:
419
+ data = from_files[source.doc_id].read_bytes()
420
+ else:
421
+ if requests:
422
+ time.sleep(sleep)
423
+ requests += 1
424
+ data = download(source.url)
425
+ document = build_document(source, corpus, data)
426
+ except (OscalError, OSError) as error:
427
+ print(f"{source.doc_id}: {error}", file=sys.stderr)
428
+ failures += 1
429
+ continue
430
+ print(
431
+ f"{source.doc_id}: {len(document.sections)} controls, sha256 {document.sha256[:12]}"
432
+ )
433
+ documents.append(document)
434
+ if documents:
435
+ merge_into_index(corpus / "index.json", documents)
436
+ print(
437
+ f"updated {corpus / 'index.json'} with {len(documents)} OSCAL document(s)"
438
+ )
439
+ if not build_after_fetch(SPEC, corpus):
440
+ failures += 1
441
+ return 1 if failures else 0
442
+
443
+
444
+ def main(argv: Sequence[str] | None = None) -> int:
445
+ parser = argparse.ArgumentParser(
446
+ prog="python -m primarysource.nist.ingest.oscal", description=__doc__
447
+ )
448
+ parser.add_argument(
449
+ "--data-dir",
450
+ type=Path,
451
+ default=default_data_dir(),
452
+ help="directory holding sources.toml and corpus/ (default: $NISTLIB_DATA, else the collection's home)",
453
+ )
454
+ parser.add_argument(
455
+ "--from-file",
456
+ action="append",
457
+ default=[],
458
+ metavar="DOC_ID=PATH",
459
+ help="use a catalogue file you already have for DOC_ID instead of downloading it (repeatable)",
460
+ )
461
+ parser.add_argument(
462
+ "doc_ids", nargs="*", help="only these documents (default: all)"
463
+ )
464
+ args = parser.parse_args(argv)
465
+ from_files: dict[str, Path] = {}
466
+ for value in args.from_file:
467
+ doc_id, sep, path = value.partition("=")
468
+ if not sep or not doc_id or not path:
469
+ print(f"--from-file expects DOC_ID=PATH, got {value!r}", file=sys.stderr)
470
+ return 2
471
+ from_files[doc_id] = Path(path)
472
+ try:
473
+ return run(args.data_dir, from_files, args.doc_ids)
474
+ except OscalError as error:
475
+ print(f"nistlib-oscal: {error}", file=sys.stderr)
476
+ return 2
477
+
478
+
479
+ if __name__ == "__main__":
480
+ raise SystemExit(main())