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.
- primarysource/__init__.py +5 -0
- primarysource/bbf/__init__.py +56 -0
- primarysource/bbf/_xml.py +63 -0
- primarysource/bbf/catalogue.json +777 -0
- primarysource/bbf/catalogue.py +137 -0
- primarysource/bbf/cli.py +79 -0
- primarysource/bbf/datamodel.py +281 -0
- primarysource/bbf/importer.py +151 -0
- primarysource/bbf/library.py +380 -0
- primarysource/bbf/server.py +72 -0
- primarysource/budget.py +63 -0
- primarysource/c2sp/__init__.py +53 -0
- primarysource/c2sp/_fetch.py +5 -0
- primarysource/c2sp/catalogue.json +231 -0
- primarysource/c2sp/catalogue.py +95 -0
- primarysource/c2sp/cli.py +62 -0
- primarysource/c2sp/importer.py +90 -0
- primarysource/c2sp/library.py +309 -0
- primarysource/c2sp/markdown.py +31 -0
- primarysource/c2sp/server.py +68 -0
- primarysource/cavp/__init__.py +42 -0
- primarysource/cavp/cli.py +84 -0
- primarysource/cavp/data/vectors.toml +47 -0
- primarysource/cavp/importer.py +156 -0
- primarysource/cavp/model.py +279 -0
- primarysource/cavp/parse.py +269 -0
- primarysource/cavp/paths.py +17 -0
- primarysource/cavp/server.py +67 -0
- primarysource/cavp/tools.py +169 -0
- primarysource/cli.py +107 -0
- primarysource/cursor.py +82 -0
- primarysource/cve/__init__.py +5 -0
- primarysource/cve/__main__.py +6 -0
- primarysource/cve/cli.py +144 -0
- primarysource/cve/collection.py +20 -0
- primarysource/cve/config.py +58 -0
- primarysource/cve/errors.py +16 -0
- primarysource/cve/kernel.py +232 -0
- primarysource/cve/mcp.py +139 -0
- primarysource/cve/parse.py +318 -0
- primarysource/cve/py.typed +0 -0
- primarysource/cve/service.py +760 -0
- primarysource/cve/store.py +214 -0
- primarysource/cve/sync.py +438 -0
- primarysource/cve/views.py +304 -0
- primarysource/debpolicy/__init__.py +16 -0
- primarysource/debpolicy/catalogue.py +263 -0
- primarysource/debpolicy/cli.py +12 -0
- primarysource/debpolicy/server.py +11 -0
- primarysource/draft/__init__.py +11 -0
- primarysource/draft/catalogue.py +117 -0
- primarysource/draft/cli.py +149 -0
- primarysource/draft/collection.py +21 -0
- primarysource/draft/datatracker.py +158 -0
- primarysource/draft/importer.py +163 -0
- primarysource/draft/licence.py +89 -0
- primarysource/draft/names.py +64 -0
- primarysource/draft/net.py +133 -0
- primarysource/draft/segment.py +192 -0
- primarysource/draft/server.py +91 -0
- primarysource/draft/status.py +146 -0
- primarysource/draft/store.py +199 -0
- primarysource/draft/tracked.json +104 -0
- primarysource/draft/views.py +430 -0
- primarysource/draft/xmlmeta.py +74 -0
- primarysource/ecma/__init__.py +52 -0
- primarysource/ecma/_pdf.py +143 -0
- primarysource/ecma/catalogue.json +22 -0
- primarysource/ecma/catalogue.py +100 -0
- primarysource/ecma/cli.py +67 -0
- primarysource/ecma/identify.py +25 -0
- primarysource/ecma/importer.py +108 -0
- primarysource/ecma/library.py +240 -0
- primarysource/ecma/server.py +67 -0
- primarysource/errors.py +29 -0
- primarysource/fetch.py +360 -0
- primarysource/fsutil.py +150 -0
- primarysource/gitdocs/__init__.py +15 -0
- primarysource/gitdocs/catalogue.py +116 -0
- primarysource/gitdocs/cli.py +12 -0
- primarysource/gitdocs/server.py +8 -0
- primarysource/home.py +65 -0
- primarysource/iana/__init__.py +32 -0
- primarysource/iana/catalogue.json +244 -0
- primarysource/iana/catalogue.py +69 -0
- primarysource/iana/cli.py +69 -0
- primarysource/iana/importer.py +157 -0
- primarysource/iana/model.py +325 -0
- primarysource/iana/reader.py +315 -0
- primarysource/iana/server.py +67 -0
- primarysource/iana/store.py +83 -0
- primarysource/iana/xmlsafe.py +47 -0
- primarysource/identify.py +186 -0
- primarysource/ieee/__init__.py +58 -0
- primarysource/ieee/_html.py +49 -0
- primarysource/ieee/catalogue.json +74 -0
- primarysource/ieee/catalogue.py +123 -0
- primarysource/ieee/cli.py +79 -0
- primarysource/ieee/identify.py +24 -0
- primarysource/ieee/ieee802.py +47 -0
- primarysource/ieee/importer.py +305 -0
- primarysource/ieee/posix.py +233 -0
- primarysource/ieee/server.py +79 -0
- primarysource/index.py +446 -0
- primarysource/iso/__init__.py +51 -0
- primarysource/iso/cli.py +98 -0
- primarysource/iso/designation.py +95 -0
- primarysource/iso/identify.py +100 -0
- primarysource/iso/importer.py +224 -0
- primarysource/iso/library.py +249 -0
- primarysource/iso/server.py +69 -0
- primarysource/itu/__init__.py +41 -0
- primarysource/itu/__main__.py +8 -0
- primarysource/itu/asn1mod.py +478 -0
- primarysource/itu/catalog.py +472 -0
- primarysource/itu/cli.py +445 -0
- primarysource/itu/collection.py +20 -0
- primarysource/itu/corpus.py +118 -0
- primarysource/itu/errata.py +16 -0
- primarysource/itu/formal.py +565 -0
- primarysource/itu/identify.py +26 -0
- primarysource/itu/index.py +490 -0
- primarysource/itu/mcp.py +474 -0
- primarysource/itu/py.typed +0 -0
- primarysource/itu/sources.py +600 -0
- primarysource/itu/text.py +609 -0
- primarysource/kconfig/__init__.py +16 -0
- primarysource/kconfig/catalogue.py +98 -0
- primarysource/kconfig/cli.py +12 -0
- primarysource/kconfig/server.py +8 -0
- primarysource/model.py +63 -0
- primarysource/nist/__init__.py +5 -0
- primarysource/nist/collection.py +46 -0
- primarysource/nist/data/acquired-nist.json +1657 -0
- primarysource/nist/data/nist-catalog.json +12090 -0
- primarysource/nist/data/sources.toml +1996 -0
- primarysource/nist/identify.py +41 -0
- primarysource/nist/ingest/__init__.py +1 -0
- primarysource/nist/ingest/acquire_nist.py +128 -0
- primarysource/nist/ingest/controls.py +82 -0
- primarysource/nist/ingest/discover_nist.py +218 -0
- primarysource/nist/ingest/extract.py +48 -0
- primarysource/nist/ingest/fetch.py +211 -0
- primarysource/nist/ingest/index.py +14 -0
- primarysource/nist/ingest/oscal.py +480 -0
- primarysource/nist/ingest/prose_layouts.py +236 -0
- primarysource/nist/ingest/sources.py +94 -0
- primarysource/nist/ingest/status.py +255 -0
- primarysource/nist/models.py +7 -0
- primarysource/nist/paths.py +33 -0
- primarysource/nist/server/__init__.py +3 -0
- primarysource/nist/server/corpus.py +38 -0
- primarysource/nist/server/mcp.py +19 -0
- primarysource/nist/server/tools.py +35 -0
- primarysource/nist/spec.py +38 -0
- primarysource/pdfcorpus/__init__.py +16 -0
- primarysource/pdfcorpus/corpus.py +159 -0
- primarysource/pdfcorpus/fts.py +175 -0
- primarysource/pdfcorpus/index.py +70 -0
- primarysource/pdfcorpus/models.py +30 -0
- primarysource/pdfcorpus/server.py +96 -0
- primarysource/pdfcorpus/spec.py +37 -0
- primarysource/pdfcorpus/split.py +391 -0
- primarysource/pdfcorpus/tools.py +244 -0
- primarysource/pep/__init__.py +15 -0
- primarysource/pep/catalogue.py +191 -0
- primarysource/pep/cli.py +12 -0
- primarysource/pep/server.py +8 -0
- primarysource/pkgdocs/__init__.py +19 -0
- primarysource/pkgdocs/fetch.py +356 -0
- primarysource/pkgdocs/index.py +89 -0
- primarysource/pkgdocs/model.py +76 -0
- primarysource/pkgdocs/readers/__init__.py +1 -0
- primarysource/pkgdocs/readers/_common.py +134 -0
- primarysource/pkgdocs/readers/asciidoc.py +160 -0
- primarysource/pkgdocs/readers/latex.py +315 -0
- primarysource/pkgdocs/readers/markdown.py +173 -0
- primarysource/pkgdocs/readers/pod.py +132 -0
- primarysource/pkgdocs/readers/rst.py +239 -0
- primarysource/pkgdocs/readers/scdoc.py +108 -0
- primarysource/pkgdocs/runtime.py +552 -0
- primarysource/pkgdocs/serving.py +158 -0
- primarysource/pkgdocs/store.py +167 -0
- primarysource/pms/__init__.py +16 -0
- primarysource/pms/catalogue.py +88 -0
- primarysource/pms/cli.py +12 -0
- primarysource/pms/server.py +8 -0
- primarysource/py.typed +0 -0
- primarysource/readers/__init__.py +10 -0
- primarysource/readers/bookmarks.py +192 -0
- primarysource/readers/html.py +1312 -0
- primarysource/readers/markdown.py +189 -0
- primarysource/readers/pdf.py +187 -0
- primarysource/readers/xml.py +230 -0
- primarysource/refs.py +87 -0
- primarysource/registry.py +161 -0
- primarysource/render.py +49 -0
- primarysource/rfc/__init__.py +3 -0
- primarysource/rfc/__main__.py +6 -0
- primarysource/rfc/cite.py +204 -0
- primarysource/rfc/cli.py +903 -0
- primarysource/rfc/collection.py +19 -0
- primarysource/rfc/config.py +138 -0
- primarysource/rfc/errata.py +203 -0
- primarysource/rfc/fsutil.py +82 -0
- primarysource/rfc/graph.py +213 -0
- primarysource/rfc/index/__init__.py +1 -0
- primarysource/rfc/index/chunker.py +281 -0
- primarysource/rfc/index/embed.py +166 -0
- primarysource/rfc/index/hybrid.py +305 -0
- primarysource/rfc/index/lexical.py +223 -0
- primarysource/rfc/index/rfcxml.py +673 -0
- primarysource/rfc/index/search.py +508 -0
- primarysource/rfc/index/txtseg.py +585 -0
- primarysource/rfc/library.py +294 -0
- primarysource/rfc/mcp/__init__.py +1 -0
- primarysource/rfc/mcp/server.py +381 -0
- primarysource/rfc/packs/__init__.py +1 -0
- primarysource/rfc/packs/builder.py +228 -0
- primarysource/rfc/packs/collections/all-current-txt.json +10 -0
- primarysource/rfc/packs/collections/essentials.json +19 -0
- primarysource/rfc/packs/collections/fyi-current.json +10 -0
- primarysource/rfc/packs/collections/standards-current.json +10 -0
- primarysource/rfc/packs/collections/year-2025.json +10 -0
- primarysource/rfc/packs/collections/year-2026.json +10 -0
- primarysource/rfc/packs/collections.py +163 -0
- primarysource/rfc/packs/docsetexport.py +163 -0
- primarysource/rfc/packs/epub.py +159 -0
- primarysource/rfc/packs/export.py +306 -0
- primarysource/rfc/packs/htmlexport.py +82 -0
- primarysource/rfc/packs/serve.py +143 -0
- primarysource/rfc/packs/zimexport.py +171 -0
- primarysource/rfc/query.py +466 -0
- primarysource/rfc/subjects.py +457 -0
- primarysource/rfc/sync/__init__.py +1 -0
- primarysource/rfc/sync/downloader.py +132 -0
- primarysource/rfc/sync/manifest.py +162 -0
- primarysource/rfc/sync/scanner.py +49 -0
- primarysource/rfc/sync/state.py +464 -0
- primarysource/rfc/views.py +1067 -0
- primarysource/rpmdocs/__init__.py +15 -0
- primarysource/rpmdocs/catalogue.py +117 -0
- primarysource/rpmdocs/cli.py +12 -0
- primarysource/rpmdocs/server.py +8 -0
- primarysource/scaffold.py +223 -0
- primarysource/serve.py +75 -0
- primarysource/settings.py +152 -0
- primarysource/usb/__init__.py +5 -0
- primarysource/usb/collection.py +37 -0
- primarysource/usb/data/acquired.json +2025 -0
- primarysource/usb/data/sources-usb.toml +852 -0
- primarysource/usb/data/sources.toml +10 -0
- primarysource/usb/data/usb-catalog.json +1663 -0
- primarysource/usb/data/usb-classification.json +1986 -0
- primarysource/usb/data/usb-corpus-build.json +608 -0
- primarysource/usb/data/usb-pdf-choice.json +839 -0
- primarysource/usb/data/usb-split-measure.json +1803 -0
- primarysource/usb/ingest/__init__.py +1 -0
- primarysource/usb/ingest/acquire.py +130 -0
- primarysource/usb/ingest/discover_usb.py +88 -0
- primarysource/usb/ingest/extract.py +48 -0
- primarysource/usb/ingest/fetch.py +197 -0
- primarysource/usb/ingest/index.py +14 -0
- primarysource/usb/ingest/sources.py +85 -0
- primarysource/usb/models.py +7 -0
- primarysource/usb/paths.py +33 -0
- primarysource/usb/server/__init__.py +3 -0
- primarysource/usb/server/corpus.py +38 -0
- primarysource/usb/server/mcp.py +19 -0
- primarysource/usb/server/tools.py +35 -0
- primarysource/usb/spec.py +20 -0
- primarysource/usbids/__init__.py +56 -0
- primarysource/usbids/cli.py +65 -0
- primarysource/usbids/importer.py +272 -0
- primarysource/usbids/parse.py +283 -0
- primarysource/usbids/server.py +55 -0
- primarysource/usbids/store.py +80 -0
- primarysource/usbids/tools.py +174 -0
- primarysource/zim/__init__.py +5 -0
- primarysource/zim/__main__.py +8 -0
- primarysource/zim/_libzim.py +37 -0
- primarysource/zim/catalog.py +594 -0
- primarysource/zim/cli.py +555 -0
- primarysource/zim/collection.py +41 -0
- primarysource/zim/errors.py +100 -0
- primarysource/zim/groups.py +30 -0
- primarysource/zim/library.py +668 -0
- primarysource/zim/manage.py +636 -0
- primarysource/zim/markdown.py +1247 -0
- primarysource/zim/mcp.py +201 -0
- primarysource/zim/purl.py +130 -0
- primarysource/zim/py.typed +0 -0
- primarysource/zim/refs.py +90 -0
- primarysource/zim/service.py +1182 -0
- primarysource/zim/snapshot.py +197 -0
- primarysource/zim/sync.py +269 -0
- primarysource/zim/views.py +230 -0
- primarysource-0.1.0.dist-info/METADATA +118 -0
- primarysource-0.1.0.dist-info/RECORD +303 -0
- primarysource-0.1.0.dist-info/WHEEL +4 -0
- primarysource-0.1.0.dist-info/entry_points.txt +2 -0
- primarysource-0.1.0.dist-info/licenses/LICENSE +203 -0
- 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,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,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)
|