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,41 @@
1
+ """How the nist collection recognises a NIST publication in a supplied PDF (`registry.Collection.identify`).
2
+
3
+ Covers read `NIST Special Publication 800-53` with `Revision 5` near it, or `FIPS PUB 201-3`; running headers
4
+ and DOIs repeat `NIST SP 800-53r5`. The nist collection fetches its own pinned copies (`data/sources.toml`), so
5
+ the command it suggests is the fetch.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import re
11
+
12
+ from primarysource.identify import Candidate, Probe, candidates
13
+
14
+ _SP = re.compile(
15
+ r"\bNIST\s+(?:Special\s+Publication|SP)\s+(?P<num>800-\d{1,3}(?:(?-i:[A-Z])(?!\d))?)"
16
+ r"(?:(?:\s*,?\s*(?:Revision|Rev\.?)\s*|r)(?P<rev>\d{1,2}))?",
17
+ re.IGNORECASE,
18
+ )
19
+ _FIPS = re.compile(
20
+ r"\b(?:FIPS|Federal\s+Information\s+Processing\s+Standards?\s+Publication)\s+(?:PUB\s+)?(?P<num>\d{2,3}(?:-\d{1,2})?)\b",
21
+ re.IGNORECASE,
22
+ )
23
+
24
+
25
+ def _sp(match: re.Match[str]) -> str:
26
+ rev = f" Rev. {match['rev']}" if match["rev"] else ""
27
+ return f"NIST SP {match['num'].upper()}{rev}"
28
+
29
+
30
+ def _fips(match: re.Match[str]) -> str:
31
+ return f"NIST FIPS {match['num']}"
32
+
33
+
34
+ def identify(probe: Probe) -> list[Candidate]:
35
+ def command(_label: str) -> str:
36
+ return "primarysource nist fetch (nist fetches its own pinned copies listed in data/sources.toml)"
37
+
38
+ return [
39
+ *candidates(probe, "nist", _SP, _sp, command),
40
+ *candidates(probe, "nist", _FIPS, _fips, command),
41
+ ]
@@ -0,0 +1 @@
1
+ """Ingest side of nistlib: fetch the publications listed in data/sources.toml and index them."""
@@ -0,0 +1,128 @@
1
+ """Download the in-scope PDFs named in data/nist-catalog.json into data/corpus/<doc_id>.pdf.
2
+
3
+ Each download goes to a .part file and is renamed only when complete. The result must start with ``%PDF``.
4
+ data/acquired-nist.json records url, path, size and sha256 per file. A file already present whose sha256
5
+ matches the manifest is skipped. Requests are throttled to one per second with a generic User-Agent.
6
+ Pass --dry-run to print the plan and the total size (from HEAD requests) before downloading anything.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from primarysource.nist.paths import PACKAGE_DATA
12
+
13
+ import argparse
14
+ import hashlib
15
+ import json
16
+ import sys
17
+ import time
18
+ import urllib.error
19
+ import urllib.request
20
+ from collections.abc import Sequence
21
+ from pathlib import Path
22
+
23
+ USER_AGENT = "nistlib-acquire/0.1 (documentation corpus)"
24
+ DELAY = 1.0
25
+ CHUNK = 1 << 20
26
+
27
+
28
+ def _size(url: str) -> int | None:
29
+ req = urllib.request.Request(url, method="HEAD", headers={"User-Agent": USER_AGENT})
30
+ try:
31
+ with urllib.request.urlopen(req, timeout=60) as resp:
32
+ length = resp.headers.get("Content-Length")
33
+ return int(length) if length else None
34
+ except (urllib.error.URLError, TimeoutError):
35
+ return None
36
+ finally:
37
+ time.sleep(DELAY)
38
+
39
+
40
+ def _sha256_file(path: Path) -> str:
41
+ digest = hashlib.sha256()
42
+ with path.open("rb") as f:
43
+ while chunk := f.read(CHUNK):
44
+ digest.update(chunk)
45
+ return digest.hexdigest()
46
+
47
+
48
+ def _download(url: str, dest: Path) -> tuple[int, str]:
49
+ dest.parent.mkdir(parents=True, exist_ok=True)
50
+ part = dest.with_suffix(dest.suffix + ".part")
51
+ digest = hashlib.sha256()
52
+ size = 0
53
+ req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
54
+ try:
55
+ with urllib.request.urlopen(req, timeout=300) as resp, part.open("wb") as out:
56
+ while chunk := resp.read(CHUNK):
57
+ if size == 0 and not chunk.startswith(b"%PDF"):
58
+ raise OSError("response does not start with %PDF")
59
+ out.write(chunk)
60
+ digest.update(chunk)
61
+ size += len(chunk)
62
+ if size == 0:
63
+ raise OSError("empty response")
64
+ part.replace(dest)
65
+ finally:
66
+ part.unlink(missing_ok=True)
67
+ time.sleep(DELAY)
68
+ return size, digest.hexdigest()
69
+
70
+
71
+ def main(argv: Sequence[str] | None = None) -> int:
72
+ parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
73
+ parser.add_argument("--catalog", type=Path, default=PACKAGE_DATA / "nist-catalog.json")
74
+ parser.add_argument("--data-dir", type=Path, default=Path("data"))
75
+ parser.add_argument("--dry-run", action="store_true")
76
+ args = parser.parse_args(argv)
77
+ catalog = json.loads(args.catalog.read_text(encoding="utf-8"))
78
+ items = [e for e in catalog["entries"] if e["in_scope"]]
79
+ if args.dry_run:
80
+ total = 0
81
+ for e in items:
82
+ size = _size(e["pdf_url"])
83
+ total += size or 0
84
+ print(f"{e['doc_id']:<20} {size or '?':>10} {e['pdf_url']}")
85
+ print(f"{len(items)} files, {total / 1e9:.2f} GB known", file=sys.stderr)
86
+ return 0
87
+ manifest_path = args.data_dir / "acquired-nist.json"
88
+ manifest = (
89
+ json.loads(manifest_path.read_text(encoding="utf-8"))
90
+ if manifest_path.exists()
91
+ else {"schema": 1, "files": []}
92
+ )
93
+ by_id = {f["doc_id"]: f for f in manifest["files"]}
94
+ failed = 0
95
+ for e in items:
96
+ doc_id = e["doc_id"]
97
+ dest = args.data_dir / "corpus" / f"{doc_id}.pdf"
98
+ old = by_id.get(doc_id)
99
+ if old and dest.exists() and _sha256_file(dest) == old["sha256"]:
100
+ continue
101
+ try:
102
+ if (
103
+ dest.exists() and not old
104
+ ): # a file placed earlier (the PIV set): record it, never overwrite
105
+ size, sha = dest.stat().st_size, _sha256_file(dest)
106
+ else:
107
+ size, sha = _download(e["pdf_url"], dest)
108
+ except (urllib.error.URLError, OSError, TimeoutError) as err:
109
+ print(f"FAILED {e['pdf_url']}: {err}", file=sys.stderr)
110
+ failed += 1
111
+ continue
112
+ record = {
113
+ "doc_id": doc_id,
114
+ "url": e["pdf_url"],
115
+ "path": f"corpus/{doc_id}.pdf",
116
+ "size": size,
117
+ "sha256": sha,
118
+ }
119
+ by_id[doc_id] = record
120
+ manifest["files"] = list(by_id.values())
121
+ manifest_path.write_text(json.dumps(manifest, indent=2), encoding="utf-8")
122
+ print(f"ok {doc_id} {size}", file=sys.stderr)
123
+ print(f"{len(items) - failed} ok, {failed} failed", file=sys.stderr)
124
+ return 1 if failed else 0
125
+
126
+
127
+ if __name__ == "__main__":
128
+ raise SystemExit(main())
@@ -0,0 +1,82 @@
1
+ """Section splitter for NIST control catalogues printed as PDF (SP 800-53 and its relatives).
2
+
3
+ Control catalogues do not number their sections the way prose documents do. A control is written as its
4
+ identifier followed by an upper-case title (``AC-2 ACCOUNT MANAGEMENT``), and its enhancements are written
5
+ relative to the parent (``(1) ACCOUNT MANAGEMENT | AUTOMATED ...`` under ``AC-2``, which is ``ac-2.1``).
6
+ This splitter recognises both, keeps the first occurrence of each identifier (later ones are the appendix
7
+ summary tables), and accepts a control identifier only where it is anchored: at the start of a line, after a
8
+ run of spaces, or after the page header. Prose that mentions a control in capitals is not a heading. An enhancement heading may also be glued to
9
+ the end of the page header when it opens a page, and is accepted there for the same reason.
10
+
11
+ Where the text has a ``-1`` control, indexing starts at the first one. The worked example of a control's structure in the introduction
12
+ (Figure 1 prints AU-4 and its first enhancement) would otherwise take those two ids, and the real AU-4 would
13
+ then be dropped as a repeat.
14
+
15
+ Validated against the XML catalogue of SP 800-53 Rev 5 (see the -oscal entries): every identifier found is in
16
+ the catalogue, with no false positives.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import re
22
+
23
+ from primarysource.nist.models import Section
24
+
25
+ _CONTROL = re.compile(r"([A-Z]{2})-(\d+)(?:\((\d+)\))?\s{1,6}([A-Z][A-Z ,;:\-/'—]{3,})")
26
+ _CHILD = re.compile(
27
+ r"^(?:\s*|.*(?:doi\.org|NIST\.SP)\S*\s+)\((\d+)\)\s+([A-Z][A-Z ,;:\-/'—|&]{3,})"
28
+ )
29
+ _PAGE_HEADER = ("doi.org", "NIST.SP")
30
+
31
+
32
+ def _anchored(line: str, start: int) -> bool:
33
+ before = line[:start]
34
+ return (
35
+ before.strip() == ""
36
+ or before.endswith(" ")
37
+ or any(h in before for h in _PAGE_HEADER)
38
+ )
39
+
40
+
41
+ def _heading_at(line: str, parent: str | None) -> tuple[str, str, bool] | None:
42
+ """Return (control id, title, is_parent) when ``line`` opens a control, else None."""
43
+ for m in _CONTROL.finditer(line):
44
+ if _anchored(line, m.start()):
45
+ base = f"{m.group(1).lower()}-{m.group(2)}"
46
+ if m.group(3):
47
+ return f"{base}.{m.group(3)}", m.group(4).strip(), False
48
+ return base, m.group(4).strip(), True
49
+ child = _CHILD.match(line)
50
+ if child and parent:
51
+ return f"{parent}.{child.group(1)}", child.group(2).strip(), False
52
+ return None
53
+
54
+
55
+ def split_control_sections(text: str) -> tuple[Section, ...]:
56
+ """One Section per control or enhancement, in document order, each running to the next heading."""
57
+ lines = text.split("\n")
58
+ if lines and lines[-1] == "":
59
+ lines.pop()
60
+ found: list[tuple[int, str, str, bool]] = []
61
+ parent: str | None = None
62
+ for number, line in enumerate(lines, start=1):
63
+ hit = _heading_at(line, parent)
64
+ if hit is None:
65
+ continue
66
+ if hit[2]:
67
+ parent = hit[0]
68
+ found.append((number, *hit))
69
+ first = next((i for i, f in enumerate(found) if f[3] and f[1].endswith("-1")), 0)
70
+ heads: list[tuple[int, str, str]] = []
71
+ seen: set[str] = set()
72
+ for number, control_id, title, _ in found[first:]:
73
+ if control_id not in seen:
74
+ seen.add(control_id)
75
+ heads.append((number, control_id, title))
76
+ sections: list[Section] = []
77
+ for index, (start, control_id, title) in enumerate(heads):
78
+ end = heads[index + 1][0] - 1 if index + 1 < len(heads) else len(lines)
79
+ sections.append(
80
+ Section(id=control_id, heading=title, start_line=start, end_line=end)
81
+ )
82
+ return tuple(sections)
@@ -0,0 +1,218 @@
1
+ """Build data/nist-catalog.json: every FIPS and SP 800 / SP 1800 publication CSRC lists, marked in or out.
2
+
3
+ CSRC has no machine-readable feed, but its publications search is server-rendered and takes
4
+ ``ipp-lg=all``, so one request per series returns the whole listing (FIPS: 70 rows, SP: 841 rows).
5
+ Candidates (listing status Final, FIPS or SP 800 or SP 1800) then get one detail-page request each,
6
+ for the PDF link and any supersession notice. Requests are throttled to one per second with a generic
7
+ User-Agent. Nothing is downloaded except HTML pages; PDFs are fetched by ``acquire_nist``.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from primarysource.nist.paths import PACKAGE_DATA
13
+
14
+ import argparse
15
+ import html
16
+ import json
17
+ import re
18
+ import sys
19
+ import time
20
+ import urllib.error
21
+ import urllib.request
22
+ from collections.abc import Sequence
23
+ from pathlib import Path
24
+
25
+ BASE = "https://csrc.nist.gov"
26
+ LISTING = (
27
+ BASE + "/publications/search?sortBy-lg=Number%20ASC&viewMode-lg=brief&ipp-lg=all"
28
+ "&series-lg={series}&status-lg=Final&status-lg=Draft&status-lg=Withdrawn"
29
+ )
30
+ USER_AGENT = "nistlib-discover/0.1 (documentation catalogue)"
31
+ DELAY = 1.0
32
+ ROW = re.compile(r'<tr id="result-\d+">(.*?)</tr>', re.S)
33
+
34
+
35
+ def fetch_text(url: str) -> str | None:
36
+ req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
37
+ try:
38
+ with urllib.request.urlopen(req, timeout=120) as resp:
39
+ return resp.read().decode("utf-8", errors="replace")
40
+ except (urllib.error.URLError, TimeoutError) as e:
41
+ print(f" {url}: {e}", file=sys.stderr)
42
+ return None
43
+ finally:
44
+ time.sleep(DELAY)
45
+
46
+
47
+ def _clean(s: str) -> str:
48
+ return re.sub(r"\s+", " ", html.unescape(re.sub(r"<[^>]+>", " ", s))).strip()
49
+
50
+
51
+ def parse_listing(page: str) -> list[dict]:
52
+ rows = []
53
+ for row in ROW.findall(page):
54
+
55
+ def g(pattern: str, row: str = row) -> str:
56
+ m = re.search(pattern, row, re.S)
57
+ return _clean(m.group(1)) if m else ""
58
+
59
+ rows.append(
60
+ {
61
+ "series": g(r'id="pub-series-\d+">(.*?)</td>'),
62
+ "number": g(r'id="pub-number-\d+">(.*?)</td>'),
63
+ "title": g(r'id="pub-title-link-\d+">(.*?)</a>'),
64
+ "status": g(r'id="pub-status-\d+">(.*?)</td>'),
65
+ "date": g(r'id="pub-release-date-\d+">(.*?)</td>'),
66
+ "href": g(r'href="(/pubs/[^"]+)"'),
67
+ }
68
+ )
69
+ return rows
70
+
71
+
72
+ def parse_detail(page: str) -> dict:
73
+ text = _clean(
74
+ re.sub(r"<script.*?</script>|<style.*?</style>", "", page, flags=re.S)
75
+ )
76
+ pdf = re.search(r'href="([^"]+)"[^>]*id="pub-local-download-link"', page)
77
+ head = re.search(
78
+ r"Date Published:(.*?)(?:Planning Note|Author\(s\)|Abstract)", text
79
+ )
80
+ sup = re.search(
81
+ r"Superseded By:\s*(.*?)(?:Supersedes:|Planning Note|Author\(s\))", text
82
+ )
83
+ return {
84
+ "pdf_url": pdf.group(1) if pdf else "",
85
+ "date_published": _clean(head.group(1)) if head else "",
86
+ "superseded_by": _clean(sup.group(1)) if sup else "",
87
+ }
88
+
89
+
90
+ def parse_href(href: str) -> dict | None:
91
+ """Split /pubs/<series>/<number>/... into the parts a doc_id and a revision order are built from."""
92
+ parts = href.removeprefix("/pubs/").removesuffix("/final").split("/")
93
+ if parts[0] == "fips":
94
+ num, _, rev = parts[1].partition("-")
95
+ return {
96
+ "base": f"fips{num}",
97
+ "rev": int(rev) if rev.isdigit() else 0,
98
+ "doc_id": f"fips{parts[1]}",
99
+ }
100
+ if parts[0] != "sp" or parts[1] not in {"800", "1800"}:
101
+ return None
102
+ if parts[1] == "1800":
103
+ return {"base": f"sp1800-{parts[2]}", "rev": 0, "doc_id": f"sp1800-{parts[2]}"}
104
+ base = f"sp800-{parts[2]}"
105
+ rev_tok = ""
106
+ rev = 0
107
+ sup = False
108
+ for tok in parts[3:]:
109
+ if re.fullmatch(r"[a-z]", tok) or re.fullmatch(r"(pt|v)\d+", tok):
110
+ base += tok
111
+ elif m := re.fullmatch(r"(r|ver)(\d+)", tok):
112
+ rev, rev_tok = int(m.group(2)), tok
113
+ elif tok.isdigit():
114
+ rev, rev_tok = int(tok), f"-{tok}"
115
+ elif tok == "sup":
116
+ sup = True
117
+ elif re.fullmatch(r"upd\d+", tok):
118
+ pass
119
+ else:
120
+ return None
121
+ if sup:
122
+ base += "-add"
123
+ return {"base": base, "rev": rev, "doc_id": base + rev_tok}
124
+
125
+
126
+ def classify(entries: list[dict]) -> None:
127
+ """Set entry['in_scope'] and entry['reason'] for every entry, in place."""
128
+ best: dict[str, int] = {}
129
+ for e in entries:
130
+ p = e["parts"]
131
+ if e["candidate"] and p and not e["superseded_by"]:
132
+ best[p["base"]] = max(best.get(p["base"], 0), p["rev"])
133
+ for e in entries:
134
+ p = e["parts"]
135
+ if e["series"] not in {"FIPS", "SP 800", "SP 1800"}:
136
+ e["in_scope"], e["reason"] = False, f"outside scope: {e['series']} series"
137
+ elif e["status"] != "Final":
138
+ e["in_scope"], e["reason"] = False, f"status {e['status']}"
139
+ elif e["superseded_by"]:
140
+ e["in_scope"], e["reason"] = False, f"superseded by {e['superseded_by']}"
141
+ elif p and p["rev"] < best.get(p["base"], 0):
142
+ e["in_scope"], e["reason"] = (
143
+ False,
144
+ "older revision; a later final revision is listed",
145
+ )
146
+ elif not e["pdf_url"]:
147
+ e["in_scope"], e["reason"] = False, "no PDF link on CSRC page"
148
+ else:
149
+ e["in_scope"], e["reason"] = True, "final and current"
150
+
151
+
152
+ def series_group(row: dict) -> str:
153
+ if row["series"] == "FIPS":
154
+ return "FIPS"
155
+ return (
156
+ f"SP {row['number'].split('-')[0]}" if row["series"] == "SP" else row["series"]
157
+ )
158
+
159
+
160
+ def main(argv: Sequence[str] | None = None) -> int:
161
+ parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
162
+ parser.add_argument("--out", type=Path, default=PACKAGE_DATA / "nist-catalog.json")
163
+ args = parser.parse_args(argv)
164
+ rows: list[dict] = []
165
+ for series in ("FIPS", "SP"):
166
+ page = fetch_text(LISTING.format(series=series))
167
+ if page is None:
168
+ print(f"cannot list {series}", file=sys.stderr)
169
+ return 1
170
+ found = parse_listing(page)
171
+ print(f"{series}: {len(found)} rows", file=sys.stderr)
172
+ rows += found
173
+ entries = []
174
+ for row in rows:
175
+ group = series_group(row)
176
+ parts = parse_href(row["href"])
177
+ candidate = (
178
+ row["status"] == "Final"
179
+ and group in {"FIPS", "SP 800", "SP 1800"}
180
+ and parts is not None
181
+ )
182
+ entry = {
183
+ "series": group,
184
+ "number": row["number"],
185
+ "title": row["title"],
186
+ "date": row["date"],
187
+ "status": row["status"],
188
+ "detail_url": BASE + row["href"],
189
+ "pdf_url": "",
190
+ "superseded_by": "",
191
+ "doc_id": parts["doc_id"] if parts else "",
192
+ "candidate": candidate,
193
+ "parts": parts,
194
+ }
195
+ if candidate:
196
+ page = fetch_text(entry["detail_url"])
197
+ if page is None:
198
+ print(f"detail failed: {entry['detail_url']}", file=sys.stderr)
199
+ else:
200
+ d = parse_detail(page)
201
+ entry.update(pdf_url=d["pdf_url"], superseded_by=d["superseded_by"])
202
+ if d["date_published"]:
203
+ entry["date_published"] = d["date_published"]
204
+ entries.append(entry)
205
+ classify(entries)
206
+ for e in entries:
207
+ e.pop("candidate")
208
+ e.pop("parts")
209
+ args.out.parent.mkdir(parents=True, exist_ok=True)
210
+ doc = {"schema": 1, "source": LISTING.format(series="FIPS|SP"), "entries": entries}
211
+ args.out.write_text(json.dumps(doc, indent=2, ensure_ascii=False), encoding="utf-8")
212
+ kept = sum(1 for e in entries if e["in_scope"])
213
+ print(f"wrote {args.out}: {len(entries)} entries, {kept} in scope", file=sys.stderr)
214
+ return 0
215
+
216
+
217
+ if __name__ == "__main__":
218
+ raise SystemExit(main())
@@ -0,0 +1,48 @@
1
+ """Text extraction with ``pdftotext -layout``. The section splitter is primarysource.pdfcorpus.split."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import shutil
6
+ import subprocess
7
+ from pathlib import Path
8
+
9
+
10
+ MISSING_MESSAGE = (
11
+ "pdftotext was not found on PATH. Install poppler-utils (on MSYS2: pacman -S mingw-w64-x86_64-poppler) "
12
+ "and make sure pdftotext is on PATH."
13
+ )
14
+
15
+
16
+ class ExtractError(RuntimeError):
17
+ """Raised when text extraction cannot run or fails."""
18
+
19
+
20
+ def find_pdftotext() -> str:
21
+ """Return the path of pdftotext, or raise ExtractError saying how to get it."""
22
+ found = shutil.which("pdftotext")
23
+ if found is None:
24
+ raise ExtractError(MISSING_MESSAGE)
25
+ return found
26
+
27
+
28
+ def extract_text(pdf: Path, *, timeout: float = 300.0) -> str:
29
+ """Run ``pdftotext -layout`` on ``pdf`` and return the text (pages separated by form feeds)."""
30
+ exe = find_pdftotext()
31
+ try:
32
+ result = subprocess.run( # noqa: S603
33
+ [exe, "-layout", "-enc", "UTF-8", str(pdf), "-"],
34
+ capture_output=True,
35
+ timeout=timeout,
36
+ check=False,
37
+ )
38
+ except subprocess.TimeoutExpired as error:
39
+ raise ExtractError(f"pdftotext timed out after {timeout:.0f}s on {pdf}") from error
40
+ if result.returncode != 0:
41
+ detail = result.stderr.decode("utf-8", "replace").strip()
42
+ raise ExtractError(f"pdftotext failed on {pdf} (exit {result.returncode}): {detail}")
43
+ return result.stdout.decode("utf-8", "replace")
44
+
45
+
46
+ # The splitter is shared (primarysource.pdfcorpus.split); it is re-exported here for the importers that have
47
+ # always found it in this module.
48
+ from primarysource.pdfcorpus.split import split_sections as split_sections # noqa: E402