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,233 @@
1
+ """Read the imported POSIX HTML edition: page outlines, budgeted reads by ref, and a bounded grep.
2
+
3
+ A page (`functions/open`) is converted with the collection's private HTML reader (`_html`, profile `POSIX`) into a
4
+ `primarysource.model.Document` whose sections are the page's headings, keyed by their HTML anchors
5
+ (`tag_17_372_03`). Each section's units are the blocks under its heading, the heading excluded and subsections
6
+ included. The core has no index yet, so there is no search here: `grep` scans pages, under a time and hit budget.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import difflib
12
+ import html
13
+ import re
14
+ import time
15
+ from dataclasses import dataclass
16
+ from pathlib import Path
17
+
18
+ from primarysource import cursor
19
+ from primarysource.budget import page as budget_page
20
+ from primarysource.errors import LibError
21
+ from primarysource.ieee import POSIX, POSIX_PAGE, _html
22
+ from primarysource.ieee.catalogue import POSIX_2024, Edition
23
+ from primarysource.ieee.importer import INSTALL_RECORD, collection_home
24
+ from primarysource.model import Document, Section, span_hash
25
+ from primarysource.refs import Ref
26
+ from primarysource.render import next_start, render
27
+
28
+ VOLUMES = {"basedefs": "XBD", "functions": "XSH", "utilities": "XCU", "xrat": "XRAT"}
29
+ """The four volumes' directories and their usual short names."""
30
+
31
+ MAX_NEEDLE = 200
32
+ MAX_HITS = 200
33
+ _PAGE = re.compile(POSIX_PAGE)
34
+ _TAGS = re.compile(r"<[^>]*>")
35
+
36
+
37
+ @dataclass(frozen=True, slots=True)
38
+ class Page:
39
+ """One converted page: the model document plus the converter's output it was built from."""
40
+
41
+ page: str
42
+ document: Document
43
+ converted: _html.Document
44
+
45
+ def section_for(self, anchor: str) -> Section | None:
46
+ """The section an anchor names: a heading's own anchor, or an id that sits just before a heading."""
47
+ found = self.document.section(anchor)
48
+ if found is None and anchor in self.converted.aliases:
49
+ head = self.converted.headings[self.converted.aliases[anchor]]
50
+ found = self.document.section(head.anchor)
51
+ return found
52
+
53
+
54
+ def parse_page(data: bytes, page: str, edition: Edition = POSIX_2024) -> Page:
55
+ """Convert one page's bytes. All 1,616 pages of the 2024 archive are UTF-8 (measured); a stray byte is
56
+ replaced rather than refused, so one bad character cannot hide a page."""
57
+ converted = _html.convert(data.decode("utf-8", errors="replace"), page, _html.POSIX)
58
+ sections: list[Section] = []
59
+ first = converted.headings[0].block if converted.headings else len(converted.blocks)
60
+ if first:
61
+ sections.append(Section("", converted.title or page, tuple(converted.blocks[:first]), 0))
62
+ for position, head in enumerate(converted.headings):
63
+ start, end = converted.span(position)
64
+ anchor = head.anchor or f"_{position + 1}"
65
+ sections.append(Section(anchor, head.text, tuple(converted.blocks[start + 1 : end]), head.level))
66
+ title = converted.title or page
67
+ document = Document(f"{edition.id}§{page}", title, POSIX.unit, tuple(sections))
68
+ return Page(page, document, converted)
69
+
70
+
71
+ def installed(edition: Edition = POSIX_2024, home: Path | None = None) -> Path:
72
+ """The directory of an imported edition, or a LibError that says how to import it."""
73
+ root = collection_home("posix", home) / edition.id
74
+ if not (root / INSTALL_RECORD).is_file():
75
+ raise LibError(
76
+ f"POSIX {edition.id} is not imported",
77
+ "",
78
+ f"download {edition.source_url} (personal use, see docs/ieee.md), then run "
79
+ f"primarysource posix import {edition.archive}",
80
+ )
81
+ return root
82
+
83
+
84
+ def pages(volume: str | None = None, *, edition: Edition = POSIX_2024, home: Path | None = None) -> list[str]:
85
+ """Page ids of the imported edition, sorted; only those under `volume` (`functions`) when given."""
86
+ root = installed(edition, home)
87
+ found = [p.relative_to(root).as_posix()[: -len(".html")] for p in root.rglob("*.html")]
88
+ return sorted(p for p in found if volume is None or p.startswith(volume + "/"))
89
+
90
+
91
+ def load_page(page: str, *, edition: Edition = POSIX_2024, home: Path | None = None) -> Page:
92
+ """Read and convert one page, refusing a page id outside the grammar and naming near misses."""
93
+ if not _PAGE.fullmatch(page):
94
+ raise LibError(f"not a POSIX page id: {page!r}", "a page is a path such as functions/open or utilities/pax")
95
+ root = installed(edition, home)
96
+ path = root.joinpath(*page.split("/")).with_name(page.split("/")[-1] + ".html")
97
+ if not path.is_file():
98
+ near = difflib.get_close_matches(page, pages(edition=edition, home=home), n=3)
99
+ raise LibError(f"no page {page!r} in {edition.id}", ("did you mean " + ", ".join(near)) if near else "")
100
+ return parse_page(path.read_bytes(), page, edition)
101
+
102
+
103
+ def cite(page: Page, section: Section | None = None, para: int | str | None = None) -> str:
104
+ """`IEEE Std 1003.1-2024 (The Open Group Base Specifications Issue 8), XSH open, DESCRIPTION ¶2`."""
105
+ volume = VOLUMES.get(page.page.split("/")[0], "")
106
+ where = f"{volume} {page.document.title}".strip()
107
+ out = f"{POSIX_2024.designations[0]} ({POSIX_2024.designations[1]}), {where}"
108
+ if section is not None and section.id:
109
+ out += f", {section.title}"
110
+ return out + (f" ¶{para}" if para is not None else "")
111
+
112
+
113
+ def _ref_text(edition: str, page: str, anchor: str | None, para: int | None = None) -> str:
114
+ return POSIX.grammar.format(Ref(edition, page, para, anchor or None))
115
+
116
+
117
+ def outline(page: str, *, edition: Edition = POSIX_2024, home: Path | None = None) -> str:
118
+ """The page's headings, indented by level, each with the ref that reads it."""
119
+ loaded = load_page(page, edition=edition, home=home)
120
+ heads = [s for s in loaded.document.sections if s.id]
121
+ top = min((s.depth for s in heads), default=1)
122
+ lines = [f"{' ' * (s.depth - top)}{s.title} {_ref_text(edition.id, page, s.id)}" for s in heads]
123
+ body = f"{loaded.document.title} {_ref_text(edition.id, page, None)}\n" + "\n".join(lines)
124
+ return render(body, {"cite": cite(loaded), "unit": POSIX.unit})
125
+
126
+
127
+ def read(ref: str, *, max_tokens: int = 2000, edition: Edition = POSIX_2024, home: Path | None = None) -> str:
128
+ """A page or a section (`#anchor`), from block n with `¶n`, as text with the fixed footer. Long results are
129
+ paged: the `next:` footer is the ref that reads the next page."""
130
+ if "§" not in ref and "::" not in ref and _PAGE.match(ref.strip()):
131
+ ref = f"{edition.id}§{ref.strip()}" # a bare page (`functions/open#tag_17_372_03`) reads this edition
132
+ parsed = POSIX.grammar.parse(ref)
133
+ if parsed.lines is not None:
134
+ raise LibError(f"{ref!r}: POSIX pages have no line numbers", "use posix-2024§<page>#<anchor>¶<n>")
135
+ if parsed.doc is not None and parsed.doc != edition.id:
136
+ raise LibError(f"{parsed.doc!r} is not imported; this reads {edition.id}", "")
137
+ if parsed.section is None:
138
+ raise LibError(f"{ref!r} names no page", "for example posix-2024§functions/open")
139
+ loaded = load_page(parsed.section, edition=edition, home=home)
140
+ section: Section | None = None
141
+ units: tuple[str, ...] = tuple(loaded.converted.blocks)
142
+ if parsed.anchor is not None:
143
+ section = loaded.section_for(parsed.anchor)
144
+ if section is None:
145
+ anchors = [s.id for s in loaded.document.sections if s.id]
146
+ near = difflib.get_close_matches(parsed.anchor, anchors, n=3)
147
+ hint = ("did you mean " + ", ".join(near)) if near else f"posix_outline {parsed.section} lists them"
148
+ raise LibError(f"no anchor {parsed.anchor!r} on {parsed.section}", hint)
149
+ units = section.units
150
+ if parsed.para is not None and parsed.para > len(units):
151
+ raise LibError(f"{ref!r}: there are {len(units)} blocks here", f"use ¶1 to ¶{len(units)}" if units else "")
152
+ result = budget_page(units, parsed.para or 1, max_tokens)
153
+ body = "\n\n".join(result.units)
154
+ footer = {
155
+ "cite": cite(loaded, section, cursor.para_label(parsed.para, result)),
156
+ "unit": POSIX.unit,
157
+ "span": span_hash(body) if body else "",
158
+ "next": cursor.read_next(_ref_text(edition.id, parsed.section, parsed.anchor), result),
159
+ "warning": "one block is larger than max_tokens" if result.oversize else "",
160
+ }
161
+ return render(body or "(this section has no blocks of its own)", footer)
162
+
163
+
164
+ def _plain(block: str) -> str:
165
+ return re.sub(r"\s+", " ", _html.to_plain(block).replace("*", "")).strip()
166
+
167
+
168
+ def grep(
169
+ needle: str,
170
+ *,
171
+ volume: str | None = None,
172
+ limit: int = 20,
173
+ start: int = 0,
174
+ seconds: float = 10.0,
175
+ edition: Edition = POSIX_2024,
176
+ home: Path | None = None,
177
+ ) -> str:
178
+ """Pages whose converted text contains `needle`, case-insensitively, one line per matching block.
179
+
180
+ Bounded three ways: at most `limit` hits (or every hit of the first page, if it alone has more), at most
181
+ `seconds` of scanning, and a needle of at most 200 characters. A literal, not a pattern, so no input can make
182
+ the scan super-linear. Pages are taken whole, so no hit is skipped: when a bound stops the scan,
183
+ `next: start=<n>` is the 0-based index of the page to resume from, in the order `pages` lists them."""
184
+ wanted = needle.strip().casefold()
185
+ if not wanted or len(wanted) > MAX_NEEDLE:
186
+ raise LibError("grep needs between 1 and 200 characters of text", "")
187
+ limit = max(1, min(limit, MAX_HITS))
188
+ if start < 0:
189
+ raise LibError(f"start is a 0-based page offset, not {start}", "pass the start= value of the last next: line")
190
+ listing = pages(volume, edition=edition, home=home)
191
+ root = installed(edition, home)
192
+ deadline = time.monotonic() + seconds
193
+ hits: list[str] = []
194
+ resume: int | None = None
195
+ for index in range(start, len(listing)):
196
+ if len(hits) >= limit or time.monotonic() > deadline:
197
+ resume = index
198
+ break
199
+ page = listing[index]
200
+ raw = root.joinpath(*page.split("/")).with_name(page.split("/")[-1] + ".html").read_bytes()
201
+ rough = html.unescape(_TAGS.sub("", raw.decode("utf-8", errors="replace")))
202
+ if wanted not in re.sub(r"\s+", " ", rough).casefold():
203
+ continue
204
+ found = _page_hits(parse_page(raw, page, edition), wanted, edition.id)
205
+ if hits and len(hits) + len(found) > limit:
206
+ resume = index
207
+ break
208
+ hits.extend(found)
209
+ body = "\n".join(hits) if hits else f"no block contains {needle!r}"
210
+ footer = {
211
+ "unit": POSIX.unit,
212
+ "next": next_start(resume),
213
+ "warning": f"stopped before page {resume + 1} of {len(listing)}" if resume is not None else "",
214
+ }
215
+ return render(body, footer)
216
+
217
+
218
+ def _page_hits(loaded: Page, wanted: str, edition_id: str) -> list[str]:
219
+ heads = [h for h in loaded.converted.headings if h.kind == "heading"]
220
+ found: list[str] = []
221
+ for index, block in enumerate(loaded.converted.blocks):
222
+ text = _plain(block)
223
+ if wanted not in text.casefold():
224
+ continue
225
+ owner = next((h for h in reversed(heads) if h.block < index), None)
226
+ if owner is None or not owner.anchor:
227
+ ref = _ref_text(edition_id, loaded.page, None, index + 1)
228
+ else:
229
+ ref = _ref_text(edition_id, loaded.page, owner.anchor, index - owner.block)
230
+ at = text.casefold().find(wanted)
231
+ snippet = text[max(0, at - 60) : at + len(wanted) + 60]
232
+ found.append(f"{ref}: {snippet}")
233
+ return found
@@ -0,0 +1,79 @@
1
+ """MCP servers for the posix and ieee802 collections: `primarysource mcp --collection posix` (or `ieee802`).
2
+
3
+ Imported only when served, so `mcp` (the `[mcp]` extra) is needed only then. Tools carry the collection's prefix
4
+ (`posix_read`, `ieee802_outline`), answer in text with the fixed footer, and turn a `LibError` into an MCP tool
5
+ error (`isError`) whose text names the remedy. Every tool reads local files only; none writes or fetches.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from collections.abc import Callable
11
+ from typing import Any
12
+
13
+ from mcp.server.mcpserver import MCPServer
14
+ from mcp.types import ToolAnnotations
15
+
16
+ from primarysource import __version__
17
+ from primarysource.ieee import ieee802, posix
18
+ from primarysource.scaffold import add_tools
19
+
20
+ READ_ONLY = ToolAnnotations(read_only_hint=True, idempotent_hint=True, destructive_hint=False, open_world_hint=False)
21
+
22
+ POSIX_INSTRUCTIONS = (
23
+ "POSIX: IEEE Std 1003.1-2024, the same text as The Open Group Base Specifications Issue 8, from the user's "
24
+ "own imported copy of the HTML edition. Pages are addressed by path: functions/open (XSH), utilities/pax "
25
+ "(XCU), basedefs/stdio.h and basedefs/V1_chap03 (XBD), xrat/... (rationale). Loop: posix_grep('<words>') or "
26
+ "posix_outline('<page>') -> posix_read('<ref>'). Refs: 'posix-2024§functions/open' (page), "
27
+ "'posix-2024§functions/open#tag_17_372_03' (a heading's anchor), '...¶2' (the 2nd block under it). The "
28
+ "cite: footer line is the citation. If the edition is not imported, the error says how."
29
+ )
30
+ IEEE802_INSTRUCTIONS = (
31
+ "IEEE 802 standards from PDFs the user obtained through the IEEE GET Program and imported. "
32
+ "ieee802_outline('802.1Q-2022') lists clauses as refs ('802.1Q-2022§5.4') with their PDF pages. Reading "
33
+ "clause text is not built yet."
34
+ )
35
+
36
+
37
+ def posix_read(ref: str, max_tokens: int = 2000) -> str:
38
+ """Read POSIX text by ref, cut at block boundaries within max_tokens. A 'next: <ref>' footer line is the ref
39
+ that reads the next page: pass it back as ref.
40
+
41
+ ref: 'posix-2024§functions/open' (whole page) | 'posix-2024§functions/open#tag_17_372_03' (one heading's
42
+ section, subsections included) | '...#tag_17_372_03¶2' (from its 2nd block). 'functions/open' alone also
43
+ works."""
44
+ return posix.read(ref, max_tokens=max_tokens)
45
+
46
+
47
+ def posix_outline(page: str) -> str:
48
+ """List a POSIX page's headings, each with the ref that reads it. page: 'functions/open', 'utilities/pax',
49
+ 'basedefs/V1_chap03'."""
50
+ return posix.outline(page)
51
+
52
+
53
+ def posix_grep(text: str, volume: str | None = None, limit: int = 20, start: int = 0) -> str:
54
+ """Find blocks containing text (case-insensitive, literal, at most 200 characters). One line per hit:
55
+ '<ref>: <snippet>'. volume: functions | utilities | basedefs | xrat. A 'next: start=N' footer line means call
56
+ again with start=N (a 0-based page offset)."""
57
+ return posix.grep(text, volume=volume, limit=limit, start=start)
58
+
59
+
60
+ def posix_pages(volume: str | None = None) -> str:
61
+ """List page ids, optionally of one volume: functions (XSH), utilities (XCU), basedefs (XBD), xrat."""
62
+ return "\n".join(posix.pages(volume)) or "no pages"
63
+
64
+
65
+ def ieee802_outline(designation: str) -> str:
66
+ """List an imported 802 standard's clauses from its PDF bookmarks. designation: '802.1Q-2022'."""
67
+ return ieee802.outline(designation)
68
+
69
+
70
+ def _server(name: str, instructions: str, tools: list[Callable[..., str]]) -> Any:
71
+ server = MCPServer(name, version=__version__, instructions=instructions)
72
+ add_tools(server, tools, annotations=READ_ONLY)
73
+ return server
74
+
75
+
76
+ posix_server = _server(
77
+ "primarysource-posix", POSIX_INSTRUCTIONS, [posix_read, posix_outline, posix_grep, posix_pages]
78
+ )
79
+ ieee802_server = _server("primarysource-ieee802", IEEE802_INSTRUCTIONS, [ieee802_outline])