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,240 @@
1
+ """Read, outline, search and cite imported Ecma standards. Local files only; nothing here fetches.
2
+
3
+ A ref is `ecma:427§5.3¶2`: the standard's number (`427`, or `ECMA-427`), an optional `@<edition number>`, `§` and
4
+ a clause number as the PDF's bookmarks give it (`5.3`, `A` for Annex A, `A.1`), or the slug of an unnumbered
5
+ bookmark (`bibliography`), and `¶n`, the nth sentence of that clause's span. The span is the clause's own text,
6
+ then each subsection as a `§5.3.1 Title` line followed by its text. Sentences, not paragraphs, because a PDF
7
+ gives sentences. A ref with no section is the whole standard.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import re
14
+ from dataclasses import dataclass
15
+ from pathlib import Path
16
+
17
+ from primarysource import budget, cursor
18
+ from primarysource.c2sp._fetch import sha256
19
+ from primarysource.ecma import _pdf
20
+ from primarysource.ecma.catalogue import NUMBER
21
+ from primarysource.ecma.catalogue import catalogue as packaged_catalogue
22
+ from primarysource.ecma.importer import read_record
23
+ from primarysource.errors import LibError
24
+ from primarysource.home import collection_home
25
+ from primarysource.model import span_hash
26
+ from primarysource.refs import Ref, RefGrammar
27
+ from primarysource.render import render
28
+
29
+ PREFIX = re.compile(r"^(?:ecma:)?(?:ecma-)?", re.IGNORECASE)
30
+
31
+
32
+ def grammar() -> RefGrammar:
33
+ from primarysource.ecma import ECMA_DOC, ECMA_SECTION
34
+
35
+ return RefGrammar(ECMA_DOC, ECMA_SECTION)
36
+
37
+
38
+ @dataclass(frozen=True, slots=True)
39
+ class Loaded:
40
+ number: str
41
+ edition: int
42
+ designation: str
43
+ edition_name: str
44
+ title: str
45
+ page_url: str
46
+ document: _pdf.PdfDocument
47
+
48
+ @property
49
+ def label(self) -> str:
50
+ return f"{self.number}@{self.edition}"
51
+
52
+
53
+ def default_home() -> Path:
54
+ return collection_home("ecma")
55
+
56
+
57
+ def parse_ref(text: str) -> Ref:
58
+ """Parse an ecma ref, accepting the `ecma:` prefix and `ECMA-427` for `427`."""
59
+ body, doc_para = cursor.split_doc_para(PREFIX.sub("", text.strip(), count=1))
60
+ ref = grammar().parse(body)
61
+ if doc_para is not None:
62
+ ref = Ref(ref.doc, None, doc_para, None, None)
63
+ if ref.lines is not None or ref.anchor is not None:
64
+ raise LibError(f"not an ecma ref: {text!r}", "ecma refs are <number>[@edition][§clause][¶n]")
65
+ if ref.doc is None:
66
+ raise LibError(f"not an ecma ref: {text!r}", "start with the standard's number, e.g. 427")
67
+ return ref
68
+
69
+
70
+ def _split_doc(token: str) -> tuple[str, int | None]:
71
+ number, _, edition = token.partition("@")
72
+ if not NUMBER.fullmatch(number) or (edition and not edition.isdigit()):
73
+ raise LibError(f"not an Ecma standard: {token!r}", "use the number, e.g. 427, or 427@1")
74
+ return number, int(edition) if edition else None
75
+
76
+
77
+ def load(token: str, home: Path | None = None) -> Loaded:
78
+ """Load `number[@edition]` (the highest imported edition if none is named), verifying the stored PDF."""
79
+ home = home or default_home()
80
+ number, wanted = _split_doc(token)
81
+ record = read_record(home)
82
+ candidates: list[tuple[int, str]] = []
83
+ for key in record["editions"]:
84
+ match = re.fullmatch(r"(\d{1,4})-(\d{1,2})", key)
85
+ if match and match.group(1) == number and (wanted is None or int(match.group(2)) == wanted):
86
+ candidates.append((int(match.group(2)), key))
87
+ if not candidates:
88
+ have = ", ".join(sorted(record["editions"])) or "nothing"
89
+ raise LibError(
90
+ f"ECMA-{number}{'@' + str(wanted) if wanted else ''} is not imported (have: {have})",
91
+ "ecma_docs lists the catalogue",
92
+ f"primarysource ecma import {number}",
93
+ )
94
+ edition, key = max(candidates)
95
+ pin = record["editions"][key].get("sha256")
96
+ pdf = home / f"{key}.pdf"
97
+ stored = home / f"{key}.json"
98
+ try:
99
+ if not isinstance(pin, str) or sha256(pdf.read_bytes()) != pin:
100
+ raise LibError(
101
+ f"{key}.pdf does not match its recorded sha256",
102
+ "",
103
+ f"re-import it: primarysource ecma import {number}",
104
+ )
105
+ raw = json.loads(stored.read_text(encoding="utf-8"))
106
+ except (OSError, ValueError) as exc:
107
+ raise LibError(
108
+ f"cannot read the import of ECMA-{number}: {exc}", "", f"re-import it: primarysource ecma import {number}"
109
+ ) from exc
110
+ document = _pdf.PdfDocument.from_json(raw)
111
+ return Loaded(
112
+ number,
113
+ edition,
114
+ str(raw.get("designation", f"ECMA-{number}")),
115
+ str(raw.get("edition", f"edition {edition}")),
116
+ document.title or str(raw.get("designation", "")),
117
+ str(raw.get("page_url", "")),
118
+ document,
119
+ )
120
+
121
+
122
+ def _cite_text(doc: Loaded, section: _pdf.PdfSection | None, para: int | str | None) -> str:
123
+ where = f", §{section.id} {section.title}" if section else ""
124
+ if para:
125
+ where += f" ¶{para}"
126
+ return f"Ecma International, {doc.designation}, {doc.title}, {doc.edition_name}{where}. {doc.page_url}"
127
+
128
+
129
+ def _doc(ref: Ref) -> str:
130
+ """The document of a parsed ref; `parse_ref` has already refused a ref without one."""
131
+ if ref.doc is None:
132
+ raise LibError("the ref names no document", "start with the document id")
133
+ return ref.doc
134
+
135
+
136
+ def _select(ref: Ref, home: Path | None) -> tuple[Loaded, _pdf.PdfSection | None, tuple[str, ...], str]:
137
+ doc = load(_doc(ref), home)
138
+ if ref.section is None:
139
+ units: list[str] = []
140
+ for section in doc.document.sections:
141
+ units.append(f"§{section.id} {section.title}")
142
+ units.extend(section.sentences)
143
+ return doc, None, tuple(units), ""
144
+ index, warning = doc.document.find(ref.section)
145
+ return doc, doc.document.sections[index], doc.document.span(index), warning
146
+
147
+
148
+ def read(ref_text: str, max_tokens: int = 2000, home: Path | None = None) -> str:
149
+ """The text of a ref, cut at sentence boundaries within `max_tokens`. A `¶n` ref starts at sentence n and runs to
150
+ the end of its scope; the `next:` footer is the ref that reads the next page."""
151
+ ref = parse_ref(ref_text)
152
+ doc, section, units, warning = _select(ref, home)
153
+ if not units:
154
+ raise LibError(f"{ref_text}: that clause has no text of its own", "read its subsections, see ecma_outline")
155
+ first = cursor.first_unit(ref.para, len(units), "sentences", ref_text)
156
+ chosen = budget.page(units, start=first, max_tokens=max_tokens)
157
+ body = "\n".join(chosen.units)
158
+ scope = f"ecma:{doc.number}@{doc.edition}" + (f"§{section.id}" if section else "")
159
+ footer = {
160
+ "cite": _cite_text(doc, section, cursor.para_label(ref.para, chosen)),
161
+ "unit": "sentence",
162
+ "span": span_hash(body),
163
+ "next": cursor.read_next(scope, chosen),
164
+ "warning": warning,
165
+ }
166
+ return render(body, footer)
167
+
168
+
169
+ def outline(token: str, home: Path | None = None) -> str:
170
+ """A standard's bookmarked clauses, indented by depth, each with the ref that reads it and its PDF page."""
171
+ doc = load(token, home)
172
+ lines = [f"{doc.designation} {doc.title}, {doc.edition_name} [{doc.label}, {doc.document.pages} pages]"]
173
+ for section in doc.document.sections:
174
+ indent = " " * section.depth
175
+ lines.append(
176
+ f"{indent}{section.id} {section.title} ecma:{doc.number}@{doc.edition}§{section.id} (p. {section.page})"
177
+ )
178
+ return "\n".join(lines)
179
+
180
+
181
+ def search(query: str, doc: str | None = None, limit: int = 10, start: int = 0, home: Path | None = None) -> str:
182
+ """Sentences containing every query word (else at least half, flagged `relaxed`), best first: one line per
183
+ hit, `<ref>¶n: <snippet>`."""
184
+ terms = _pdf.query_terms(query)
185
+ home = home or default_home()
186
+ record = read_record(home)
187
+ keys = sorted(record["editions"])
188
+ if not keys:
189
+ raise LibError(
190
+ "no Ecma standards are imported", "ecma_docs lists the catalogue", "primarysource ecma import 427"
191
+ )
192
+ tokens = [doc] if doc else [k.replace("-", "@") for k in keys]
193
+ scored: list[tuple[float, int, str, str]] = []
194
+ relaxed = False
195
+ for need_all in (True, False):
196
+ for order, token in enumerate(tokens):
197
+ loaded = load(token, home)
198
+ for section in loaded.document.sections:
199
+ for score, number in _pdf.score_units(section.sentences, terms, section.title, need_all):
200
+ ref = f"ecma:{loaded.number}@{loaded.edition}§{section.id}¶{number}"
201
+ scored.append((-score, order, ref, _pdf.snippet(section.sentences[number - 1], terms)))
202
+ if scored:
203
+ relaxed = not need_all
204
+ break
205
+ if not scored:
206
+ return render("no matches", {})
207
+ scored.sort()
208
+ shown, nxt = cursor.window(scored, start, limit)
209
+ footer = {"next": nxt, "relaxed": "only some query words matched" if relaxed else ""}
210
+ return render("\n".join(f"{ref}: {snip}" for _, _, ref, snip in shown), footer)
211
+
212
+
213
+ def cite(ref_text: str, home: Path | None = None) -> str:
214
+ """The citation string for a ref, with the span hash of the exact text cited."""
215
+ ref = parse_ref(ref_text)
216
+ doc, section, units, warning = _select(ref, home)
217
+ if ref.para is not None:
218
+ if ref.para > len(units):
219
+ raise LibError(f"{ref_text}: the span has {len(units)} sentences", f"use ¶1 to ¶{len(units)}")
220
+ units = (units[ref.para - 1],)
221
+ footer = {
222
+ "cite": _cite_text(doc, section, ref.para),
223
+ "unit": "sentence",
224
+ "span": span_hash("\n".join(units)),
225
+ "warning": warning,
226
+ }
227
+ label = f"ecma:{doc.number}@{doc.edition}" + (f"§{section.id}" if section else "")
228
+ return render(label, footer)
229
+
230
+
231
+ def docs(home: Path | None = None) -> str:
232
+ """The catalogue and which editions are imported."""
233
+ home = home or default_home()
234
+ cat = packaged_catalogue()
235
+ record = read_record(home)
236
+ lines = [f"placement {cat.terms.placement}; {cat.terms.licence}"]
237
+ for found in cat.editions:
238
+ state = "imported" if found.key in record["editions"] else "not imported"
239
+ lines.append(f"{state:<13} {found.designation}@{found.edition} {found.title}, {found.edition_name}")
240
+ return "\n".join(lines)
@@ -0,0 +1,67 @@
1
+ """MCP server for the ecma collection: `primarysource mcp --collection ecma`.
2
+
3
+ Imported only when served, so `mcp` (the `[mcp]` extra) is needed only then. Tools carry the `ecma_` prefix,
4
+ answer in text with the fixed footer, and turn a `LibError` into an MCP tool error naming the remedy. Every tool
5
+ reads local files only; none writes or fetches.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Any
11
+
12
+ from mcp.server.mcpserver import MCPServer
13
+ from mcp.types import ToolAnnotations
14
+
15
+ from primarysource import __version__
16
+ from primarysource.ecma import library
17
+ from primarysource.scaffold import add_tools
18
+
19
+ READ_ONLY = ToolAnnotations(read_only_hint=True, idempotent_hint=True, destructive_hint=False, open_world_hint=False)
20
+
21
+ INSTRUCTIONS = (
22
+ "Ecma International standards from the user's own imported PDFs, starting with ECMA-427 (Package-URL / purl). "
23
+ "Loop: ecma_search('<words>') -> ecma_read('<ref>'). Refs: 'ecma:427§5.3' (a clause as numbered in the PDF), "
24
+ "'...¶2' (its 2nd sentence), '427@1' pins the edition, 'A' is Annex A. ecma_outline('427') lists clauses as "
25
+ "refs with PDF pages; ecma_cite('<ref>') gives the citation and the hash of the cited text. A clause's span is "
26
+ "its own text and then each subsection. If nothing is imported, the error says how."
27
+ )
28
+
29
+
30
+ def ecma_read(ref: str, max_tokens: int = 2000) -> str:
31
+ """Read Ecma text by ref, cut at sentence boundaries within max_tokens. A 'next: <ref>' footer line is the ref
32
+ that reads the next page: pass it back as ref.
33
+
34
+ ref: 'ecma:427' (whole standard) | 'ecma:427§5.3' (a clause and its subsections) | 'ecma:427§5.3¶2' (from its
35
+ 2nd sentence) | '427@1§A' (edition 1, Annex A). ecma_cite gives exactly one sentence."""
36
+ return library.read(ref, max_tokens=max_tokens)
37
+
38
+
39
+ def ecma_search(query: str, doc: str | None = None, limit: int = 10, start: int = 0) -> str:
40
+ """Find sentences containing the query words (all of them, else at least half, flagged 'relaxed'). One line
41
+ per hit: '<ref>¶n: <snippet>'. doc restricts to one standard, e.g. '427'. A 'next: start=N' footer line means
42
+ call again with start=N (a 0-based offset)."""
43
+ return library.search(query, doc=doc, limit=limit, start=start)
44
+
45
+
46
+ def ecma_outline(doc: str) -> str:
47
+ """List a standard's clauses, indented by depth, each with the ref that reads it and its PDF page."""
48
+ return library.outline(doc)
49
+
50
+
51
+ def ecma_cite(ref: str) -> str:
52
+ """The citation string for a ref, with the sha256 prefix of the exact text cited (cite: and span: lines)."""
53
+ return library.cite(ref)
54
+
55
+
56
+ def ecma_docs() -> str:
57
+ """List the catalogued Ecma standards and whether each is imported."""
58
+ return library.docs()
59
+
60
+
61
+ def _build() -> Any:
62
+ built = MCPServer("primarysource-ecma", version=__version__, instructions=INSTRUCTIONS)
63
+ add_tools(built, (ecma_read, ecma_search, ecma_outline, ecma_cite, ecma_docs), annotations=READ_ONLY)
64
+ return built
65
+
66
+
67
+ server = _build()
@@ -0,0 +1,29 @@
1
+ """An error that names its remedy.
2
+
3
+ Every library in the family has the same rule: a missing corpus or index is an error that says what to run,
4
+ never an empty result. An empty list has to mean "nothing matched".
5
+
6
+ The text of an error is fixed lines in a fixed order (PLAN.md §5.10), the same in a CLI and in an MCP `isError`
7
+ result: `error: <message>`, then `hint: <what to call next>`, then `action: <command a human must run>`. An empty
8
+ field is left out.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ ERROR_LINES = ("error", "hint", "action")
14
+
15
+
16
+ class LibError(Exception):
17
+ """A failure the caller can act on. `hint` is the tool call or command that fixes it, or empty; `action` is a
18
+ command only a human can run (a fetch, an import, an install), or empty."""
19
+
20
+ def __init__(self, message: str, hint: str = "", action: str = "") -> None:
21
+ super().__init__(message)
22
+ self.message = message
23
+ self.hint = hint
24
+ self.action = action
25
+
26
+ def as_text(self) -> str:
27
+ """The `error:`, `hint:` and `action:` lines, in that order, without the empty ones."""
28
+ fields = {"error": self.message, "hint": self.hint, "action": self.action}
29
+ return "\n".join(f"{key}: {fields[key]}" for key in ERROR_LINES if fields[key])