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,236 @@
1
+ """Section splitting for NIST prose documents whose headings the generic splitter does not recognise.
2
+
3
+ ``extract.split_sections`` reads headings that start a line. Several families of NIST PDF do not satisfy that:
4
+
5
+ ``numbered`` ``1 Introduction`` / ``1. Introduction`` chapters. ``pdftotext`` often glues the chapter heading
6
+ after the page's running header (``This publication is available free of charge from: <doi> 1
7
+ Introduction``), which is why the generic splitter sees no ``1``. Sub-headings (``1.1 Purpose``)
8
+ are indented but otherwise ordinary.
9
+ ``numbered_zero`` ``1.0 Introduction`` chapters (id ``1``), sub-headings ``3.1 ...``.
10
+ ``section_colon`` ``SECTION 1: INTRODUCTION`` chapters, ``1.1 Background`` sub-headings.
11
+ ``chapter_word`` ``CHAPTER ONE`` chapters whose title is on the next line, all-caps ``1.1 TITLE`` sub-headings.
12
+
13
+ Every layout also reads appendices: ``Appendix A—Title``, ``Appendix A: Title``, ``APPENDIX A: TITLE``, a bare
14
+ ``APPENDIX A`` with the title on the next line, and ``Appendix A Title`` when it opens a page.
15
+
16
+ The acceptance rule is the generic one: a heading counts only when it is the next child of a parent already seen,
17
+ so cross-references, footnotes and repeated page headers cannot become sections. Documents with no numbered
18
+ headings at all (the annual reports, and the newer-format publications with unnumbered headings) are not split:
19
+ ``detect_layout`` returns None for them, because any section id would have to be invented.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import re
25
+
26
+ from primarysource.nist.models import Section
27
+
28
+ LAYOUTS: tuple[str, ...] = (
29
+ "numbered",
30
+ "numbered_zero",
31
+ "section_colon",
32
+ "chapter_word",
33
+ )
34
+
35
+ # pdftotext puts the running header and the first line of the page on one line.
36
+ _RUNNING_HEADER = re.compile(
37
+ r"^\f?\s*This publication is available free of charge from:\s*\S+\s*(?P<rest>.*)$",
38
+ re.IGNORECASE,
39
+ )
40
+ _LEADER = re.compile(r"\.{4,}|(?:\. ){4,}")
41
+ _MAX_HEADING = 120
42
+
43
+ _CHAPTER = re.compile(r"^(?P<id>\d+)\.?\s+(?P<heading>[A-Z].*)$")
44
+ _CHAPTER_ZERO = re.compile(r"^(?P<id>\d+)\.0\s+(?P<heading>[A-Z].*)$")
45
+ _SECTION_COLON = re.compile(r"^SECTION\s+(?P<id>\d+)\s*[:.]\s*(?P<heading>\S.*)$")
46
+ _CHAPTER_WORD = re.compile(
47
+ r"^CHAPTER\s+(?P<word>ONE|TWO|THREE|FOUR|FIVE|SIX|SEVEN|EIGHT|NINE|TEN|ELEVEN|TWELVE)\b\s*(?P<rest>.*)$"
48
+ )
49
+ _WORDS = "ONE TWO THREE FOUR FIVE SIX SEVEN EIGHT NINE TEN ELEVEN TWELVE".split()
50
+ _SUB = re.compile(r"^(?P<id>(?:\d+|[A-Z])(?:\.\d+)+)\.?\s+(?P<heading>[A-Z0-9].*)$")
51
+ _APPENDIX_SEP = re.compile(
52
+ r"^(?:Appendix|APPENDIX)\s+(?P<id>[A-Z])\s*[—–:.\-]+\s*(?P<heading>\S.*)$"
53
+ )
54
+ _APPENDIX_OPEN = re.compile(
55
+ r"^(?:Appendix|APPENDIX)\s+(?P<id>[A-Z])\s+(?P<heading>[A-Z].*)$"
56
+ )
57
+ _APPENDIX_BARE = re.compile(r"^APPENDIX\s+(?P<id>[A-Z])$")
58
+
59
+ # A hit is (id, heading, number of extra lines consumed after the heading line).
60
+ _Hit = tuple[str, str, int]
61
+
62
+
63
+ def _clean_heading(heading: str) -> str | None:
64
+ heading = heading.strip()
65
+ if not heading or _LEADER.search(heading) or len(heading) > _MAX_HEADING:
66
+ return None
67
+ if heading.endswith(".") or heading.count("(") != heading.count(")"):
68
+ return None # table-of-contents row or running prose
69
+ if re.search(r"\s{3,}PAGE\s+\d+$", heading):
70
+ return None # running page header, not a heading
71
+ return heading
72
+
73
+
74
+ def _next_text(lines: list[str], index: int) -> str | None:
75
+ """The next non-blank line after ``index``, stripped, or None when it is not close by."""
76
+ for look in range(index + 1, min(index + 4, len(lines))):
77
+ stripped = lines[look].strip()
78
+ if stripped:
79
+ return stripped
80
+ return None
81
+
82
+
83
+ def _appendix_hit(
84
+ text: str, opens_page: bool, lines: list[str], index: int
85
+ ) -> _Hit | None:
86
+ m = _APPENDIX_SEP.match(text)
87
+ if m is None and opens_page:
88
+ m = _APPENDIX_OPEN.match(text)
89
+ if m is not None:
90
+ heading = _clean_heading(m.group("heading"))
91
+ return (m.group("id"), heading, 0) if heading else None
92
+ m = _APPENDIX_BARE.match(text)
93
+ if m is not None:
94
+ following = _next_text(lines, index)
95
+ heading = _clean_heading(following) if following else None
96
+ return (m.group("id"), heading, 1) if heading else None
97
+ return None
98
+
99
+
100
+ def _top_level(layout: str, text: str, lines: list[str], index: int) -> _Hit | None:
101
+ if layout == "chapter_word":
102
+ m = _CHAPTER_WORD.match(text)
103
+ if m is None:
104
+ return None
105
+ number = str(_WORDS.index(m.group("word")) + 1)
106
+ rest = m.group("rest").strip()
107
+ if rest and not rest.startswith("PAGE"):
108
+ heading = _clean_heading(rest)
109
+ return (number, heading, 0) if heading else None
110
+ if rest:
111
+ return None # "CHAPTER TWO PAGE 6" running header
112
+ following = _next_text(lines, index)
113
+ heading = _clean_heading(following) if following else None
114
+ return (number, heading, 1) if heading else None
115
+ pattern = {
116
+ "numbered": _CHAPTER,
117
+ "numbered_zero": _CHAPTER_ZERO,
118
+ "section_colon": _SECTION_COLON,
119
+ }[layout]
120
+ m = pattern.match(text)
121
+ if m is None:
122
+ return None
123
+ heading = _clean_heading(m.group("heading"))
124
+ if heading is None:
125
+ return None
126
+ if layout == "numbered" and " " in heading:
127
+ return None # a table row: columns are separated by runs of spaces
128
+ return m.group("id"), heading, 0
129
+
130
+
131
+ def _match(
132
+ layout: str, line: str, lines: list[str], index: int
133
+ ) -> tuple[_Hit, bool] | None:
134
+ """Match one line: the hit, and whether the line opens a page (heading glued after the running header)."""
135
+ opens_page = False
136
+ header = _RUNNING_HEADER.match(line)
137
+ if header is not None:
138
+ opens_page = True
139
+ line = header.group("rest")
140
+ text = line.strip().lstrip("\f").strip()
141
+ if not text:
142
+ return None
143
+ hit = _appendix_hit(text, opens_page, lines, index)
144
+ if hit is None:
145
+ hit = _top_level(layout, text, lines, index)
146
+ if hit is not None:
147
+ return hit, opens_page
148
+ m = _SUB.match(text)
149
+ if m is None:
150
+ return None
151
+ heading = _clean_heading(m.group("heading"))
152
+ return ((m.group("id"), heading, 0), opens_page) if heading else None
153
+
154
+
155
+ def _ordinal(part: str) -> int:
156
+ return int(part) if part.isdigit() else ord(part) - ord("A") + 1
157
+
158
+
159
+ def _parent_key(sec_id: str) -> str:
160
+ parent, _, _ = sec_id.rpartition(".")
161
+ if parent:
162
+ return parent
163
+ return "#appendix" if sec_id.isalpha() else "#chapter"
164
+
165
+
166
+ def _is_next(sec_id: str, last_child: dict[str, int]) -> bool:
167
+ key = _parent_key(sec_id)
168
+ if key not in last_child and not key.startswith("#"):
169
+ return False
170
+ return _ordinal(sec_id.rpartition(".")[2]) == last_child.get(key, 0) + 1
171
+
172
+
173
+ def split_for_layout(text: str, layout: str) -> tuple[Section, ...]:
174
+ """Split ``text`` into sections using the heading conventions of ``layout`` (one of ``LAYOUTS``).
175
+
176
+ Lines are numbered from 1 and split on newline only. Sections do not nest: each runs from its heading line
177
+ to the line before the next heading, the last to the end of the text.
178
+ """
179
+ if layout not in LAYOUTS:
180
+ raise ValueError(
181
+ f"unknown layout {layout!r}; expected one of {', '.join(LAYOUTS)}"
182
+ )
183
+ lines = text.split("\n")
184
+ if lines and lines[-1] == "":
185
+ lines.pop()
186
+ matches = [
187
+ (index, m)
188
+ for index, line in enumerate(lines)
189
+ if (m := _match(layout, line, lines, index))
190
+ ]
191
+ # A bare chapter number ("3 Administrators should consult ...") also starts a footnote. Where a chapter
192
+ # number has a candidate at the top of a page, behind the running header, that one is the heading and the
193
+ # others are dropped; otherwise a footnote met first would take the chapter's place in the sibling order.
194
+ page_openers = {
195
+ hit[0]
196
+ for _, (hit, opens) in matches
197
+ if opens and layout == "numbered" and hit[0].isdigit()
198
+ }
199
+ found: list[tuple[str, str, int]] = []
200
+ last_child: dict[str, int] = {}
201
+ for index, (hit, opens_page) in matches:
202
+ sec_id, heading, _ = hit
203
+ if layout == "numbered" and sec_id in page_openers and not opens_page:
204
+ continue
205
+ if not _is_next(sec_id, last_child):
206
+ continue
207
+ key = _parent_key(sec_id)
208
+ last_child[key] = last_child.get(key, 0) + 1
209
+ last_child.setdefault(sec_id, 0)
210
+ found.append((sec_id, heading, index + 1))
211
+ sections: list[Section] = []
212
+ for position, (sec_id, heading, start) in enumerate(found):
213
+ end = found[position + 1][2] - 1 if position + 1 < len(found) else len(lines)
214
+ sections.append(
215
+ Section(id=sec_id, heading=heading, start_line=start, end_line=end)
216
+ )
217
+ return tuple(sections)
218
+
219
+
220
+ MIN_SECTIONS = 3
221
+
222
+
223
+ def detect_layout(text: str) -> str | None:
224
+ """Name the layout that finds the most sections in ``text``, or None when none finds ``MIN_SECTIONS``.
225
+
226
+ The layouts are mutually exclusive in practice: each one's chapters are the only parents the others'
227
+ sub-headings would accept, so a document written one way yields nothing under the others. A document with
228
+ no numbered headings (an annual report, an unnumbered newer-format publication) yields None.
229
+ """
230
+ best: str | None = None
231
+ best_count = MIN_SECTIONS - 1
232
+ for layout in LAYOUTS:
233
+ count = len(split_for_layout(text, layout))
234
+ if count > best_count:
235
+ best, best_count = layout, count
236
+ return best
@@ -0,0 +1,94 @@
1
+ """Parser for data/sources.toml, the list of publications ``nistlib-fetch`` may download."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ import tomllib
7
+ from dataclasses import dataclass
8
+ from pathlib import Path
9
+
10
+ _DOC_ID = re.compile(r"^[a-z0-9][a-z0-9._-]*$")
11
+ _DATE = re.compile(r"^\d{4}-(0[1-9]|1[0-2])$")
12
+ _SHA256 = re.compile(r"^[0-9a-f]{64}$")
13
+ _REQUIRED = ("doc_id", "url", "number", "title", "date", "status")
14
+ _KNOWN = (*_REQUIRED, "sha256", "splitter")
15
+ _SPLITTERS = ("headings", "controls")
16
+
17
+
18
+ class SourcesError(ValueError):
19
+ """Raised when sources.toml is missing, malformed, or inconsistent."""
20
+
21
+
22
+ @dataclass(frozen=True, slots=True)
23
+ class Source:
24
+ """One entry of sources.toml. ``sha256`` is empty until recorded."""
25
+
26
+ doc_id: str
27
+ url: str
28
+ number: str
29
+ title: str
30
+ date: str
31
+ status: str
32
+ sha256: str = ""
33
+ splitter: str = (
34
+ "headings" # "headings" for prose, "controls" for control catalogues
35
+ )
36
+
37
+
38
+ def parse_sources(text: str) -> tuple[Source, ...]:
39
+ """Parse the text of sources.toml: an array of ``[[source]]`` tables."""
40
+ try:
41
+ data = tomllib.loads(text)
42
+ except tomllib.TOMLDecodeError as error:
43
+ raise SourcesError(f"sources.toml is not valid TOML: {error}") from error
44
+ entries = data.get("source")
45
+ if not isinstance(entries, list) or not entries:
46
+ raise SourcesError("sources.toml has no [[source]] entries")
47
+ sources: list[Source] = []
48
+ seen: set[str] = set()
49
+ for position, entry in enumerate(entries, start=1):
50
+ label = f"source #{position}"
51
+ if not isinstance(entry, dict):
52
+ raise SourcesError(f"{label}: not a table")
53
+ missing = [
54
+ key
55
+ for key in _REQUIRED
56
+ if not isinstance(entry.get(key), str) or not entry[key].strip()
57
+ ]
58
+ if missing:
59
+ raise SourcesError(
60
+ f"{label}: missing or empty field(s): {', '.join(missing)}"
61
+ )
62
+ unknown = sorted(set(entry) - set(_KNOWN))
63
+ if unknown:
64
+ raise SourcesError(f"{label}: unknown field(s): {', '.join(unknown)}")
65
+ doc_id = entry["doc_id"]
66
+ label = f"source {doc_id!r}"
67
+ if not _DOC_ID.match(doc_id):
68
+ raise SourcesError(f"{label}: doc_id must be a lowercase file-safe slug")
69
+ if doc_id in seen:
70
+ raise SourcesError(f"{label}: duplicate doc_id")
71
+ seen.add(doc_id)
72
+ if not entry["url"].startswith("https://"):
73
+ raise SourcesError(f"{label}: url must be https")
74
+ if not _DATE.match(entry["date"]):
75
+ raise SourcesError(f"{label}: date must be YYYY-MM, got {entry['date']!r}")
76
+ digest = entry.get("sha256", "")
77
+ if digest and not _SHA256.match(digest):
78
+ raise SourcesError(f"{label}: sha256 must be 64 lowercase hex digits")
79
+ splitter = entry.get("splitter", "headings")
80
+ if splitter not in _SPLITTERS:
81
+ raise SourcesError(
82
+ f"{label}: splitter must be one of {_SPLITTERS}, got {splitter!r}"
83
+ )
84
+ sources.append(Source(**{key: entry[key] for key in _KNOWN if key in entry}))
85
+ return tuple(sources)
86
+
87
+
88
+ def load_sources(path: Path) -> tuple[Source, ...]:
89
+ """Read and parse ``path``."""
90
+ try:
91
+ text = path.read_text(encoding="utf-8")
92
+ except OSError as error:
93
+ raise SourcesError(f"cannot read {path}: {error}") from error
94
+ return parse_sources(text)
@@ -0,0 +1,255 @@
1
+ """Publication status of each corpus document, read from the bulk data the library already holds.
2
+
3
+ Three sources, each checked on its own:
4
+
5
+ * the CSRC listing (``data/nist-catalog.json``, written by ``discover_nist``): the status NIST gives each
6
+ publication (Final, Draft, Withdrawn, ...), and whether it is current;
7
+ * the OSCAL catalogue's own revision history (``metadata/revisions``): the versions NIST released and when;
8
+ * the front matter of the PDF text: a draft carries "Draft" or "Initial Public Draft" on its first pages.
9
+
10
+ A document's default status is the one NIST's listing gives its publication. The OSCAL history supplies the
11
+ newest release of that publication, so a family can be resolved to its current final revision without a
12
+ hand-written table.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from primarysource.nist.paths import PACKAGE_DATA, corpus_dir
18
+
19
+ import json
20
+ import re
21
+ import sys
22
+ import xml.etree.ElementTree as ET
23
+ from collections.abc import Iterable
24
+ from dataclasses import dataclass
25
+ from pathlib import Path
26
+
27
+ OSCAL_NS = "{http://csrc.nist.gov/ns/oscal/1.0}"
28
+ _DOC_ID = re.compile(r"^sp(?P<series>\d+)-(?P<number>\d+)(?P<letter>(?:(?!r\d)[a-z])?)(?:r(?P<rev>\d+))?")
29
+ _DRAFT = re.compile(r"\b(initial public draft|draft)\b", re.I)
30
+ _FRONT_MATTER_CHARS = 4000
31
+ # The main document of a publication, not a baseline profile of it ("sp800-53r5-high-oscal").
32
+ _MAIN_CATALOGUE = re.compile(r"^sp\d+-\d+[a-z]?(r\d+)?(-oscal)?$")
33
+ DEFAULTS_FILE = "defaults.json"
34
+ DEFAULTS_SCHEMA = 1
35
+
36
+
37
+ @dataclass(frozen=True)
38
+ class Release:
39
+ version: str
40
+ last_modified: str # YYYY-MM-DD
41
+
42
+
43
+ @dataclass(frozen=True)
44
+ class DocumentStatus:
45
+ doc_id: str
46
+ csrc_number: str | None # e.g. "800-53 Rev. 5", None when the listing has no such publication
47
+ csrc_status: str | None # "Final", "Draft", "Withdrawn", ... as NIST lists it
48
+ csrc_current: bool # the listing marks it "final and current"
49
+ latest_release: Release | None # newest OSCAL release, when the document is an OSCAL catalogue
50
+ pdf_marked_draft: bool | None # None when there is no PDF text to read
51
+ oscal_status: str | None # the catalogue's own publication-status prop, lowercased; None when absent
52
+ framework: str | None = None # the catalogue's framework-identifier prop, lowercased ("csf")
53
+ variant: str | None = None # a baseline profile's name ("low"), None for the main catalogue
54
+
55
+
56
+ _VARIANT = re.compile(r"^sp\d+-\d+[a-z]?(?:r\d+)?-([a-z]+)-oscal$")
57
+
58
+
59
+ def csrc_number_for(doc_id: str) -> str | None:
60
+ """The CSRC number a corpus id stands for: ``sp800-53r5-oscal`` -> ``800-53 Rev. 5``.
61
+
62
+ An id without a revision keeps no "Rev." part: ``sp800-218`` -> ``800-218``, ``sp800-218a`` -> ``800-218A``.
63
+ """
64
+ m = _DOC_ID.match(doc_id)
65
+ if m is None:
66
+ return None
67
+ base = f"{m.group('series')}-{m.group('number')}{(m.group('letter') or '').upper()}"
68
+ rev = m.group("rev")
69
+ return base if rev is None else f"{base} Rev. {rev}"
70
+
71
+
72
+ def publication_key(csrc_number: str) -> str:
73
+ """The publication a CSRC number belongs to, without its revision: ``800-53 Rev. 5`` -> ``800-53``."""
74
+ return csrc_number.split(" Rev.")[0]
75
+
76
+
77
+ def load_csrc(path: Path) -> dict[str, list[dict]]:
78
+ """Rows of ``nist-catalog.json`` grouped by their CSRC number."""
79
+ rows: dict[str, list[dict]] = {}
80
+ for row in json.loads(path.read_text(encoding="utf-8"))["entries"]:
81
+ rows.setdefault(row["number"], []).append(row)
82
+ return rows
83
+
84
+
85
+ def csrc_state(rows: list[dict] | None) -> tuple[str | None, bool]:
86
+ """The status NIST gives a publication, and whether it is current.
87
+
88
+ Several rows can share a number (a publication and its earlier drafts); the one marked final is preferred.
89
+ """
90
+ if not rows:
91
+ return None, False
92
+ final = [r for r in rows if r["status"] == "Final"]
93
+ chosen = final[0] if final else rows[0]
94
+ return chosen["status"], chosen["reason"] == "final and current"
95
+
96
+
97
+ def oscal_releases(root: ET.Element) -> list[Release]:
98
+ """Every release the catalogue's revision history lists, oldest first."""
99
+ revisions = root.find(f"{OSCAL_NS}metadata/{OSCAL_NS}revisions")
100
+ if revisions is None:
101
+ return []
102
+ out: list[Release] = []
103
+ for rev in revisions.findall(f"{OSCAL_NS}revision"):
104
+ version = (rev.findtext(f"{OSCAL_NS}version") or "").strip()
105
+ modified = (rev.findtext(f"{OSCAL_NS}last-modified") or "").strip()[:10]
106
+ if version and modified:
107
+ out.append(Release(version, modified))
108
+ return sorted(out, key=lambda r: r.last_modified)
109
+
110
+
111
+ def oscal_prop(root: ET.Element, name: str) -> str | None:
112
+ """The value of the first prop the catalogue states under ``name``, lowercased."""
113
+ for prop in root.iter(f"{OSCAL_NS}prop"):
114
+ if prop.get("name") == name and prop.get("value"):
115
+ return prop.get("value", "").strip().lower()
116
+ return None
117
+
118
+
119
+ def oscal_publication_status(root: ET.Element) -> str | None:
120
+ """The ``publication-status`` prop the catalogue states for itself, lowercased."""
121
+ return oscal_prop(root, "publication-status")
122
+
123
+
124
+ def latest_release(root: ET.Element) -> Release | None:
125
+ releases = oscal_releases(root)
126
+ return releases[-1] if releases else None
127
+
128
+
129
+ def pdf_marked_draft(text: str) -> bool:
130
+ """True when the front matter of a PDF's text says it is a draft."""
131
+ return _DRAFT.search(text[:_FRONT_MATTER_CHARS]) is not None
132
+
133
+
134
+ def document_status(
135
+ doc_id: str,
136
+ csrc_rows: dict[str, list[dict]],
137
+ *,
138
+ oscal_path: Path | None = None,
139
+ pdf_text: str | None = None,
140
+ ) -> DocumentStatus:
141
+ number = csrc_number_for(doc_id)
142
+ status, current = csrc_state(csrc_rows.get(number) if number else None)
143
+ release = None
144
+ oscal_status = None
145
+ framework = None
146
+ if oscal_path is not None:
147
+ root = ET.parse(oscal_path).getroot()
148
+ release = latest_release(root)
149
+ oscal_status = oscal_publication_status(root)
150
+ framework = oscal_prop(root, "framework-identifier")
151
+ variant = _VARIANT.match(doc_id)
152
+ return DocumentStatus(
153
+ doc_id=doc_id,
154
+ csrc_number=number,
155
+ csrc_status=status,
156
+ csrc_current=current,
157
+ latest_release=release,
158
+ pdf_marked_draft=None if pdf_text is None else pdf_marked_draft(pdf_text),
159
+ oscal_status=oscal_status,
160
+ framework=framework,
161
+ variant=variant.group(1) if variant else None,
162
+ )
163
+
164
+
165
+ def compute_defaults(statuses: Iterable[DocumentStatus]) -> dict[str, str]:
166
+ """Publication -> the doc_id of its default document, for the server to resolve a bare publication number.
167
+
168
+ A document with no CSRC number (CSF 2.0, a CSWP) is its own default when its catalogue states it final.
169
+ """
170
+ statuses = list(statuses)
171
+ mapping = {publication_key(s.csrc_number): s.doc_id for s in default_documents(statuses)}
172
+ # A baseline profile is named by its publication and its level: "800-53 low".
173
+ for s in statuses:
174
+ if s.csrc_number and s.csrc_current and s.variant:
175
+ mapping[f"{publication_key(s.csrc_number)} {s.variant}"] = s.doc_id
176
+ # A document with no CSRC number (CSF 2.0, a CSWP) is named by its framework, and is its own default.
177
+ for s in statuses:
178
+ if s.csrc_number is None and s.oscal_status == "final":
179
+ mapping.setdefault((s.framework or s.doc_id).lower(), s.doc_id)
180
+ return dict(sorted(mapping.items()))
181
+
182
+
183
+ def write_defaults(root: Path, mapping: dict[str, str]) -> Path:
184
+ path = root / DEFAULTS_FILE
185
+ path.write_text(
186
+ json.dumps({"schema": DEFAULTS_SCHEMA, "defaults": mapping}, indent=2) + "\n",
187
+ encoding="utf-8",
188
+ )
189
+ return path
190
+
191
+
192
+ def read_defaults(root: Path) -> dict[str, str]:
193
+ """The defaults map, or an empty map when none has been written yet."""
194
+ path = root / DEFAULTS_FILE
195
+ if not path.is_file():
196
+ return {}
197
+ raw = json.loads(path.read_text(encoding="utf-8"))
198
+ if raw.get("schema") != DEFAULTS_SCHEMA:
199
+ return {}
200
+ return dict(raw["defaults"])
201
+
202
+
203
+ def statuses_for_corpus(root: Path, catalog: Path) -> list[DocumentStatus]:
204
+ """Status of every document in ``root/index.json``, from the CSRC listing, OSCAL and the PDF text."""
205
+ rows = load_csrc(catalog)
206
+ raw = json.loads((root / "index.json").read_text(encoding="utf-8"))
207
+ out: list[DocumentStatus] = []
208
+ for doc in raw["documents"]:
209
+ doc_id = doc["doc_id"]
210
+ text = root / doc["text_file"]
211
+ oscal = root / f"{doc_id}.xml"
212
+ out.append(
213
+ document_status(
214
+ doc_id,
215
+ rows,
216
+ oscal_path=oscal if oscal.is_file() else None,
217
+ pdf_text=text.read_text(encoding="utf-8") if text.is_file() else None,
218
+ )
219
+ )
220
+ return out
221
+
222
+
223
+ def main(argv: list[str] | None = None) -> int:
224
+ """Write ``defaults.json`` for a corpus directory: ``python -m primarysource.nist.ingest.status [corpus] [catalog]``."""
225
+ args = list(sys.argv[1:] if argv is None else argv)
226
+ root = Path(args[0]) if args else corpus_dir()
227
+ catalog = Path(args[1]) if len(args) > 1 else PACKAGE_DATA / "nist-catalog.json"
228
+ statuses = statuses_for_corpus(root, catalog)
229
+ mapping = compute_defaults(statuses)
230
+ write_defaults(root, mapping)
231
+ for publication, doc_id in mapping.items():
232
+ print(f"{publication}: {doc_id}")
233
+ return 0
234
+
235
+
236
+ def default_documents(statuses: Iterable[DocumentStatus]) -> list[DocumentStatus]:
237
+ """The documents that are the current final revision of their publication.
238
+
239
+ A publication qualifies when NIST's listing marks it final and current. Among the corpus documents of one
240
+ publication, the default is the newest by OSCAL release date, or the one without a release when none has one.
241
+ """
242
+ by_number: dict[str, list[DocumentStatus]] = {}
243
+ for s in statuses:
244
+ if s.csrc_number and s.csrc_current and _MAIN_CATALOGUE.match(s.doc_id):
245
+ by_number.setdefault(s.csrc_number, []).append(s)
246
+ defaults: list[DocumentStatus] = []
247
+ for group in by_number.values():
248
+ def newest(s: DocumentStatus) -> str:
249
+ return s.latest_release.last_modified if s.latest_release else ""
250
+ defaults.append(max(group, key=newest))
251
+ return sorted(defaults, key=lambda s: s.doc_id)
252
+
253
+
254
+ if __name__ == "__main__":
255
+ raise SystemExit(main())
@@ -0,0 +1,7 @@
1
+ """The nist collection's document model: the shared one (primarysource.pdfcorpus.models)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from primarysource.pdfcorpus.models import Document, Section
6
+
7
+ __all__ = ["Document", "Section"]
@@ -0,0 +1,33 @@
1
+ """Where the nist collection keeps its files (primarysource PLAN.md §5.8).
2
+
3
+ The data directory holds `sources.toml` overrides and the fetched `corpus/`: `$NISTLIB_DATA` if set (the old
4
+ setting), else `primarysource.home.collection_home("nist")`, which is `$PRIMARYSOURCE_HOME/nist` or the per-user
5
+ `primarysource/nist`. Never the working directory and never beside the installed package. The tracked metadata
6
+ (`sources.toml`, catalogues, digests) ships in the package under `data/`.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import os
12
+ from pathlib import Path
13
+
14
+ from primarysource.home import collection_home
15
+
16
+ PACKAGE_DATA = Path(__file__).resolve().parent / "data"
17
+
18
+
19
+ def data_dir() -> Path:
20
+ env = os.environ.get("NISTLIB_DATA")
21
+ return Path(env) if env else collection_home("nist", legacy_app="nistlib")
22
+
23
+
24
+ def corpus_dir() -> Path:
25
+ """`$NISTLIB_CORPUS` if set, else `corpus/` in the data directory."""
26
+ env = os.environ.get("NISTLIB_CORPUS")
27
+ return Path(env) if env else data_dir() / "corpus"
28
+
29
+
30
+ def sources_file(data: Path, name: str = "sources.toml") -> Path:
31
+ """The data directory's own `sources.toml` if it has one, else the one shipped in the package."""
32
+ local = data / name
33
+ return local if local.is_file() else PACKAGE_DATA / name
@@ -0,0 +1,3 @@
1
+ """Read-only MCP server over the locally fetched NIST corpus. Makes no network calls."""
2
+
3
+ from __future__ import annotations
@@ -0,0 +1,38 @@
1
+ """Load the nist corpus: the shared loader (primarysource.pdfcorpus.corpus) with the nist spec."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from primarysource.nist.spec import SPEC
8
+ from primarysource.pdfcorpus import corpus as _shared
9
+ from primarysource.pdfcorpus.corpus import SCHEMA_VERSION, CorpusError
10
+
11
+ ENV_VAR = SPEC.env_var
12
+ FETCH_COMMAND = SPEC.fetch_command
13
+ __all__ = ["ENV_VAR", "FETCH_COMMAND", "SCHEMA_VERSION", "Corpus", "CorpusError", "default_corpus_dir", "load_corpus"]
14
+
15
+
16
+ class Corpus(_shared.Corpus):
17
+ """A nist corpus: the shared one with this collection's hooks as defaults."""
18
+
19
+ def __init__(self, root: Path, documents: tuple[_shared.Document, ...], defaults: dict[str, str] | None = None) -> None:
20
+ super().__init__(
21
+ root,
22
+ documents,
23
+ defaults or {},
24
+ SPEC.alias,
25
+ fetch_command=SPEC.fetch_command,
26
+ index_command=SPEC.index_command,
27
+ prefix=f"{SPEC.name}_",
28
+ )
29
+
30
+
31
+ def default_corpus_dir() -> Path:
32
+ """``$NISTLIB_CORPUS`` if set, else ``corpus/`` in the collection's data directory."""
33
+ return SPEC.corpus_dir()
34
+
35
+
36
+ def load_corpus(root: Path | None = None) -> _shared.Corpus:
37
+ """Read and validate the index; raise ``CorpusError`` whose ``action`` is the fetch command."""
38
+ return _shared.load_corpus(SPEC, root)