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,430 @@
1
+ """The text of every tool and command: read, outline, search, cite, info, list. Local files only, no network.
2
+
3
+ Every result about a draft says three things the other collections need not, because a draft is not a fixed
4
+ document: which revision it read (a ref without one resolves to the latest stored revision, and says so), whether
5
+ that draft is still alive (RFC 2026 §2.2: no formal status, subject to change or removal at any time), and when
6
+ that was last checked. A dead or superseded draft starts with a banner line as well as ending in `warning:`, so
7
+ nobody has to reach the footer to learn it.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import difflib
13
+ import math
14
+ import re
15
+ from collections import Counter
16
+ from dataclasses import dataclass
17
+ from datetime import date
18
+
19
+ from primarysource import budget, cursor
20
+ from primarysource.draft import catalogue, datatracker, licence, names
21
+ from primarysource.draft.segment import Block, DSection, Segmented, normalise
22
+ from primarysource.draft.status import STALE_DAYS, Lifecycle, Status, lifecycle, staleness
23
+ from primarysource.draft.store import RevMeta, Store
24
+ from primarysource.errors import LibError
25
+ from primarysource.model import span_hash
26
+ from primarysource.refs import Ref
27
+ from primarysource.render import render
28
+
29
+ _WORD = re.compile(r"[a-z0-9][a-z0-9_]*")
30
+ _MONTHS = ("January", "February", "March", "April", "May", "June", "July", "August", "September", "October",
31
+ "November", "December") # fmt: skip
32
+ MAX_QUERY = 300
33
+
34
+
35
+ @dataclass(frozen=True, slots=True)
36
+ class Target:
37
+ """A ref resolved against the library: the draft, the revision actually read, and whether it was chosen."""
38
+
39
+ name: str
40
+ rev: str
41
+ chosen: bool
42
+ ref: Ref
43
+
44
+ @property
45
+ def doc(self) -> str:
46
+ return f"{self.name}-{self.rev}"
47
+
48
+
49
+ def resolve(store: Store, text: str, need_rev: bool = True) -> Target:
50
+ """Parse `text` as a ref and pick the revision. A ref with no document part is refused."""
51
+ if len(text) > 400:
52
+ raise LibError("not a ref: too long", "")
53
+ ref = names.GRAMMAR.parse(text)
54
+ if not ref.doc:
55
+ raise LibError(f"not a draft ref: {text[:60]!r}", "expected draft-<words>[-NN][§<section>[¶n]]")
56
+ name, rev = names.split_doc(ref.doc)
57
+ if rev is None and not need_rev:
58
+ return Target(name, "", False, ref)
59
+ chosen_rev, chosen = store.resolve(name, rev)
60
+ return Target(name, chosen_rev, chosen, ref)
61
+
62
+
63
+ def _day(published: str) -> str:
64
+ try:
65
+ y, m, d = (int(x) for x in published[:10].split("-"))
66
+ date(y, m, d)
67
+ return f"{d} {_MONTHS[m - 1]} {y}"
68
+ except (ValueError, IndexError):
69
+ return ""
70
+
71
+
72
+ def _initials(author: str) -> str:
73
+ parts = author.split()
74
+ if len(parts) < 2:
75
+ return author
76
+ return " ".join(f"{p[0]}." for p in parts[:-1] if p) + " " + parts[-1]
77
+
78
+
79
+ def _authors(meta: RevMeta) -> str:
80
+ listed = [_initials(a) for a in meta.authors]
81
+ if not listed:
82
+ return ""
83
+ if len(listed) > 3:
84
+ return f"{listed[0]} et al."
85
+ if len(listed) == 3:
86
+ return f"{listed[0]}, {listed[1]}, and {listed[2]}"
87
+ return " and ".join(listed)
88
+
89
+
90
+ def section_anchor(section: DSection) -> str:
91
+ """The datatracker HTML's anchor for a numbered section (`section-5.1`, `section-appendix.a`, `section-a.1`),
92
+ or '' for an unnumbered one, which has no stable anchor."""
93
+ if not section.num:
94
+ return ""
95
+ if section.num.startswith("Appendix "):
96
+ return "section-appendix." + section.num.split(" ", 1)[1].lower()
97
+ return "section-" + section.num.lower()
98
+
99
+
100
+ def cite_string(meta: RevMeta, section: DSection | None = None, para: int | str | None = None) -> str:
101
+ """`Authors, "Title", Work in Progress, Internet-Draft, name-NN, date, <url#anchor>, Section 5.1, paragraph 2.`
102
+ "Work in Progress" is the only way RFC 2026 §2.2 and the drafts' own status text allow one to be cited."""
103
+ anchor = section_anchor(section) if section else ""
104
+ url = datatracker.html_url(meta.name, meta.rev) + (f"#{anchor}" if anchor else "")
105
+ parts = [p for p in (_authors(meta), f'"{meta.title}"', "Work in Progress", "Internet-Draft",
106
+ f"{meta.name}-{meta.rev}", _day(meta.published), f"<{url}>") if p] # fmt: skip
107
+ text = ", ".join(parts)
108
+ if section is not None:
109
+ where = f"Section {section.num.removeprefix('Appendix ')}" if section.num else f'"{section.title}"'
110
+ if section.num.startswith("Appendix "):
111
+ where = f"Appendix {section.num.split(' ', 1)[1]}"
112
+ text += f", {where}" + (f", paragraph{'s' if isinstance(para, str) else ''} {para}" if para else "")
113
+ return text + "."
114
+
115
+
116
+ def standing(store: Store, name: str, rev: str, chosen: bool = False) -> tuple[list[str], str]:
117
+ """(warnings, banner) for reading `name`-`rev`: what a reader must know before relying on it. The banner is
118
+ '' when the draft is alive and `rev` is its latest revision."""
119
+ notes: list[str] = []
120
+ banner = ""
121
+ if chosen:
122
+ notes.append(f"no revision in the ref: read the latest stored revision -{rev}")
123
+ status = store.status(name)
124
+ if status is None:
125
+ notes.append(
126
+ f"no datatracker status stored, so expiry and replacement are unknown: primarysource draft refresh {name}"
127
+ )
128
+ return notes, banner
129
+ cycle = lifecycle(status)
130
+ if not cycle.alive:
131
+ banner = f"[{cycle.level.upper()}: {cycle.text}]"
132
+ notes.append(f"{cycle.level.upper()}: {cycle.text}")
133
+ if status.rev != rev:
134
+ newer = f"-{rev} is not the latest revision: the datatracker listed -{status.rev}"
135
+ banner = banner or f"[{newer}]"
136
+ notes.append(newer)
137
+ age = staleness(status)
138
+ if age > STALE_DAYS:
139
+ notes.append(f"status was checked {age} days ago: primarysource draft refresh {name}")
140
+ return notes, banner
141
+
142
+
143
+ def _footer(cite: str, notes: list[str], span: str = "", nxt: str = "") -> dict[str, str]:
144
+ return {"cite": cite, "unit": "paragraph", "span": span, "next": nxt, "warning": "; ".join(notes)}
145
+
146
+
147
+ def _section_not_found(seg: Segmented, section: str, doc: str) -> LibError:
148
+ close = difflib.get_close_matches(section, [s.id for s in seg.sections], n=3)
149
+ hint = f"did you mean {', '.join(close)}?" if close else f"draft_outline lists the sections of {doc}"
150
+ return LibError(f"{doc} has no section {section!r}", hint)
151
+
152
+
153
+ def _block_text(block: Block) -> str:
154
+ return block.text
155
+
156
+
157
+ def read(store: Store, text: str, max_tokens: int = 2000) -> str:
158
+ """Read by ref: `name[-rev]` (abstract and outline), `§section` (its paragraphs, paged), `§section¶n` (from
159
+ paragraph n, paged) or `:first-last` (those lines of the stored text). The `next:` footer is the ref that reads
160
+ the next page."""
161
+ target = resolve(store, text)
162
+ meta = store.meta(target.name, target.rev)
163
+ notes, banner = standing(store, target.name, target.rev, target.chosen)
164
+ ref = target.ref
165
+ if ref.lines is not None:
166
+ return _read_lines(store, target, meta, ref.lines, banner, notes)
167
+ seg = store.segmented(target.name, target.rev)
168
+ if ref.section is None:
169
+ return _overview(seg, target, meta, banner, notes)
170
+ section = seg.section(ref.section)
171
+ if section is None:
172
+ raise _section_not_found(seg, ref.section, target.doc)
173
+ head = f"{target.doc}§{section.id} {section.title}".rstrip()
174
+ if ref.para is not None and ref.para > len(section.blocks):
175
+ raise LibError(f"{head} has {len(section.blocks)} paragraphs, not {ref.para}", "")
176
+ units = tuple(_block_text(b) for b in section.blocks)
177
+ if not units:
178
+ body = "(no text under this heading; see its subsections in the outline)"
179
+ return render("\n".join(x for x in (banner, head, body) if x), _footer(cite_string(meta, section), notes))
180
+ if max_tokens < 1:
181
+ raise LibError("max_tokens must be positive", "")
182
+ page = budget.page(units, ref.para or 1, max_tokens)
183
+ body = "\n\n".join(page.units)
184
+ nxt = cursor.read_next(f"{target.doc}§{section.id}", page)
185
+ cite = cite_string(meta, section, cursor.para_label(ref.para, page))
186
+ if ref.para is not None: # a ¶ read is the paragraphs themselves, as a search hit's ref promises
187
+ return render("\n".join(x for x in (banner, body) if x), _footer(cite, notes, span_hash(body), nxt))
188
+ top = [banner, f"{head} (paragraphs {page.first}-{page.last} of {page.total})"]
189
+ return render("\n".join(x for x in top if x) + "\n\n" + body, _footer(cite, notes, span_hash(body), nxt))
190
+
191
+
192
+ def _read_lines(
193
+ store: Store, target: Target, meta: RevMeta, span: tuple[int, int], banner: str, notes: list[str]
194
+ ) -> str:
195
+ lines = normalise(store.raw_text(target.name, target.rev).decode("utf-8", errors="replace"))
196
+ first, last = span
197
+ if first > len(lines):
198
+ raise LibError(f"{target.doc} has {len(lines)} lines; {first}-{last} is past the end", "")
199
+ body = "\n".join(lines[first - 1 : last])
200
+ cite = cite_string(meta)[:-1] + f", lines {first}-{min(last, len(lines))}."
201
+ return render("\n".join(x for x in (banner, body) if x), _footer(cite, notes, span_hash(body)))
202
+
203
+
204
+ def _overview(seg: Segmented, target: Target, meta: RevMeta, banner: str, notes: list[str]) -> str:
205
+ abstract = seg.section("abstract")
206
+ head = f"{target.doc} {meta.title}"
207
+ parts = [banner] if banner else []
208
+ parts += [head, f"{len(seg.sections)} sections; segmented from the text by {seg.method}"]
209
+ if abstract is not None and abstract.blocks:
210
+ parts += ["", "Abstract", *(b.text for b in abstract.blocks)]
211
+ parts += ["", "Sections (read one with draft_read('<ref>')):", *_outline_lines(seg, target.doc)]
212
+ return render("\n".join(p for p in parts if p is not None), _footer(cite_string(meta), notes))
213
+
214
+
215
+ def _outline_lines(seg: Segmented, doc: str) -> list[str]:
216
+ out = []
217
+ for s in seg.sections:
218
+ label = f"{s.num} " if s.num else ""
219
+ out.append(f"{' ' * (s.depth - 1)}{doc}§{s.id} {label}{s.title} ({len(s.blocks)})")
220
+ return out
221
+
222
+
223
+ def outline(store: Store, text: str) -> str:
224
+ """The sections of a stored revision, each with the ref that reads it and its paragraph count."""
225
+ target = resolve(store, text)
226
+ meta = store.meta(target.name, target.rev)
227
+ notes, banner = standing(store, target.name, target.rev, target.chosen)
228
+ seg = store.segmented(target.name, target.rev)
229
+ body = "\n".join([x for x in (banner, f"{target.doc} {meta.title}") if x] + _outline_lines(seg, target.doc))
230
+ return render(body, _footer(cite_string(meta), notes))
231
+
232
+
233
+ def cite(store: Store, text: str) -> str:
234
+ """The citation for a ref, with the span hash of the cited text when it is a paragraph or a section."""
235
+ target = resolve(store, text)
236
+ meta = store.meta(target.name, target.rev)
237
+ notes, banner = standing(store, target.name, target.rev, target.chosen)
238
+ ref = target.ref
239
+ section: DSection | None = None
240
+ span = ""
241
+ if ref.section is not None:
242
+ seg = store.segmented(target.name, target.rev)
243
+ section = seg.section(ref.section)
244
+ if section is None:
245
+ raise _section_not_found(seg, ref.section, target.doc)
246
+ if ref.para is not None:
247
+ if ref.para > len(section.blocks):
248
+ raise LibError(f"{target.doc}§{section.id} has {len(section.blocks)} paragraphs, not {ref.para}", "")
249
+ span = span_hash(section.blocks[ref.para - 1].text)
250
+ else:
251
+ span = span_hash("\n\n".join(b.text for b in section.blocks))
252
+ cite_text = cite_string(meta, section, ref.para)
253
+ resolved = Ref(doc=target.doc, section=ref.section, para=ref.para)
254
+ said = f"citation for {names.GRAMMAR.format(resolved)}; the cite: line is the string to use"
255
+ return render("\n".join(x for x in (banner, said) if x), _footer(cite_text, notes, span))
256
+
257
+
258
+ def _lifecycle_line(status: Status) -> str:
259
+ cycle: Lifecycle = lifecycle(status)
260
+ return f"{cycle.level.upper()}: {cycle.text}"
261
+
262
+
263
+ def info(store: Store, text: str) -> str:
264
+ """Everything known about a draft: standing, revisions stored and upstream, licence legend, pins."""
265
+ ref = names.GRAMMAR.parse(text)
266
+ if not ref.doc:
267
+ raise LibError(f"not a draft name: {text[:60]!r}", "")
268
+ name, _rev = names.split_doc(ref.doc)
269
+ stored = store.revisions(name)
270
+ status = store.status(name)
271
+ tracked = catalogue.TRACKED.get(name)
272
+ if not stored and status is None and tracked is None:
273
+ raise LibError(
274
+ f"{name} is neither stored nor tracked",
275
+ "draft_list lists what is tracked",
276
+ f"primarysource draft import {name}",
277
+ )
278
+ lines = [name]
279
+ if status is not None:
280
+ lines += [
281
+ f"title: {status.title}",
282
+ f"datatracker state: {status.state}",
283
+ f"standing: {_lifecycle_line(status)}",
284
+ ]
285
+ lines.append(
286
+ f"latest revision on the datatracker: -{status.rev}"
287
+ + (f" (posted {status.published(status.rev)})" if status.published(status.rev) else "")
288
+ )
289
+ lines.append(f"expires: {status.expires[:10] or 'unknown'}")
290
+ if status.replaced_by:
291
+ lines.append(f"replaced by: {', '.join(status.replaced_by)}")
292
+ if status.became_rfc:
293
+ lines.append(f"became: {status.became_rfc} (rfclib reads it)")
294
+ lines.append(f"revisions on the datatracker: {', '.join(r for r, _ in status.revisions) or 'none listed'}")
295
+ if status.group or status.stream:
296
+ lines.append(f"group: {status.group or '-'} stream: {status.stream or '-'}")
297
+ lines.append(f"status checked: {status.checked[:10]} ({staleness(status)} days ago)")
298
+ elif tracked is not None:
299
+ lines += [
300
+ "no datatracker status stored here; the catalogue's snapshot:",
301
+ f"catalogue snapshot, checked {tracked.checked or 'never'}: state {tracked.state or 'unknown'}, "
302
+ f"revision read -{tracked.rev_read or '?'}"
303
+ + (f", replaced by {', '.join(tracked.replaced_by)}" if tracked.replaced_by else "")
304
+ + (f", became {tracked.became_rfc}" if tracked.became_rfc else ""),
305
+ f"refresh it: primarysource draft refresh {name}",
306
+ ]
307
+ lines.append(f"revisions stored: {', '.join('-' + r for r in stored) or 'none'}")
308
+ if stored:
309
+ meta = store.meta(name, stored[-1])
310
+ lines.append(
311
+ f"text of -{meta.rev}: {meta.txt_bytes:,} bytes, sha256 {meta.txt_sha256}, imported {meta.imported[:10]}"
312
+ )
313
+ lines.append(
314
+ f"author's xml of -{meta.rev}: {meta.xml_state}"
315
+ + (f", sha256 {meta.xml_sha256}" if meta.xml_sha256 else "")
316
+ )
317
+ lines.append("licence: " + licence.Licence(meta.restriction, meta.copyright_year, meta.ipr).summary())
318
+ pin = catalogue.pin_for(name, meta.rev)
319
+ lines.append(f"pinned in the catalogue: {'yes' if pin else 'no (trusted on first use)'}")
320
+ if tracked is not None:
321
+ lines.append(f"tracked for: {', '.join(tracked.consumers)}. {tracked.why}")
322
+ lines.append(
323
+ "a draft has no formal status and may change or disappear at any time (RFC 2026 §2.2): "
324
+ "cite it only as work in progress"
325
+ )
326
+ notes = []
327
+ if status is not None and not lifecycle(status).alive:
328
+ notes.append(_lifecycle_line(status))
329
+ return render("\n".join(lines), {"warning": "; ".join(notes)})
330
+
331
+
332
+ def list_tracked(store: Store) -> str:
333
+ """One line per tracked or stored draft: stored revisions, the state last seen, who replaced it."""
334
+ rows = []
335
+ for name in sorted(set(catalogue.TRACKED) | set(store.names())):
336
+ stored = store.revisions(name)
337
+ status = store.status(name)
338
+ tracked = catalogue.TRACKED.get(name)
339
+ state = (
340
+ status.state if status else (f"{tracked.state} (catalogue)" if tracked and tracked.state else "unknown")
341
+ )
342
+ later = f"-{status.rev}" if status else (f"-{tracked.rev_read}" if tracked and tracked.rev_read else "-")
343
+ extra = ""
344
+ if status and status.replaced_by:
345
+ extra = f" replaced by {', '.join(status.replaced_by)}"
346
+ elif tracked and tracked.replaced_by:
347
+ extra = f" replaced by {', '.join(tracked.replaced_by)}"
348
+ rows.append(
349
+ f"{name} stored: {', '.join('-' + r for r in stored) or 'none'} latest: {later} state: {state}{extra}"
350
+ )
351
+ return render("\n".join(rows) or "no drafts stored or tracked", {})
352
+
353
+
354
+ def _tokens(text: str) -> list[str]:
355
+ return _WORD.findall(text.lower())
356
+
357
+
358
+ def _snippet(blocks: tuple[Block, ...], words: set[str], width: int = 160) -> str:
359
+ for b in blocks:
360
+ flat = " ".join(b.text.split())
361
+ low = flat.lower()
362
+ hits = [low.find(w) for w in words if low.find(w) >= 0]
363
+ if hits:
364
+ at = max(0, min(hits) - 40)
365
+ return ("..." if at else "") + flat[at : at + width]
366
+ return ""
367
+
368
+
369
+ def search(store: Store, query: str, limit: int = 10, name: str | None = None) -> str:
370
+ """BM25 over the sections of each draft's latest stored revision (or of one named draft or revision). All
371
+ words must match; if none of the sections has them all, any section with some does, and `relaxed:` says so."""
372
+ if not query.strip() or len(query) > MAX_QUERY:
373
+ raise LibError(f"a query is 1 to {MAX_QUERY} characters", "")
374
+ if not 1 <= limit <= 50:
375
+ raise LibError("limit is 1 to 50", "")
376
+ words = _tokens(query)
377
+ if not words:
378
+ raise LibError("the query has no searchable words", "use letters and digits")
379
+ chosen: list[tuple[str, str]] = []
380
+ if name:
381
+ n, rev = names.split_doc(name)
382
+ chosen.append((n, store.resolve(n, rev)[0]))
383
+ else:
384
+ chosen = [(n, store.revisions(n)[-1]) for n in store.names()]
385
+ if not chosen:
386
+ raise LibError(
387
+ "no draft is stored", "draft_list lists the drafts to import", "primarysource draft import <name>"
388
+ )
389
+ rows: list[tuple[str, str, DSection, Counter[str], int, Status | None]] = []
390
+ for n, rev in chosen:
391
+ seg = store.segmented(n, rev)
392
+ meta = store.meta(n, rev)
393
+ status = store.status(n)
394
+ for s in seg.sections:
395
+ tf = Counter(_tokens(s.title) * 3 + _tokens(" ".join(b.text for b in s.blocks)))
396
+ rows.append((n, rev, s, tf, sum(tf.values()), status))
397
+ _ = meta
398
+ avg = sum(r[4] for r in rows) / max(1, len(rows))
399
+ df = {w: sum(1 for r in rows if r[3][w]) for w in set(words)}
400
+
401
+ def score(row: tuple[str, str, DSection, Counter[str], int, Status | None]) -> float:
402
+ total = 0.0
403
+ for w in set(words):
404
+ f = row[3][w]
405
+ if f:
406
+ idf = math.log(1 + (len(rows) - df[w] + 0.5) / (df[w] + 0.5))
407
+ total += idf * f * 2.2 / (f + 1.2 * (0.25 + 0.75 * row[4] / max(avg, 1.0)))
408
+ return total
409
+
410
+ everything = [r for r in rows if all(r[3][w] for w in set(words))]
411
+ relaxed = ""
412
+ if not everything:
413
+ everything = [r for r in rows if any(r[3][w] for w in set(words))]
414
+ relaxed = "not every word matched in any one section; these match some of them" if everything else ""
415
+ everything.sort(key=lambda r: (-score(r), r[0], r[1], r[2].head_line))
416
+ lines = []
417
+ dead = 0
418
+ for n, rev, s, _tf, _len, status in everything[:limit]:
419
+ mark = ""
420
+ if status is not None and not lifecycle(status).alive:
421
+ mark = f" [{lifecycle(status).level}]"
422
+ dead += 1
423
+ label = f"{s.num} " if s.num else ""
424
+ lines.append(f"{n}-{rev}§{s.id} {label}{s.title}{mark}: {_snippet(s.blocks, set(words))}")
425
+ if not lines:
426
+ return render("no section matched", {})
427
+ notes = (
428
+ [f"{dead} hit(s) are in drafts that are not alive (marked); check draft_info before citing"] if dead else []
429
+ )
430
+ return render("\n".join(lines), {"relaxed": relaxed, "warning": "; ".join(notes)})
@@ -0,0 +1,74 @@
1
+ """What the root element of a draft's submitted `.xml` says, read without parsing the rest.
2
+
3
+ The archive's `.xml` is the author's source: xml2rfc v2 (`<!DOCTYPE rfc SYSTEM "rfc2629.dtd">`, no `version`
4
+ attribute) or v3, with no `pn` ids and no section numbers. It is stored as the author's source and pinned, but
5
+ it is not segmented (segment.py says why), so all this module reads is the root start tag: the format version,
6
+ `docName`, `category`, `submissionType` and `ipr` (the licence legend, RFC 7991 Appendix A.1.1).
7
+
8
+ Reading only the first start tag is also the hardening. The rest of a v2 source can use entities the DTD defines
9
+ (`&nbsp;`) which a parser without the DTD cannot expand, and a hostile file can declare its own. So:
10
+
11
+ - any `<!ENTITY` or internal DTD subset is refused before a parser sees the bytes (no billion-laughs, no
12
+ external entity, ever);
13
+ - the parser stops at the root start tag, and never fetches a DTD.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import re
19
+ import xml.etree.ElementTree as ET # noqa: S405 # internal subsets and entities are refused first; root tag only
20
+ from dataclasses import dataclass
21
+ from typing import Any
22
+
23
+ V2 = "v2"
24
+ V3 = "v3"
25
+ REFUSED = "refused"
26
+ UNPARSEABLE = "unparseable"
27
+
28
+ _INTERNAL_SUBSET = re.compile(rb"<!DOCTYPE[^>\[]*\[")
29
+ _MAX_ATTR = 200
30
+ _HEAD = 65536
31
+ _ROOT_TAG = re.compile(rb"<rfc(?=[\s/>])(?:[^>\"']|\"[^\"]*\"|'[^']*')*>")
32
+
33
+
34
+ @dataclass(frozen=True, slots=True)
35
+ class XmlInfo:
36
+ """`state` is V2, V3, REFUSED or UNPARSEABLE; `why` says what was refused; the rest are the root's
37
+ attributes (empty when absent)."""
38
+
39
+ state: str
40
+ ipr: str = ""
41
+ doc_name: str = ""
42
+ category: str = ""
43
+ submission_type: str = ""
44
+ why: str = ""
45
+
46
+
47
+ def inspect(data: bytes) -> XmlInfo:
48
+ """The root element's facts, or a refusal that says why. Never raises on bad input."""
49
+ if b"<!ENTITY" in data:
50
+ return XmlInfo(REFUSED, why="declares entities")
51
+ if _INTERNAL_SUBSET.search(data[:4096]):
52
+ return XmlInfo(REFUSED, why="has an internal DTD subset")
53
+ root = _ROOT_TAG.search(data[:_HEAD])
54
+ if root is None:
55
+ return XmlInfo(UNPARSEABLE, why=f"no <rfc> start tag in the first {_HEAD:,} bytes")
56
+ parser: ET.XMLPullParser[Any] = ET.XMLPullParser(events=("start",))
57
+ try:
58
+ # only the bytes up to the end of the root start tag are fed: whatever follows (entities a DTD would
59
+ # define, a hostile body) is never seen by the parser
60
+ parser.feed(data[: root.end()])
61
+ events: Any = parser.read_events()
62
+ for event in events:
63
+ element = event[1]
64
+ attrs = {key: value[:_MAX_ATTR] for key, value in element.attrib.items()}
65
+ return XmlInfo(
66
+ V3 if attrs.get("version") == "3" else V2,
67
+ ipr=attrs.get("ipr", ""),
68
+ doc_name=attrs.get("docName", ""),
69
+ category=attrs.get("category", ""),
70
+ submission_type=attrs.get("submissionType", ""),
71
+ )
72
+ except ET.ParseError as exc:
73
+ return XmlInfo(UNPARSEABLE, why=str(exc)[:100])
74
+ return XmlInfo(UNPARSEABLE, why="no root element")
@@ -0,0 +1,52 @@
1
+ """Ecma International standards, starting with ECMA-427 (Package-URL, 1st edition, December 2025).
2
+
3
+ Source: the standard's PDF from ecma-international.org, pinned by sha256 in `catalogue.json`, imported into the
4
+ user's own `$PRIMARYSOURCE_HOME/ecma/` with `primarysource ecma import 427`. Placement is a volume (Ecma's text
5
+ copyright notice allows copies only with the notice and no modification, and Ecma prefers its own download).
6
+ Reading the PDF needs `pypdf` (the `[ecma]` extra) at import time only, and uses `pdftotext` for the text when it
7
+ is on PATH. `docs/ecma.md` is the user-facing description.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from primarysource.model import Unit
13
+ from primarysource.refs import Ref, RefGrammar
14
+ from primarysource.registry import Collection
15
+
16
+ ECMA_DOC = r"(?:ECMA-|ecma-)?[0-9]{1,4}(?:@[0-9]{1,2})?"
17
+ """A standard by number, optionally with its edition number: `427`, `ECMA-427`, `427@1`."""
18
+
19
+ ECMA_SECTION = r"[0-9A-Za-z][0-9A-Za-z.\-]*"
20
+ """A clause as the PDF bookmarks number it (`5.3`, `A`, `A.1`) or the slug of an unnumbered bookmark."""
21
+
22
+
23
+ class _Refs:
24
+ """The collection's ref parser: `library.parse_ref` (which also takes the `ecma:` prefix and refuses what the
25
+ collection has no meaning for), imported on first use so the collection loads without its reader."""
26
+
27
+ _grammar = RefGrammar(ECMA_DOC, ECMA_SECTION)
28
+
29
+ def parse(self, text: str) -> Ref:
30
+ from primarysource.ecma.library import parse_ref
31
+
32
+ return parse_ref(text)
33
+
34
+ def format(self, ref: Ref) -> str:
35
+ return self._grammar.format(ref)
36
+
37
+
38
+ ECMA = Collection(
39
+ name="ecma",
40
+ title="Ecma International standards (ECMA-427, Package-URL)",
41
+ prefix="ecma_",
42
+ unit=Unit.SENTENCE,
43
+ grammar=_Refs(),
44
+ server="primarysource.ecma.server:server",
45
+ cli="primarysource.ecma.cli:main",
46
+ identify="primarysource.ecma.identify:identify",
47
+ )
48
+ """Refs: `ecma:427§5.3¶2`. `§` names a clause by its number; `¶n` is the nth sentence of the clause's span (its own
49
+ text, then each subsection as a `§5.3.1 Title` line and its text), because a PDF gives sentences, not paragraphs.
50
+ The `ecma:` prefix is optional and `ECMA-427` means `427`."""
51
+
52
+ __all__ = ["ECMA", "ECMA_DOC", "ECMA_SECTION"]