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,585 @@
1
+ """Section/paragraph segmentation for RFCs that exist only as .txt.
2
+
3
+ The legacy chunker (chunker.py) guesses headings line by line and keeps a
4
+ "section numbers only go up" rule, so one false heading early on (a status
5
+ code like '400 (Bad Request)' at column 0) silently discards every real
6
+ section after it: RFC 7230 came out with 2 of 85 headings. This module
7
+ segments the document the way RFCXML documents are segmented instead:
8
+
9
+ 1. The document's own Table of Contents is the answer key. Most RFCs have
10
+ one; its entries (number + title) say exactly which headings to find.
11
+ 2. Headings are located in order after the ToC, as short lines at the left
12
+ margin whose tokens start with the entry's number and title words.
13
+ Numbered entries are anchored first; unnumbered ones (Security
14
+ Considerations, Authors' Addresses) are then searched only between their
15
+ anchored neighbours, so a stray word can't capture them.
16
+ 3. Headings deeper than the ToC lists (ToCs often stop at depth 2) are
17
+ accepted between anchors only when they are a structural successor of
18
+ the previous heading (first child or next sibling).
19
+ 4. Without a ToC, a left-margin scan accepts a candidate only when it is a
20
+ plausible successor of the last accepted heading, so a false '400' is
21
+ skipped instead of poisoning the rest of the document.
22
+
23
+ Every section is then split into paragraph blocks (blank-line separated,
24
+ re-joined across page breaks), classified as text / artwork / ABNF, and
25
+ the result is returned in rfcxml's ParsedXml shape: the `blocks` table,
26
+ paragraph refs (RFC791§3.1¶4), view=code/normative, grep labels and
27
+ chunking all work unchanged. Published RFC text never changes, so the
28
+ synthetic paragraph numbers are as stable as the RFC Editor's pn ids.
29
+
30
+ Quality is recorded per document (ToC entries matched, method), so weak
31
+ segmentations are visible instead of silent.
32
+ """
33
+
34
+ from __future__ import annotations
35
+
36
+ import re
37
+ from dataclasses import dataclass, field
38
+
39
+ from .chunker import _UNNUMBERED, _clean_title
40
+ from .rfcxml import _FURNITURE, Block, ParsedXml, XRef, XSection, _toks
41
+
42
+ VERSION = "2"
43
+
44
+ # Up to 8 levels: RFC 8878 numbers sections to depth 7 (3.1.1.3.2.1.1).
45
+ _NUM = r"\d{1,3}(?:\.\d{1,3}){0,7}"
46
+ _APPX = r"(?:Appendix|APPENDIX|Annex)\s+[A-Z](?:\.\d{1,3})*"
47
+ _LETTER = r"[A-Z](?:\.\d{1,3})+|[A-Z](?=\.)"
48
+ _LABEL = rf"(?P<lab>{_APPX}|{_LETTER}|{_NUM})"
49
+ _LEADER = r"(?:\s*\.(?:\s?\.)+\s*|\s{2,}|\s(?=\d+\s*$))"
50
+ _PAGE = r"(?P<page>\d{1,3}|[ivxlc]{1,7})" # 4 digits is a year
51
+ _TOC_NUM = re.compile(rf"^\s*{_LABEL}\.?[:]?\s+(?P<title>\S.*?)"
52
+ rf"(?:{_LEADER}{_PAGE})?\s*$")
53
+ _TOC_UNNUM = re.compile(rf"^\s*(?P<title>[A-Z][A-Za-z'’()/,&\- ]{{2,70}}?)"
54
+ rf"{_LEADER}{_PAGE}\s*$")
55
+ _TOC_HEAD = re.compile(r"^\s*(table\s+of\s+contents|contents)\s*:?\s*$", re.I)
56
+ _HEAD = re.compile(rf"^(?P<ind> {{0,4}}){_LABEL}\.?[:]?\s+(?P<title>\S.*)$")
57
+ _ABNF = re.compile(r"^\s*[A-Za-z][\w\-]*\s*=/?\s*\S")
58
+ _ART = re.compile(r"[+|]-{2,}|-{2,}[+|]|^\s*\|.*\|\s*$|\s{6,}\S.*\s{6,}\S")
59
+ _REF_LABEL = re.compile(r"^\s*\[([^\]]{1,40})\]")
60
+ _SERIES = re.compile(r"\b(RFC|BCP|STD|FYI)[\s\-]?(\d{1,5})\b")
61
+ _NORM = re.compile(r"\b(MUST NOT|MUST|SHALL NOT|SHALL|SHOULD NOT|SHOULD|"
62
+ r"NOT RECOMMENDED|RECOMMENDED|MAY|REQUIRED|OPTIONAL)\b")
63
+ _SEC_OF = re.compile(
64
+ r"[Ss]ections?\s+(?P<sec>\d{1,3}(?:\.\d{1,3})*|[A-Z](?:\.\d{1,3})+)"
65
+ r"\s+of\s+(?:\[(?P<lab>[^\]]{1,40})\]|(?P<ser>RFC|BCP|STD)\s*(?P<n>\d+))")
66
+ _LAB_SEC = re.compile(
67
+ r"\[(?P<lab>[^\]]{1,40})\],?\s+[Ss]ection\s+"
68
+ r"(?P<sec>\d{1,3}(?:\.\d{1,3})*|[A-Z](?:\.\d{1,3})+)")
69
+ _LAB = re.compile(r"\[(?P<lab>[^\]]{1,40})\]")
70
+ _SENT_END = re.compile(r"[.:;!?)\]>\"]\s*$")
71
+
72
+
73
+ @dataclass
74
+ class TocEntry:
75
+ sec_num: str # canonical, chunks vocabulary
76
+ title: str
77
+ line: int # 1-based ToC line
78
+
79
+
80
+ @dataclass
81
+ class Quality:
82
+ method: str # toc | scan
83
+ toc_entries: int = 0
84
+ matched: int = 0 # ToC entries located in the body
85
+ extra: int = 0 # headings found that the ToC did not list
86
+ headings: int = 0
87
+ notes: list[str] = field(default_factory=list)
88
+
89
+ @property
90
+ def score(self) -> float | None:
91
+ if self.method == "toc" and self.toc_entries:
92
+ return round(self.matched / self.toc_entries, 3)
93
+ return None
94
+
95
+
96
+ # --------------------------------------------------------------------------
97
+ # labels
98
+
99
+ def canon(label: str) -> str:
100
+ """ToC/heading label -> chunks sec_num: '3.3.3', 'Appendix A', 'A.1'."""
101
+ lab = label.strip().rstrip(".:")
102
+ m = re.fullmatch(r"(?:Appendix|APPENDIX|Annex)\s+([A-Z])((?:\.\d+)*)", lab)
103
+ if m:
104
+ return f"Appendix {m.group(1)}" if not m.group(2) else \
105
+ m.group(1) + m.group(2)
106
+ if re.fullmatch(r"[A-Z]", lab):
107
+ return f"Appendix {lab}"
108
+ return lab
109
+
110
+
111
+ def _depth(sec: str) -> int:
112
+ if sec.startswith("Appendix"):
113
+ return 1
114
+ return sec.count(".") + 1 if sec else 1
115
+
116
+
117
+ def _pn(sec: str, title: str, used: set[str]) -> str:
118
+ if sec.startswith("Appendix"):
119
+ base = "section-appendix." + sec.split()[1].lower()
120
+ elif sec and sec[0].isalpha():
121
+ base = "section-" + sec.lower()
122
+ elif sec:
123
+ base = "section-" + sec
124
+ else:
125
+ base = "section-u." + ("-".join(_toks(title)[:4]) or "x")
126
+ pn, k = base, 2
127
+ while pn in used:
128
+ pn, k = f"{base}.{k}", k + 1
129
+ used.add(pn)
130
+ return pn
131
+
132
+
133
+ def _furniture(ln: str) -> bool:
134
+ return any(p.search(ln) for p in _FURNITURE)
135
+
136
+
137
+ # --------------------------------------------------------------------------
138
+ # table of contents
139
+
140
+ def parse_toc(lines: list[str]) -> tuple[list[TocEntry], int, int]:
141
+ """(entries, first line, last line) of the ToC, or ([], 0, 0)."""
142
+ limit = min(len(lines), max(400, len(lines) // 5))
143
+ start = next((i for i in range(limit) if _TOC_HEAD.match(lines[i])), -1)
144
+ if start < 0:
145
+ return [], 0, 0
146
+ entries: list[TocEntry] = []
147
+ misses, last = 0, start
148
+ for i in range(start + 1, len(lines)):
149
+ ln = lines[i].replace("\f", "")
150
+ if not ln.strip() or _furniture(ln):
151
+ continue
152
+ m = _TOC_NUM.match(ln)
153
+ if m and m.group("page") is None and entries and \
154
+ not ln.startswith(" "):
155
+ break # page-less ToCs are indented; a margin heading = body
156
+ if m:
157
+ title = re.sub(r"[\s.]+$", "", m.group("title")).strip()
158
+ entries.append(TocEntry(canon(m.group("lab")), title, i + 1))
159
+ misses, last = 0, i
160
+ continue
161
+ wrapped = entries and _indent(ln) > _indent(lines[entries[-1].line - 1])
162
+ m = _TOC_UNNUM.match(ln)
163
+ if m and not wrapped:
164
+ entries.append(TocEntry("", m.group("title").strip(), i + 1))
165
+ misses, last = 0, i
166
+ continue
167
+ if wrapped and re.search(rf"{_LEADER}{_PAGE}\s*$", ln):
168
+ # second line of a wrapped title: "...Interaction with" /
169
+ # " Proxies ........ 12"
170
+ entries[-1].title += " " + re.sub(rf"{_LEADER}{_PAGE}\s*$", "",
171
+ ln).strip()
172
+ misses, last = 0, i
173
+ continue
174
+ misses += 1
175
+ if misses >= 3:
176
+ break
177
+ _unnumber_quoted(entries, lines)
178
+ return entries, start + 1, last + 1
179
+
180
+
181
+ def _unnumber_quoted(entries: list[TocEntry], lines: list[str]) -> None:
182
+ """Numeric entries indented under an "Appendix X" entry belong to a quoted document.
183
+
184
+ RFC 9592's Appendix A reprints the last edition of the Tao, whose own "1 Introduction ...
185
+ 2.2.1" numbering the ToC lists beneath the appendix; the RFCXML marks those sections
186
+ unnumbered. An RFC's own numbered sections never sit inside an appendix, so the label is
187
+ dropped and the entry is kept as an unnumbered one. Scoped by indentation: a numeric entry
188
+ back at the appendix's indent (or shallower) ends the quoted span.
189
+ """
190
+ app_ind: int | None = None
191
+ for e in entries:
192
+ ln = lines[e.line - 1].replace("\f", "")
193
+ ind = _indent(ln)
194
+ if re.match(r"(?:Appendix|APPENDIX|Annex)\s", ln.strip()):
195
+ app_ind = ind
196
+ continue
197
+ if app_ind is not None and ind <= app_ind:
198
+ app_ind = None
199
+ if app_ind is not None and re.fullmatch(r"\d+(?:\.\d+)*", e.sec_num):
200
+ e.sec_num = ""
201
+
202
+
203
+ # --------------------------------------------------------------------------
204
+ # headings
205
+
206
+ def _heading_tokens(sec: str, title: str) -> list[tuple[list[str],
207
+ list[str]]]:
208
+ """(label tokens, full title tokens) a body heading may start with."""
209
+ tt = _toks(title)
210
+ if sec.startswith("Appendix"):
211
+ letter = sec.split()[1].lower()
212
+ return [(["appendix", letter], tt), ([letter], tt)]
213
+ if sec[:1].isalpha(): # 'F.1' may be written 'Appendix F.1.'
214
+ return [(_toks(sec), tt), (["appendix"] + _toks(sec), tt)]
215
+ return [(_toks(sec), tt)]
216
+
217
+
218
+ def _indent(line: str) -> int:
219
+ return len(line) - len(line.lstrip(" "))
220
+
221
+
222
+ def _margin(line: str) -> bool:
223
+ """At the left margin, or centered (RFC 791-era top-level headings);
224
+ body prose sits at a 3-space indent and never qualifies."""
225
+ ind = _indent(line)
226
+ return ind <= 1 or (ind >= 10 and len(line.strip()) <= 60
227
+ and abs((80 - len(line.rstrip())) - ind) <= 12)
228
+
229
+
230
+ def _matches(line: str, wants, numbered: bool) -> bool:
231
+ """Left-margin line that starts with the label and the title's first
232
+ words (case/punctuation-insensitive; a wrapped title may stop early)."""
233
+ if not line.strip():
234
+ return False
235
+ if re.search(r"(\.\s?){4,}\s*\d+\s*$", line): # ToC leader line
236
+ return False
237
+ deep = _indent(line) > 4 and not _margin(line)
238
+ lt = _toks(line)
239
+ for lab, tt in wants:
240
+ if not lab: # unnumbered: whole title, at the margin
241
+ if tt and lt[:len(tt)] == tt and len(lt) <= len(tt) + 2 and \
242
+ _margin(line):
243
+ return True
244
+ continue
245
+ if lt[:len(lab)] != lab:
246
+ continue
247
+ rest = lt[len(lab):]
248
+ if deep: # indented by level (RFC 1122): the exact heading only
249
+ if tt and rest == tt and _indent(line) <= 16:
250
+ return True
251
+ continue
252
+ tt = tt[:4]
253
+ n = min(len(rest), len(tt))
254
+ if not tt:
255
+ return bool(numbered and lab)
256
+ if n and rest[:n] == tt[:n] and (n >= min(2, len(tt))
257
+ or n == len(rest)):
258
+ if numbered or len(lt) <= len(lab) + len(tt) + 6:
259
+ return True
260
+ return False
261
+
262
+
263
+ def _successor(prev: str, cand: str) -> bool:
264
+ """cand can follow prev in an outline: a first child, the next sibling
265
+ of prev or of an ancestor (small gaps tolerated), or the first
266
+ appendix."""
267
+ if not prev:
268
+ return bool(re.fullmatch(r"[12](?:\.1)?", cand))
269
+ if cand.startswith("Appendix") or (cand[:1].isalpha()):
270
+ return True
271
+ if prev.startswith("Appendix") or prev[:1].isalpha():
272
+ return False
273
+ p = [int(x) for x in prev.split(".")]
274
+ c = [int(x) for x in cand.split(".")]
275
+ if len(c) == len(p) + 1 and c[:-1] == p and 1 <= c[-1] <= 2:
276
+ return True
277
+ for k in range(1, len(p) + 1): # next sibling at level k
278
+ if len(c) in (k, k + 1) and c[:k - 1] == p[:k - 1] and \
279
+ p[k - 1] < c[k - 1] <= p[k - 1] + 3:
280
+ return len(c) == k or c[k] == 1
281
+ return False
282
+
283
+
284
+ def _followed_by_body(lines: list[str], i: int, label: str = "") -> bool:
285
+ """True when the heading at ``i`` is followed by body text, or directly by its own first child.
286
+
287
+ A heading followed straight by its child ("3.5.1" then "3.5.1.1") used to read as a run of numbers, so it
288
+ was rejected and, with it, every descendant, since none of them was a successor any more
289
+ (docs/validation.md, rfc: 41 such headings and 194 descendants, e.g. rfc9288 line 338)."""
290
+ for j in range(i + 1, min(len(lines), i + 12)):
291
+ s = lines[j].strip()
292
+ if not s or _furniture(lines[j]):
293
+ continue
294
+ m = _HEAD.match(lines[j])
295
+ if m and label and canon(m.group("lab")).startswith(label + "."):
296
+ return True
297
+ return not m or lines[j].startswith(" ")
298
+ return False
299
+
300
+
301
+ def _scan_candidates(lines: list[str], lo: int, hi: int,
302
+ indent: int = 0) -> list[tuple[int, str, str]]:
303
+ out = []
304
+ for i in range(lo, min(hi, len(lines))):
305
+ ln = lines[i]
306
+ m = _HEAD.match(ln)
307
+ if not m or len(m.group("ind")) > indent:
308
+ continue
309
+ if re.search(r"(\.\s?){4,}\s*\d+\s*$", ln):
310
+ continue
311
+ # Both callers keep only structural successors of the previous heading
312
+ # (and, here, headings followed by body text), which is what makes the
313
+ # wider title rules below safe on this path and nowhere else.
314
+ lab = canon(m.group("lab"))
315
+ title = _clean_title(m.group("title"), allow_lowercase=True)
316
+ if title is None:
317
+ title = _guarded_title(m.group("title"), lab)
318
+ if not title:
319
+ continue
320
+ out.append((i + 1, lab, title))
321
+ return out
322
+
323
+
324
+ # A relaxed title that ends on one of these is a wrapped line of prose.
325
+ _CONNECTIVE = frozenset(
326
+ "and or but the a an of to in for with is are by as on that which where".split())
327
+
328
+
329
+ def _guarded_title(raw: str, lab: str) -> str | None:
330
+ """Titles `_clean_title` refuses that are real when the number is structurally sound.
331
+
332
+ - An XML element or similar led by '<' (RFC 9022 4.6.2.1 "<rdeCsv:csv> Element").
333
+ - A comma title under a multi-level number (RFC 9788 C.1.2.1). Single-level numbers are
334
+ excluded: in old RFCs without a ToC they are numbered prose lists ("2. Any mapping of
335
+ COTS onto X.25 (usually X.25(80)), where X.25 is" in RFC 1277), which would otherwise take
336
+ a real heading's place in the successor chain.
337
+ A trailing connective means the line wrapped mid-sentence, so it is prose either way.
338
+ """
339
+ t = re.split(r"\s{2,}", raw.strip())[0].strip()
340
+ if not t or t.endswith(",") or re.search(r"\.\d{1,3}$", t):
341
+ return None
342
+ words = t.split()
343
+ if words[-1].lower() in _CONNECTIVE:
344
+ return None
345
+ if len(words) > 16 or (len(words) > 1 and t.endswith(".")):
346
+ return None
347
+ if t.startswith("<"):
348
+ return t
349
+ multi = "." in lab and not lab.startswith("Appendix")
350
+ if "," in t and multi:
351
+ return t
352
+ return None
353
+
354
+
355
+ def find_headings(lines: list[str]) -> tuple[list[tuple[int, str, str]],
356
+ Quality, tuple[int, int]]:
357
+ """[(line, sec_num, title)] in document order, quality, ToC span."""
358
+ toc, t0, t1 = parse_toc(lines)
359
+ heads: list[tuple[int, str, str]] = []
360
+ numbered = [e for e in toc if e.sec_num]
361
+ q = Quality("toc" if len(numbered) >= 3 else "scan",
362
+ toc_entries=len(toc) if len(numbered) >= 3 else 0)
363
+ body0 = t1 if t1 else 0
364
+
365
+ if q.method == "toc":
366
+ at = body0
367
+ anchor: dict[int, int] = {} # toc index -> line
368
+ for k, e in enumerate(toc):
369
+ if not e.sec_num:
370
+ continue
371
+ wants = _heading_tokens(e.sec_num, e.title)
372
+ for i in range(at, len(lines)):
373
+ if _matches(lines[i], wants, True):
374
+ anchor[k] = i + 1
375
+ at = i + 1
376
+ break
377
+ for k, e in enumerate(toc): # unnumbered, between anchors
378
+ if e.sec_num:
379
+ continue
380
+ lo = max([anchor[j] for j in anchor if j < k], default=body0)
381
+ hi = min([anchor[j] for j in anchor if j > k],
382
+ default=len(lines) + 1)
383
+ wants = [([], _toks(e.title))]
384
+ for i in range(lo, hi - 1):
385
+ if _matches(lines[i], wants, False):
386
+ anchor[k] = i + 1
387
+ break
388
+ q.matched = len(anchor)
389
+ heads = sorted((anchor[k], toc[k].sec_num, toc[k].title)
390
+ for k in anchor)
391
+ # deeper headings the ToC omitted: structural successors only
392
+ extra = []
393
+ bounds = [h[0] for h in heads] + [len(lines) + 1]
394
+ for idx, (ln, sec, _t) in enumerate(heads):
395
+ if not sec or sec.startswith("Appendix"):
396
+ continue
397
+ prev = sec
398
+ for cl, cs, ct in _scan_candidates(lines, ln, bounds[idx + 1] - 1):
399
+ if cs.startswith(sec + ".") and _successor(prev, cs) and \
400
+ _followed_by_body(lines, cl - 1, cs):
401
+ extra.append((cl, cs, ct))
402
+ prev = cs
403
+ q.extra = len(extra)
404
+ heads = sorted(heads + extra)
405
+ else:
406
+ prev = ""
407
+ for cl, cs, ct in _scan_candidates(lines, body0, len(lines)):
408
+ if _successor(prev, cs) and _followed_by_body(lines, cl - 1, cs):
409
+ heads.append((cl, cs, ct))
410
+ prev = cs
411
+ q.notes.append("no usable ToC; left-margin successor scan")
412
+
413
+ # unnumbered boilerplate / back matter at the left margin: before the
414
+ # ToC (Abstract, Status of This Memo) and after the last numbered one
415
+ known = {u.lower() for u in _UNNUMBERED} | {
416
+ "status of this memo", "acknowledgements", "authors' addresses",
417
+ "author's address", "intellectual property", "full copyright statement",
418
+ "index"}
419
+ have = {h[0] for h in heads}
420
+ last_num = max((h[0] for h in heads if h[1]), default=body0)
421
+ for i, ln in enumerate(lines):
422
+ if i + 1 in have or not ln or ln[0] == " ":
423
+ continue
424
+ s = ln.strip().rstrip(".:")
425
+ if s.lower() not in known or s.lower() == "table of contents":
426
+ continue
427
+ if (t0 and i + 1 < t0) or i + 1 > last_num:
428
+ heads.append((i + 1, "", s))
429
+ heads.sort()
430
+ q.headings = len(heads)
431
+ return heads, q, (t0, t1)
432
+
433
+
434
+ # --------------------------------------------------------------------------
435
+ # paragraphs
436
+
437
+ def _kind(chunk: list[str]) -> tuple[str, str]:
438
+ body = [ln for ln in chunk if ln.strip()]
439
+ if not body:
440
+ return "t", ""
441
+ abnf = sum(1 for ln in body if _ABNF.match(ln))
442
+ if abnf and abnf >= max(1, len(body) // 3) and any(
443
+ re.search(r"=\s*\S.*(/|\*|%x|\"|\()", ln)
444
+ or re.match(r"\s*;", ln) for ln in body):
445
+ return "sourcecode", "abnf"
446
+ art = sum(1 for ln in body if _ART.search(ln))
447
+ alpha = sum(sum(ch.isalpha() for ch in ln) for ln in body)
448
+ chars = sum(len(ln.strip()) for ln in body) or 1
449
+ if (len(body) >= 2 and art >= max(1, len(body) // 3)) or \
450
+ (len(body) >= 3 and alpha / chars < 0.45):
451
+ return "artwork", ""
452
+ return "t", ""
453
+
454
+
455
+ def paragraphs(lines: list[str], a: int, b: int) -> list[tuple[int, int]]:
456
+ """(start, end) line spans of blank-separated paragraphs in [a, b];
457
+ a paragraph broken by a page break (furniture + blanks, previous line
458
+ not ending a sentence) is re-joined."""
459
+ spans: list[list[int]] = []
460
+ cur: list[int] | None = None
461
+ gap_furn = False
462
+ for n in range(a, b + 1):
463
+ ln = lines[n - 1].replace("\f", "")
464
+ if _furniture(ln) or lines[n - 1].startswith("\f"):
465
+ gap_furn = True
466
+ if cur:
467
+ spans.append(cur)
468
+ cur = None
469
+ continue
470
+ if not ln.strip():
471
+ if cur:
472
+ spans.append(cur)
473
+ cur = None
474
+ continue
475
+ if cur is None:
476
+ if gap_furn and spans and not _SENT_END.search(
477
+ lines[spans[-1][1] - 1]) and ln.startswith(" "):
478
+ cur = spans.pop() # continue the paragraph split by a page
479
+ else:
480
+ cur = [n, n]
481
+ gap_furn = False
482
+ cur[1] = n
483
+ if cur:
484
+ spans.append(cur)
485
+ return [(s, e) for s, e in spans]
486
+
487
+
488
+ def _text(lines: list[str], s: int, e: int, kind: str) -> str:
489
+ rows = [lines[n - 1].replace("\f", "").rstrip() for n in range(s, e + 1)
490
+ if not _furniture(lines[n - 1])]
491
+ rows = [r for r in rows if r.strip()]
492
+ if kind in ("artwork", "sourcecode"):
493
+ ind = min((len(r) - len(r.lstrip()) for r in rows), default=0)
494
+ return "\n".join(r[ind:] for r in rows)
495
+ return re.sub(r"\s+", " ", " ".join(r.strip() for r in rows)).strip()
496
+
497
+
498
+ # --------------------------------------------------------------------------
499
+ # document
500
+
501
+ def segment(txt: str, doc_id: str) -> tuple[ParsedXml, Quality]:
502
+ lines = txt.lstrip("").splitlines()
503
+ heads, q, (t0, t1) = find_headings(lines)
504
+ if not heads: # early RFCs without any structure: one body section
505
+ heads = [(1, "", "Entire document")]
506
+ q.notes.append("no headings found; single body section")
507
+ used: set[str] = set()
508
+ sections: list[XSection] = []
509
+ for k, (ln, sec, title) in enumerate(heads):
510
+ nxt = heads[k + 1][0] if k + 1 < len(heads) else len(lines) + 1
511
+ end = nxt - 1
512
+ if t0 and ln < t0 <= end: # the ToC belongs to no section
513
+ end = t0 - 2
514
+ # a heading may wrap onto the next line (indented continuation)
515
+ body0 = ln + 1
516
+ if body0 <= end and lines[body0 - 1].startswith(" ") and \
517
+ not lines[body0 - 1].strip().endswith(".") and \
518
+ len(lines[body0 - 1].strip()) < 60 and \
519
+ lines[ln - 1].rstrip()[-1:].isalpha() and \
520
+ body0 + 1 <= end and not lines[body0].strip():
521
+ title = f"{title} {lines[body0 - 1].strip()}"
522
+ body0 += 1
523
+ x = XSection(_pn(sec, title, used), "", sec, title, _depth(sec),
524
+ head_line=ln, end_line=max(ln, end))
525
+ for s, e in paragraphs(lines, body0, x.end_line):
526
+ kind, lang = _kind([lines[n - 1] for n in range(s, e + 1)])
527
+ text = _text(lines, s, e, kind)
528
+ if not text:
529
+ continue
530
+ x.blocks.append(Block(f"{x.pn}-{len(x.blocks) + 1}", kind, text,
531
+ sorted(set(_NORM.findall(text)),
532
+ key=text.find)
533
+ if kind == "t" else [], lang, s, e,
534
+ exact=True))
535
+ sections.append(x)
536
+ px = ParsedXml(doc_id, sections, [], {})
537
+ px.xrefs = _xrefs(px)
538
+ return px, q
539
+
540
+
541
+ def _xrefs(px: ParsedXml) -> list[XRef]:
542
+ """Citations with their source section, from reference lists + body
543
+ phrases ('Section 4.2 of [RFC1234]', '[X], Section 3', '[X]')."""
544
+ refs: dict[str, tuple[str, bool]] = {}
545
+ for sec in px.sections:
546
+ if "reference" not in sec.title.lower():
547
+ continue
548
+ norm = "normative" in sec.title.lower()
549
+ for b in sec.blocks:
550
+ m = _REF_LABEL.match(b.text)
551
+ if not m:
552
+ continue
553
+ lab = m.group(1).strip()
554
+ s = _SERIES.search(lab.replace(" ", "")) or _SERIES.search(b.text)
555
+ if s:
556
+ refs[lab] = (f"{s.group(1)}{int(s.group(2))}", norm)
557
+ b.kind = "reference"
558
+ out: list[XRef] = []
559
+ for sec in px.sections:
560
+ if "reference" in sec.title.lower():
561
+ continue
562
+ for b in sec.blocks:
563
+ if b.kind != "t":
564
+ continue
565
+ seen = set()
566
+ for m in _SEC_OF.finditer(b.text):
567
+ dst = (refs.get(m.group("lab"), ("", False))[0]
568
+ if m.group("lab") else
569
+ f"{m.group('ser')}{int(m.group('n'))}")
570
+ if dst:
571
+ seen.add((dst, m.group("sec")))
572
+ for m in _LAB_SEC.finditer(b.text):
573
+ dst = refs.get(m.group("lab"), ("", False))[0]
574
+ if dst:
575
+ seen.add((dst, m.group("sec")))
576
+ for m in _LAB.finditer(b.text):
577
+ dst = refs.get(m.group("lab"), ("", False))[0]
578
+ if dst and not any(d == dst for d, _s in seen):
579
+ seen.add((dst, ""))
580
+ for dst, ds in seen:
581
+ lab = next((k for k, v in refs.items() if v[0] == dst), dst)
582
+ out.append(XRef(b.pn, sec.sec_num, dst, ds, lab,
583
+ refs.get(lab, ("", False))[1]))
584
+ px.refs = refs
585
+ return out