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,609 @@
1
+ """PDF to clauses. The unit of citation in an ITU recommendation is the subclause, not the page.
2
+
3
+ Extraction is lossy in ways that matter to a citation: ITU's PDFs emit "Rec. ITU -T X.680" with a stray space,
4
+ hyphenate "set -of" and "ISO/IEC 8824 -1". `tidy` repairs the three artefacts actually observed in 02/2021, and the
5
+ repairs are named here rather than hidden, because a quote that has been silently normalised is a quote that
6
+ cannot be checked against the paper. Anything else the extractor mangles stays broken and visible.
7
+
8
+ Clause boundaries are recognised as a numbered heading: `8.1.2.4 For tags with a number greater than or
9
+ equal to 31 ...`. Table-of-contents lines carry dot leaders and are discarded rather than indexed, since an
10
+ index whose top hit for "BOOLEAN" is a contents entry answers no question.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import re
16
+ from dataclasses import dataclass
17
+
18
+ # pypdf (the [itu] extra) is imported where a PDF is opened, not here: the collection and its server import
19
+ # without it (PLAN.md §5.13), and an index already built needs no PDF reader.
20
+
21
+ # Numeric (8.1.2.4) or annex (A.1, C.3.2) numbering. Annex letters stop at I: past that they collide with
22
+ # recommendation names ("G.711", "H.323") far more often than they head a clause.
23
+ # The extractor sometimes drops the space after the number ("22.11.2.1If ..."); a capitalised word is accepted then.
24
+ CLAUSE_NUMBER = re.compile(r"(?<![\w.])((?:\d{1,2}|[A-I])(?:\.\d{1,2}){1,5})(?:\s|(?=[A-Z][a-z]))")
25
+ # A row of the document-history table ("1.1 ITU-T X.690 (1994) Cor. 1 ...") looks like clause 1.1.
26
+ HISTORY_ROW = re.compile(r"ITU-T\s+[A-Z]\.\d+\s*\(?\d{4}")
27
+ # "ISO/IEC 8824 -1": the extractor puts a space before the part number of a joint designation.
28
+ ISO_PART = re.compile(r"(\d{4}) -(\d)")
29
+ LEADER = re.compile(r"\.{5,}")
30
+ SPACE = re.compile(r"\s+")
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class Clause:
35
+ number: str
36
+ title: str
37
+ body: str
38
+ heading: bool = False # title is the document's own heading, not the first sentence
39
+
40
+ def cite(self, edition_key: str) -> str:
41
+ return f"Rec. ITU-T {edition_key} §{self.number}"
42
+
43
+
44
+ def tidy(text: str) -> str:
45
+ """Repair the three extraction artefacts observed in 02/2021. Lossy, and said so on purpose."""
46
+ text = text.replace("ITU -T", "ITU-T").replace("set -of", "set-of")
47
+ return SPACE.sub(" ", ISO_PART.sub(r"\1-\2", text)).strip()
48
+
49
+
50
+ def pages(path: object) -> list[str]:
51
+ from pypdf import PdfReader
52
+
53
+ reader = PdfReader(str(path))
54
+ return strip_running_lines([page.extract_text() or "" for page in reader.pages])
55
+
56
+
57
+ def strip_running_lines(page_texts: list[str]) -> list[str]:
58
+ """Drop running page headers and footers ("ISO/IEC 8825-1:2021 (E) 14", "Rec. ITU-T X.690 (02/2021)").
59
+
60
+ They land in the middle of a clause when a clause crosses a page, which corrupts quoting and makes the same
61
+ sentence differ between editions. A line is running text when, with its digits masked, it contains a digit
62
+ (a page or edition number) and repeats on at least a quarter of the pages. Requiring the digit keeps a
63
+ legitimately repeated word such as a table caption.
64
+ """
65
+ if len(page_texts) < 4:
66
+ return page_texts
67
+
68
+ def masked(line: str) -> str:
69
+ return re.sub(r"\d+", "#", line.strip())
70
+
71
+ seen: dict[str, int] = {}
72
+ for page in page_texts:
73
+ for key in {masked(line) for line in page.splitlines() if line.strip()}:
74
+ seen[key] = seen.get(key, 0) + 1
75
+ threshold = max(3, len(page_texts) // 4)
76
+ # A clause heading masks to the same string every time it repeats ("10.1 General", "18.1 General" ->
77
+ # "#.# General"), so lines that start with a clause number are never running text.
78
+ numbered = re.compile(r"^(?:#|[A-I])(?:\.#)+\s")
79
+ running = {
80
+ key
81
+ for key, count in seen.items()
82
+ if count >= threshold and "#" in key and len(key) <= 90 and not numbered.match(key)
83
+ }
84
+ return ["\n".join(line for line in page.splitlines() if masked(line) not in running) for page in page_texts]
85
+
86
+
87
+ _TITLE_END = re.compile(r"(?<!Rec)(?<!e\.g)(?<!i\.e)(?<!Fig)(?<!No)(?<!cf)(?<!Cor)(?<!Amd)\. ")
88
+
89
+
90
+ def first_sentence(chunk: str) -> str:
91
+ """First sentence of the chunk, capped. A heading and its first rule are not separable in extracted
92
+ text — `11.6 Set-of components The encodings of…` runs straight on — so the title is offered as a label
93
+ and `body` stays authoritative."""
94
+ cut = _TITLE_END.search(chunk)
95
+ head = chunk if cut is None else chunk[: cut.start()]
96
+ return head[:96].rstrip()
97
+
98
+
99
+ _REFERENCE_WORDS = frozenset(
100
+ [
101
+ "clause",
102
+ "clauses",
103
+ "subclause",
104
+ "subclauses",
105
+ "sub-clause",
106
+ "see",
107
+ "in",
108
+ "of",
109
+ "and",
110
+ "or",
111
+ "to",
112
+ "with",
113
+ "by",
114
+ "from",
115
+ "per",
116
+ "under",
117
+ "table",
118
+ "figure",
119
+ "annex",
120
+ "than",
121
+ "also",
122
+ "both",
123
+ "either",
124
+ "between",
125
+ "through",
126
+ ]
127
+ )
128
+ _PRECEDING_WORD = re.compile(r"([A-Za-z-]+|§|[,(])\s*$")
129
+
130
+
131
+ def _is_reference(flat: str, start: int) -> bool:
132
+ """True when a dotted number is a pointer ("see 8.1.2") rather than the start of a heading.
133
+
134
+ A heading follows a sentence end or the previous clause's text; a pointer follows a word that introduces
135
+ one. Treating the pointer as a heading split clauses mid-sentence and produced duplicate numbers, so that
136
+ `read` could return a fragment of a cross-reference instead of the clause. The cost of the opposite error
137
+ (a real heading right after one of these words) is a clause merged into its neighbour, which is visible in
138
+ the outline rather than silently wrong.
139
+ """
140
+ match = _PRECEDING_WORD.search(flat[max(start - 20, 0) : start])
141
+ if not match:
142
+ return False
143
+ word = match.group(1)
144
+ if len(word) > 1 and word.isupper():
145
+ return False # an acronym ("PER", "BER"), not the word "per" shouted
146
+ return word in {"§", ",", "("} or word.lower() in _REFERENCE_WORDS
147
+
148
+
149
+ def _plausible_heading(flat: str, start: int, number: str) -> bool:
150
+ """Annex numbers (A.1) are only headings at a sentence boundary: elsewhere they are far more likely to be
151
+ a reference ("Part 2, D.2)"), a recommendation name, or an enumerator."""
152
+ if number[0].isdigit():
153
+ return True
154
+ before = flat[:start].rstrip()
155
+ return not before or before[-1] in ".:;)"
156
+
157
+
158
+ _HEADING_TITLE = re.compile(r"^((?:\d{1,2}|[A-I])(?:\.\d{1,2})*)\s+(\S.*)$")
159
+ _ANNEX_TITLE = re.compile(r"^Annex\s+([A-I])\b\s*[-\u2013\u2014:.]*\s*(.*)$")
160
+ _TOC_LINE = re.compile(r"^\s*((?:\d{1,2}|[A-I])(?:\.\d{1,2})*)\s+(.*?)[\s.]*\.{3,}[\s.\d]*$")
161
+
162
+
163
+ def headings(path: object, page_text: list[str] | None = None) -> list[tuple[str, str]]:
164
+ """The recommendation's own list of numbered headings, in document order: (number, title).
165
+
166
+ The PDF bookmarks are the source: ITU's PDFs carry one per heading down to three or four levels, with the
167
+ exact title. The contents pages are the fallback when a PDF has none. Deeper numbered paragraphs
168
+ (8.1.2.4.2) are in neither, and are found by `clauses` from their position instead.
169
+ """
170
+ found: list[tuple[str, str]] = []
171
+
172
+ def walk(items: list[object]) -> None:
173
+ for item in items:
174
+ if isinstance(item, list):
175
+ walk(item)
176
+ continue
177
+ title = SPACE.sub(" ", str(getattr(item, "title", ""))).strip()
178
+ match = _HEADING_TITLE.match(title)
179
+ annex = _ANNEX_TITLE.match(title)
180
+ if match:
181
+ found.append((match.group(1), tidy(match.group(2))))
182
+ elif annex:
183
+ found.append((annex.group(1), tidy(annex.group(2)) or f"Annex {annex.group(1)}"))
184
+
185
+ from pypdf import PdfReader
186
+ from pypdf.errors import PdfReadError
187
+
188
+ try:
189
+ walk(list(PdfReader(str(path)).outline))
190
+ except (PdfReadError, ValueError, OSError):
191
+ found.clear() # not a readable PDF: fall through to the contents lines
192
+ if found:
193
+ return found
194
+ for line in "\n".join(page_text if page_text is not None else pages(path)).splitlines():
195
+ toc = _TOC_LINE.match(line)
196
+ if toc:
197
+ found.append((toc.group(1), tidy(toc.group(2))))
198
+ return found
199
+
200
+
201
+ def _key(number: str) -> tuple[int | str, ...]:
202
+ return tuple(int(part) if part.isdigit() else part for part in number.split("."))
203
+
204
+
205
+ def _is_successor(last: tuple[int | str, ...], candidate: tuple[int | str, ...]) -> bool:
206
+ """Whether `candidate` can directly follow `last` in a numbered document: its first child (8.1 -> 8.1.1), its
207
+ next sibling (8.1.2 -> 8.1.3), or the next sibling of an ancestor (8.1.2.4 -> 8.1.3). Anything else in the
208
+ middle of a clause is a pointer, an enumerator or a stray number, which is exactly what the unconstrained
209
+ splitter was mistaking for headings."""
210
+ if candidate[:-1] == last and candidate[-1] == 1:
211
+ return True
212
+ for depth in range(1, len(last) + 1):
213
+ parent, tail = last[: depth - 1], last[depth - 1]
214
+ if isinstance(tail, int) and candidate == (*parent, tail + 1):
215
+ return True
216
+ return False
217
+
218
+
219
+ def _title_pattern(title: str) -> re.Pattern[str] | None:
220
+ words = tidy(title).split()
221
+ if not words:
222
+ return None
223
+ return re.compile(r"\s*".join(re.escape(word) for word in words), re.IGNORECASE)
224
+
225
+
226
+ def _locate(flat: str, listed: list[tuple[str, str]]) -> list[tuple[str, str, int, int]]:
227
+ """Find each listed heading in the text, in order: (number, title, start, end-of-title).
228
+
229
+ The number alone is not enough (every pointer to "8.1" matches it), so the title has to follow, and the search
230
+ only moves forward, so a heading cannot be satisfied by an earlier cross-reference.
231
+ """
232
+ out: list[tuple[str, str, int, int]] = []
233
+ cursor = 0
234
+ for number, title in listed:
235
+ pattern = _title_pattern(title)
236
+ # A top-level annex is bookmarked as "A" but printed as "Annex A".
237
+ label = r"Annex\s+" + number + r"[\s\-–—:.]*" if number.isalpha() else re.escape(number) + r"\s+"
238
+ head = re.compile(r"(?<![\w.])" + label)
239
+ for match in head.finditer(flat, cursor):
240
+ # A heading that follows the previous heading's title with nothing between is not a pointer, whatever
241
+ # that title ends in ("15 The renames clause 15.1 Explicitly ...").
242
+ adjacent = not flat[cursor : match.start()].strip()
243
+ if not adjacent and _is_reference(flat, match.start()):
244
+ continue
245
+ tail = pattern.match(flat, match.end()) if pattern else None
246
+ if tail is None and pattern is not None:
247
+ short = _title_pattern(" ".join(title.split()[:4]))
248
+ tail = short.match(flat, match.end()) if short else None
249
+ if tail is not None:
250
+ out.append((number, title, match.start(), tail.end()))
251
+ cursor = tail.end()
252
+ break
253
+ return out
254
+
255
+
256
+ def _body_after_title(flat: str, start: int, stop: int) -> str:
257
+ return flat[start:stop].strip()
258
+
259
+
260
+ def clauses(source: list[str] | str, listed: list[tuple[str, str]] | None = None) -> tuple[Clause, ...]:
261
+ """Split recommendation text into clauses.
262
+
263
+ With `listed` (the recommendation's own headings, from `headings`) the structure comes from the document:
264
+ every listed heading found in order becomes a clause with its exact title, headings with no text of their
265
+ own included, and numbered paragraphs inside it are accepted only as strict successors. Without it, the
266
+ older heuristic applies: any dotted number that is not a pointer.
267
+ """
268
+ raw = source if isinstance(source, str) else "\n".join(source)
269
+ # Contents lines are dropped whole. Stripping the dot leaders instead leaves "11.2 Unused bits 20" behind,
270
+ # which is a heading-shaped fragment that runs on into the next section and gets indexed as if it were
271
+ # the rule itself.
272
+ kept = [line for line in raw.splitlines() if not LEADER.search(line)]
273
+ flat = tidy("\n".join(kept))
274
+ if listed:
275
+ return _clauses_from_headings(flat, listed)
276
+ marks = [
277
+ (match.group(1), match.start(), match.end())
278
+ for match in CLAUSE_NUMBER.finditer(flat)
279
+ if not match.group(1).endswith(".0")
280
+ and not _is_reference(flat, match.start())
281
+ and _plausible_heading(flat, match.start(), match.group(1))
282
+ ]
283
+ found: list[Clause] = []
284
+ for index, (number, _start, end) in enumerate(marks):
285
+ stop = marks[index + 1][1] if index + 1 < len(marks) else len(flat)
286
+ body = flat[end:stop].strip()
287
+ if len(body) < 20 or HISTORY_ROW.match(body):
288
+ continue
289
+ found.append(Clause(number=number, title=first_sentence(body), body=body))
290
+ return tuple(found)
291
+
292
+
293
+ # Words that can follow a pointer ("applying 13.4 to the set", "then 16.8 to 16.11 apply") and never open a
294
+ # numbered paragraph or a definition entry. A definition's term is lowercase too ("3.1 canonical encoding:"), which
295
+ # is why this is a list of function words and not "anything lowercase": of the 276 accepted paragraphs that began
296
+ # with a lowercase word in the 02/2021 corpus, only four (all "to") began with one of these; all four were pointers.
297
+ _POINTER_FOLLOWERS = frozenset(
298
+ [
299
+ *("to", "and", "or", "shall", "should", "may", "must", "applies", "apply", "is", "are", "of", "do", "does"),
300
+ *("not", "still", "which", "that", "if", "then", "in", "for", "from", "as", "with", "by", "also", "above"),
301
+ *("below", "can", "cannot", "will", "was", "were", "has", "have", "being", "but"),
302
+ ]
303
+ )
304
+ _NEXT_WORD = re.compile(r"\s*([a-z][a-z-]*)\b")
305
+
306
+
307
+ def _is_pointer_by_follower(flat: str, end: int) -> bool:
308
+ match = _NEXT_WORD.match(flat, end)
309
+ return match is not None and match.group(1) in _POINTER_FOLLOWERS
310
+
311
+
312
+ def _is_annex_entry(
313
+ flat: str, last: tuple[int | str, ...], candidate: tuple[int | str, ...], at: int, floor: int
314
+ ) -> bool:
315
+ """`C.1` straight after the text of the previous annex or clause, where PDF bookmarks skip the annex's own
316
+ heading ("Annex C", its title and "(This annex forms an integral part ...)"). Accepted only for a later annex
317
+ letter and only when the words "Annex C" occur since the previous numbered paragraph, so a pointer such as
318
+ "defined in C.10" cannot open an annex."""
319
+ if len(candidate) != 2 or candidate[1] != 1 or not isinstance(candidate[0], str):
320
+ return False
321
+ letter = candidate[0]
322
+ if not isinstance(last[0], int) and letter <= last[0]:
323
+ return False # annexes only advance; a letter has no heading to open twice
324
+ # Later than the current one, not necessarily the next: an annex without numbered paragraphs ("Annex B") is
325
+ # skipped over.
326
+ return re.search(rf"Annex\s+{letter}\b", flat[floor:at]) is not None
327
+
328
+
329
+ def _paragraph_marks(
330
+ flat: str, number: str, start: int, stop: int, taken: frozenset[str] = frozenset()
331
+ ) -> list[tuple[str, int, int]]:
332
+ """Numbered paragraphs inside one heading's region, accepted only as successors of the previous one.
333
+
334
+ `taken` holds the numbers of headings located elsewhere in the document. Such a number cannot also be a
335
+ paragraph of this region, so an occurrence here is a pointer or a list entry ("RepetitionStructure 16.4
336
+ ConcatenationStructure 16.5") however well it fits the sequence.
337
+ """
338
+ marks: list[tuple[str, int, int]] = []
339
+ last = _key(number)
340
+ floor = start
341
+ for match in CLAUSE_NUMBER.finditer(flat, start, stop):
342
+ candidate = match.group(1)
343
+ key = _key(candidate)
344
+ if (
345
+ candidate not in taken
346
+ and (_is_successor(last, key) or _is_annex_entry(flat, last, key, match.start(), floor))
347
+ and not candidate.endswith(".0")
348
+ # nothing between the heading's title and the number: a paragraph, whatever the title ends in
349
+ and (not flat[start : match.start()].strip() or not _is_reference(flat, match.start()))
350
+ and not _is_pointer_by_follower(flat, match.end())
351
+ ):
352
+ marks.append((candidate, match.start(), match.end()))
353
+ last = key
354
+ floor = match.end()
355
+ return marks
356
+
357
+
358
+ def _clauses_from_headings(flat: str, listed: list[tuple[str, str]]) -> tuple[Clause, ...]:
359
+ located = _locate(flat, listed)
360
+ found: list[Clause] = []
361
+ taken = frozenset(number for number, *_ in located)
362
+ for index, (number, title, _start, title_end) in enumerate(located):
363
+ region_end = located[index + 1][2] if index + 1 < len(located) else len(flat)
364
+ marks = _paragraph_marks(flat, number, title_end, region_end, taken)
365
+ first_stop = marks[0][1] if marks else region_end
366
+ found.append(
367
+ Clause(number=number, title=title, body=_body_after_title(flat, title_end, first_stop), heading=True)
368
+ )
369
+ for position, (candidate, _s, end) in enumerate(marks):
370
+ stop = marks[position + 1][1] if position + 1 < len(marks) else region_end
371
+ body = flat[end:stop].strip()
372
+ if len(body) >= 20 and not HISTORY_ROW.match(body):
373
+ found.append(Clause(number=candidate, title=first_sentence(body), body=body))
374
+ return tuple(found)
375
+
376
+
377
+ def find(clauses_in: tuple[Clause, ...], number: str) -> Clause | None:
378
+ return next((clause for clause in clauses_in if clause.number == number), None)
379
+
380
+
381
+ _STOPWORDS = frozenset(
382
+ [
383
+ "a",
384
+ "an",
385
+ "the",
386
+ "of",
387
+ "for",
388
+ "in",
389
+ "on",
390
+ "to",
391
+ "and",
392
+ "or",
393
+ "is",
394
+ "are",
395
+ "be",
396
+ "by",
397
+ "with",
398
+ "as",
399
+ "at",
400
+ "from",
401
+ "that",
402
+ "this",
403
+ "it",
404
+ "its",
405
+ "into",
406
+ "than",
407
+ ]
408
+ )
409
+
410
+ # Words that make a question a question and say nothing about the subject. Left in a query they are required terms
411
+ # under all-terms matching and noise under any-term matching. Modal verbs ("must", "shall", "should") are NOT here:
412
+ # in a normative document they are part of what is being asked. Measured on the blind retrieval set, dropping this
413
+ # list took hit@1 from 38 to 45 of 82 and improved both halves of an even/odd split of the questions.
414
+ _QUESTION_WORDS = frozenset(
415
+ [
416
+ "how",
417
+ "what",
418
+ "which",
419
+ "when",
420
+ "where",
421
+ "why",
422
+ "who",
423
+ "whom",
424
+ "do",
425
+ "does",
426
+ "did",
427
+ "i",
428
+ "my",
429
+ "me",
430
+ "we",
431
+ "you",
432
+ "your",
433
+ "there",
434
+ "these",
435
+ "those",
436
+ "if",
437
+ "any",
438
+ "some",
439
+ "say",
440
+ "says",
441
+ "said",
442
+ "get",
443
+ "gets",
444
+ "was",
445
+ "were",
446
+ "been",
447
+ ]
448
+ )
449
+
450
+ _DEFINITION_WORDS = frozenset({"definition", "definitions", "meaning", "means", "mean", "glossary", "term", "terms"})
451
+
452
+
453
+ def query_tokens(raw: str) -> list[str]:
454
+ """The words of a query with punctuation stripped from their ends. Case is kept."""
455
+ return [token for token in (word.strip("?.,;:!()\"'") for word in raw.split()) if token]
456
+
457
+
458
+ def asks_for_definition(raw: str) -> bool:
459
+ """Whether the query is looking up what a term means, which is when the definitions clause is the right answer."""
460
+ return any(token.lower() in _DEFINITION_WORDS for token in query_tokens(raw))
461
+
462
+
463
+ def query_words(raw: str) -> list[str]:
464
+ """The words of a query that are search terms: filler and question words dropped, a query made only of them
465
+ kept as typed. Modal verbs stay: in a normative document they are part of what is being asked."""
466
+ tokens = [token for token in raw.split() if token]
467
+ # Filler words must not be required terms ("encoding for a value of a CHOICE type" matches no clause that
468
+ # contains every one of those words), but a query made only of them is still a query.
469
+ content = [token for token in tokens if token.strip("?.,;:!()\"'").lower() not in _STOPWORDS | _QUESTION_WORDS]
470
+ return content or tokens
471
+
472
+
473
+ def fts_query(raw: str, *, any_term: bool = False) -> str:
474
+ """Turn a user query into something FTS5 will accept: `query_words`, each quoted as a phrase.
475
+
476
+ A raw query is not a valid MATCH expression: `set-of value order` parses as `set` NEAR `-of` (an
477
+ unsupported-unary-not) and raised OperationalError out of a search tool, which is exactly the wrong shape
478
+ of failure - the caller sees a crash where it should see either an answer or a stated error. Each token is
479
+ quoted, and internal double quotes are doubled, which is FTS5's own escape. The cost is that advanced
480
+ syntax (`AND`, `NEAR`, prefix `*`) stops working from the query box; that is the honest trade, and it is
481
+ said here rather than left for a user to discover. The index searches through the core's own quoting
482
+ (`primarysource.index.fts_query`), given these words.
483
+ """
484
+ tokens = query_words(raw)
485
+ if not tokens:
486
+ raise ValueError("empty query: there is nothing to search for")
487
+ joiner = " OR " if any_term else " "
488
+ return joiner.join(f'"{token.replace(chr(34), chr(34) * 2)}"' for token in tokens)
489
+
490
+
491
+ # --- segmenting, normative statements and references -------------------------------------------------------
492
+
493
+ _ABBREVIATIONS = ("Rec.", "e.g.", "i.e.", "No.", "Fig.", "Figs.", "Tab.", "etc.", "cf.", "vs.", "ITU-T.")
494
+ _SENTENCE_BREAK = re.compile(r"(?<=[.;:])\s+(?=[A-Z(])")
495
+ _NORMATIVE = re.compile(r"\b(shall(?: not)?|should(?: not)?|must(?: not)?|may)\b")
496
+ _N = r"(?:\d{1,2}|[A-I])(?:\.\d{1,2})+"
497
+ _INTERNAL = re.compile(rf"(?:clauses?|sub-?clauses?|§)\s*({_N}(?:\s*(?:,|and|or|to)\s*{_N})*)")
498
+ _NUMBER_AT = re.compile(rf"(?<![\w.])({_N})(?![\d.]*\d)")
499
+ _NUMBER = re.compile(_N)
500
+ _REC = re.compile(r"\b(?:ITU-T\s+)?([A-Z]\.\d{1,4})(?:\s*\|\s*ISO/IEC\s+\d+(?:-\d+)?)?")
501
+ _ISO = re.compile(r"\bISO/IEC\s+\d+(?:-\d+)?(?::\d{4})?")
502
+ _RFC = re.compile(r"\bRFC\s*\d+")
503
+
504
+
505
+ def sentences(body: str) -> tuple[str, ...]:
506
+ """Split a clause body into citable units.
507
+
508
+ The extracted text has no paragraph structure left (`tidy` flattens whitespace, and PDF line breaks carry
509
+ no information), so the unit is the sentence and `¶n` means the nth sentence of the extracted clause. That
510
+ numbering is stable for a digest-pinned edition and is NOT ITU's own paragraphing; a citation that uses it
511
+ should also give the clause number and edition, which `cite` does.
512
+ """
513
+ merged: list[str] = []
514
+ for piece in _SENTENCE_BREAK.split(body):
515
+ if merged and merged[-1].endswith(_ABBREVIATIONS):
516
+ merged[-1] = f"{merged[-1]} {piece}"
517
+ else:
518
+ merged.append(piece)
519
+ return tuple(merged)
520
+
521
+
522
+ def normative(body: str) -> tuple[tuple[int, str], ...]:
523
+ """(¶ index, sentence) for every sentence stating an obligation, permission or recommendation."""
524
+ return tuple((n, s) for n, s in enumerate(sentences(body), start=1) if _NORMATIVE.search(s))
525
+
526
+
527
+ @dataclass(frozen=True)
528
+ class Refs:
529
+ clauses: tuple[str, ...]
530
+ bare: tuple[str, ...] # dotted numbers after "see"/"in"/"of"...: pointers if, and only if, the clause exists
531
+ recommendations: tuple[str, ...]
532
+ standards: tuple[str, ...]
533
+ foreign: tuple[str, ...] = () # numbers qualified by another recommendation's designation: not this document
534
+
535
+
536
+ _DESIGNATION = r"(?:ITU-T\s+[A-Z]\.\d+|ISO/IEC\s+\d+(?:-\d+)?)"
537
+ # "... Rec. ITU-T X.680 | ISO/IEC 8824-1, clause 12.3": the designation directly before the pointer.
538
+ _FOREIGN_BEFORE = re.compile(rf"{_DESIGNATION}(?:\s*\|\s*{_DESIGNATION})*\s*,?\s*$")
539
+ # "clause 12.3 of Rec. ITU-T X.680": the designation directly after it.
540
+ _FOREIGN_AFTER = re.compile(r"\s*,?\s*(?:of|in)\s+(?:Rec\.|ITU-T|ISO/IEC)")
541
+
542
+
543
+ def _is_foreign(body: str, start: int, end: int) -> bool:
544
+ """True when a pointer is qualified by another recommendation's designation. Resolving "clause 12.3 of
545
+ Rec. ITU-T X.680" against this document's own clause 12.3 would silently cite the wrong text. A pointer
546
+ qualified by the document's own name is not recognised as such: this text says "this Recommendation" instead."""
547
+ return (
548
+ _FOREIGN_BEFORE.search(body[max(start - 60, 0) : start]) is not None
549
+ or _FOREIGN_AFTER.match(body, end) is not None
550
+ )
551
+
552
+
553
+ def refs(body: str) -> Refs:
554
+ """What a clause points at: other clauses of the same document, other recommendations, ISO/IEC and RFCs.
555
+
556
+ Only references introduced by a word such as "clause" or the section sign count as clause references;
557
+ a bare dotted number is more often a version, an arc or an octet range than a pointer. Numbers after a
558
+ weaker introducer ("see 8.1.3", "as in 8.1.3.2") are returned separately as `bare`: callers should keep
559
+ one only if that clause exists, because nothing else distinguishes it from an arc or a version.
560
+ """
561
+ seen: dict[str, None] = {}
562
+ foreign: dict[str, None] = {}
563
+ for match in _INTERNAL.finditer(body):
564
+ target = foreign if _is_foreign(body, match.start(), match.end()) else seen
565
+ for number in _NUMBER.findall(match.group(1)):
566
+ target.setdefault(number)
567
+ bare = dict.fromkeys(
568
+ m.group(1)
569
+ for m in _NUMBER_AT.finditer(body)
570
+ if m.group(1) not in seen and _is_reference(body, m.start()) and not _is_foreign(body, m.start(), m.end())
571
+ )
572
+ recs = dict.fromkeys(m.group(1) for m in _REC.finditer(body))
573
+ standards = dict.fromkeys([m.group(0) for m in _ISO.finditer(body)] + [m.group(0) for m in _RFC.finditer(body)])
574
+ return Refs(tuple(seen), tuple(bare), tuple(recs), tuple(standards), tuple(foreign))
575
+
576
+
577
+ _REF = re.compile(r"\s*(?:([A-Z]\.\d+(?:#[\d-]+)?)\s*)?§?\s*((?:\d{1,2}|[A-I])(?:\.\d+)*)(?:\s*¶\s*(\d+)(-)?)?\s*")
578
+
579
+
580
+ @dataclass(frozen=True)
581
+ class Target:
582
+ """A parsed ref. `onward`: the ref ends `¶n-`, the clause from sentence n on, which is the `next:` of a read."""
583
+
584
+ rec: str | None
585
+ number: str
586
+ para: int | None
587
+ onward: bool = False
588
+
589
+
590
+ def parse_target(raw: str) -> Target:
591
+ """`X.690§11.6¶2`, `X.690#2021-02§11.6¶5-`, `X.690 11.6`, `§11.6`, `11.6¶2` -> `Target`."""
592
+ match = _REF.fullmatch(raw)
593
+ if not match:
594
+ raise ValueError(f"{raw!r} is not a clause reference; expected e.g. X.690§11.6 or 11.6¶2")
595
+ para = int(match.group(3)) if match.group(3) else None
596
+ if para == 0:
597
+ raise ValueError(f"{raw!r}: ¶ counts sentences from 1")
598
+ return Target(match.group(1), match.group(2), para, bool(match.group(4)))
599
+
600
+
601
+ def parse_ref(raw: str) -> tuple[str | None, str, int | None]:
602
+ """`X.690§11.6¶2`, `X.690 11.6`, `§11.6`, `11.6¶2` -> (rec or None, clause number, ¶ or None).
603
+
604
+ The `¶n-` form is a read cursor and means more than one sentence, so it is refused here, where one ¶ is meant.
605
+ """
606
+ target = parse_target(raw)
607
+ if target.onward:
608
+ raise ValueError(f"{raw!r} is a read cursor (¶n-, the clause from ¶n on); name one ¶ or the clause")
609
+ return target.rec, target.number, target.para
@@ -0,0 +1,16 @@
1
+ """`kconfig`: the Kconfig language documents of the Linux kernel, a pinned release, read as reStructuredText.
2
+ Served under `kconfig_`.
3
+
4
+ Stdlib only. `mcp` is imported by `server.py` when a server is built; `primarysource kconfig ...` runs `cli.py`.
5
+ `docs/kconfig.md` is the description; `catalogue.py` holds the pin, the licence and the build.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from primarysource.kconfig.catalogue import SPEC
11
+ from primarysource.pkgdocs.runtime import make_collection
12
+
13
+ COLLECTION = make_collection(SPEC, "primarysource.kconfig")
14
+ """Refs: `kconfig:kconfig-language§Menu entries`; see `pkgdocs.runtime`."""
15
+
16
+ __all__ = ["COLLECTION", "SPEC"]