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,143 @@
1
+ """The bbf and ecma collections' PDF model: stored extractions, section lookup and sentence search.
2
+
3
+ Reading a PDF moved to the core: `extract` is `primarysource.readers.pdf.extract` (`cl.reader_pdf_pypdf`) and the
4
+ cutting into sections is `primarysource.readers.bookmarks` (`cl.segment_bookmarks`). They are re-exported here so
5
+ the importers, the libraries and their tests are unchanged. The import runs once and its result is stored as
6
+ JSON beside the PDF, so serving reads `PdfDocument.from_json` and never needs `pypdf`. A proposal for the core
7
+ version of the rest is in `docs/ecma.md`.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import re
13
+ import shutil # noqa: F401 - tests patch `_pdf.shutil.which` to keep pdftotext out
14
+ from collections.abc import Sequence
15
+
16
+ from primarysource.errors import LibError
17
+ from primarysource.readers.bookmarks import cut as _cut
18
+ from primarysource.readers.bookmarks import heading_id as _heading_id
19
+ from primarysource.readers.bookmarks import slug, split_sentences
20
+ from primarysource.readers.pdf import INSTALL_HINT, MAX_BYTES, MAX_OUTLINE, MAX_PAGES, extract
21
+
22
+ __all__ = [
23
+ "INSTALL_HINT",
24
+ "MAX_BYTES",
25
+ "MAX_OUTLINE",
26
+ "MAX_PAGES",
27
+ "MAX_QUERY_TERMS",
28
+ "PdfDocument",
29
+ "PdfSection",
30
+ "_cut",
31
+ "_heading_id",
32
+ "extract",
33
+ "query_terms",
34
+ "score_units",
35
+ "slug",
36
+ "snippet",
37
+ "split_sentences",
38
+ ]
39
+
40
+
41
+ class PdfSection:
42
+ """One bookmarked section of an extracted PDF: `sentences` are its own text, subsections excluded."""
43
+
44
+ __slots__ = ("depth", "id", "page", "sentences", "title")
45
+
46
+ def __init__(self, ident: str, title: str, depth: int, page: int, sentences: tuple[str, ...]) -> None:
47
+ self.id, self.title, self.depth, self.page, self.sentences = ident, title, depth, page, sentences
48
+
49
+
50
+ class PdfDocument:
51
+ """An extracted PDF as stored on disk: see `extract` for the fields."""
52
+
53
+ def __init__(self, title: str, pages: int, extractor: str, sections: tuple[PdfSection, ...]) -> None:
54
+ self.title, self.pages, self.extractor, self.sections = title, pages, extractor, sections
55
+
56
+ @classmethod
57
+ def from_json(cls, raw: object) -> PdfDocument:
58
+ """Validate a stored extraction. A malformed one is a `LibError`: the install is damaged or hostile."""
59
+ try:
60
+ if not isinstance(raw, dict):
61
+ raise TypeError("not an object")
62
+ sections = tuple(
63
+ PdfSection(
64
+ str(entry["id"]),
65
+ str(entry["title"]),
66
+ int(entry["depth"]),
67
+ int(entry["page"]),
68
+ tuple(line for line in str(entry["text"]).split("\n") if line),
69
+ )
70
+ for entry in raw["sections"]
71
+ )
72
+ return cls(str(raw.get("title", "")), int(raw["pages"]), str(raw.get("extractor", "")), sections)
73
+ except (AssertionError, KeyError, TypeError, ValueError) as exc:
74
+ raise LibError("the stored extraction is malformed", "", "re-import the document") from exc
75
+
76
+ def find(self, wanted: str) -> tuple[int, str]:
77
+ """Index of the section `wanted` names (its id, else its title's slug, else its title ignoring case),
78
+ and a warning ("" if none). Raises `LibError` with close matches when nothing fits."""
79
+ text = wanted.strip()
80
+ for match in (
81
+ [i for i, s in enumerate(self.sections) if s.id == text],
82
+ [i for i, s in enumerate(self.sections) if s.id.lower() == text.lower()],
83
+ [i for i, s in enumerate(self.sections) if slug(s.title) == slug(text)],
84
+ ):
85
+ if match:
86
+ warning = ""
87
+ if len(match) > 1:
88
+ warning = f"{len(match)} sections match {text!r}; this is the first"
89
+ return match[0], warning
90
+ import difflib
91
+
92
+ close = difflib.get_close_matches(text, [s.id for s in self.sections], n=3, cutoff=0.6)
93
+ raise LibError(f"no section {text!r}", f"did you mean: {', '.join(close)}" if close else "see the outline")
94
+
95
+ def span(self, index: int) -> tuple[str, ...]:
96
+ """The section's sentences, then each subsection as a heading unit (`§5.3.1 Title`) followed by its own
97
+ sentences, down to the next section at the same or a shallower depth."""
98
+ root = self.sections[index]
99
+ units = list(root.sentences)
100
+ for sub in self.sections[index + 1 :]:
101
+ if sub.depth <= root.depth:
102
+ break
103
+ units.append(f"§{sub.id} {sub.title}")
104
+ units.extend(sub.sentences)
105
+ return tuple(units)
106
+
107
+
108
+ _TERM = re.compile(r"\w+", re.UNICODE)
109
+ MAX_QUERY_TERMS = 16
110
+
111
+
112
+ def query_terms(query: str) -> list[str]:
113
+ """The words of a search query, case-folded, at most `MAX_QUERY_TERMS` of at most 64 characters each."""
114
+ terms = [t for t in _TERM.findall(query.casefold()) if len(t) <= 64]
115
+ if not terms:
116
+ raise LibError("the query has no searchable words", "use words or identifiers, e.g. 'Inform retry'")
117
+ return terms[:MAX_QUERY_TERMS]
118
+
119
+
120
+ def snippet(text: str, terms: list[str], width: int = 160) -> str:
121
+ """About `width` characters of `text` around the first query word."""
122
+ flat = " ".join(text.split())
123
+ low = flat.casefold()
124
+ at = min((i for i in (low.find(t) for t in terms) if i >= 0), default=0)
125
+ begin = max(0, at - 60)
126
+ return ("..." if begin else "") + flat[begin : begin + width] + ("..." if begin + width < len(flat) else "")
127
+
128
+
129
+ def score_units(units: Sequence[str], terms: list[str], title: str, need_all: bool) -> list[tuple[float, int]]:
130
+ """(score, 1-based unit number) for each unit containing every term (or, if not `need_all`, at least half).
131
+ Score: term frequency, plus 3 per distinct term present, plus 2 per term in the section title."""
132
+ found: list[tuple[float, int]] = []
133
+ low_title = title.casefold()
134
+ for number, unit in enumerate(units, start=1):
135
+ if unit.startswith("§"):
136
+ continue # a subsection heading unit is reached through its own section
137
+ words = _TERM.findall(unit.casefold())
138
+ counts = [words.count(t) for t in terms]
139
+ present = sum(1 for c in counts if c)
140
+ if present == 0 or present * 2 < len(terms) or (need_all and present < len(terms)):
141
+ continue
142
+ found.append((sum(counts) + 3 * present + sum(2 for t in terms if t in low_title), number))
143
+ return found
@@ -0,0 +1,22 @@
1
+ {
2
+ "terms": {
3
+ "licence": "Ecma International text copyright policy (default notice)",
4
+ "placement": "volume",
5
+ "terms_url": "https://ecma-international.org/policies/by-ipr/ecma-text-copyright-policy/",
6
+ "terms_read": "2026-11-12",
7
+ "summary": "The notice printed in ECMA-427 allows copying and distribution of the whole or parts with the copyright notice and licence on every copy, and derivative works only as commentary, accessibility, translation or format change, or implementation; the content may not otherwise be modified. Ecma prefers that the standard is downloaded from its own site. This collection therefore stays on a user-fetched volume: the importer downloads the PDF from ecma-international.org and writes only to the user's own home. Software in the standard is BSD-licensed (the Software License section of the PDF)."
8
+ },
9
+ "editions": [
10
+ {
11
+ "number": "427",
12
+ "edition": 1,
13
+ "designation": "ECMA-427",
14
+ "title": "Package-URL (PURL) specification",
15
+ "edition_name": "1st edition, December 2025",
16
+ "page_url": "https://ecma-international.org/publications-and-standards/standards/ecma-427/",
17
+ "source_url": "https://ecma-international.org/wp-content/uploads/ECMA-427_1st_edition_december_2025.pdf",
18
+ "size": 2320437,
19
+ "sha256": "037180df99e7d7c26d910dc3adb3fa07c4fb787cc4bd29e43634de3802313825"
20
+ }
21
+ ]
22
+ }
@@ -0,0 +1,100 @@
1
+ """The Ecma catalogue: which standards and editions, where to fetch them, and the sha256 of each. Metadata only.
2
+
3
+ `catalogue.json` is package data. Starting with ECMA-427 (Package-URL, 1st edition, December 2025). An edition is
4
+ identified by the standard's number and its edition number; a ref names the number (`ecma:427`) and optionally the
5
+ edition (`ecma:427@1`); with no edition the highest imported one is read.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import re
12
+ from dataclasses import dataclass
13
+ from importlib import resources
14
+ from typing import Any
15
+
16
+ from primarysource.errors import LibError
17
+
18
+ NUMBER = re.compile(r"[0-9]{1,4}")
19
+ SHA256 = re.compile(r"[0-9a-f]{64}")
20
+ HOSTS = ("ecma-international.org", "www.ecma-international.org")
21
+
22
+
23
+ @dataclass(frozen=True, slots=True)
24
+ class Terms:
25
+ licence: str
26
+ placement: str
27
+ terms_url: str
28
+ terms_read: str
29
+ summary: str
30
+
31
+
32
+ @dataclass(frozen=True, slots=True)
33
+ class Edition:
34
+ """One edition of one Ecma standard, with its pinned PDF."""
35
+
36
+ number: str
37
+ edition: int
38
+ designation: str
39
+ title: str
40
+ edition_name: str
41
+ page_url: str
42
+ source_url: str
43
+ size: int
44
+ sha256: str
45
+
46
+ @property
47
+ def key(self) -> str:
48
+ """The file stem under the install directory: `427-1`."""
49
+ return f"{self.number}-{self.edition}"
50
+
51
+
52
+ @dataclass(frozen=True, slots=True)
53
+ class Catalogue:
54
+ terms: Terms
55
+ editions: tuple[Edition, ...]
56
+
57
+ def edition(self, number: str, edition: int | None = None) -> Edition:
58
+ """The catalogued edition of `number` (the highest if `edition` is None)."""
59
+ have = [e for e in self.editions if e.number == number and (edition is None or e.edition == edition)]
60
+ if not have:
61
+ known = ", ".join(f"{e.number}@{e.edition}" for e in self.editions)
62
+ raise LibError(
63
+ f"no Ecma standard {number}{'@' + str(edition) if edition else ''} in the catalogue",
64
+ f"known: {known}",
65
+ )
66
+ return max(have, key=lambda e: e.edition)
67
+
68
+
69
+ def parse_catalogue(raw: dict[str, Any]) -> Catalogue:
70
+ """Validate a catalogue document. Raises `ValueError`: a malformed catalogue is a packaging bug."""
71
+ terms = raw["terms"]
72
+ if terms["placement"] not in ("layer", "volume"):
73
+ raise ValueError("terms.placement must be 'layer' or 'volume'")
74
+ editions: list[Edition] = []
75
+ for entry in raw["editions"]:
76
+ found = Edition(
77
+ str(entry["number"]),
78
+ int(entry["edition"]),
79
+ entry["designation"],
80
+ entry["title"],
81
+ entry["edition_name"],
82
+ entry["page_url"],
83
+ entry["source_url"],
84
+ int(entry["size"]),
85
+ entry["sha256"],
86
+ )
87
+ if not NUMBER.fullmatch(found.number) or not SHA256.fullmatch(found.sha256) or found.edition < 1:
88
+ raise ValueError(f"catalogue entry {found.designation!r} is malformed")
89
+ editions.append(found)
90
+ if len({e.key for e in editions}) != len(editions):
91
+ raise ValueError("duplicate editions in the catalogue")
92
+ return Catalogue(
93
+ Terms(terms["licence"], terms["placement"], terms["terms_url"], terms["terms_read"], terms["summary"]),
94
+ tuple(editions),
95
+ )
96
+
97
+
98
+ def catalogue() -> Catalogue:
99
+ text = resources.files("primarysource.ecma").joinpath("catalogue.json").read_text(encoding="utf-8")
100
+ return parse_catalogue(json.loads(text))
@@ -0,0 +1,67 @@
1
+ """`primarysource ecma ...`: the collection's own commands.
2
+
3
+ primarysource ecma import [NUMBER] [--edition N] [--file PDF]
4
+ primarysource ecma docs
5
+ primarysource ecma outline <doc>
6
+ primarysource ecma read <ref> [--max-tokens N]
7
+ primarysource ecma search <query> [--doc NUMBER] [--limit N] [--start N]
8
+ primarysource ecma cite <ref>
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import argparse
14
+ import sys
15
+ from collections.abc import Sequence
16
+ from pathlib import Path
17
+
18
+ from primarysource.ecma import importer, library
19
+ from primarysource.errors import LibError
20
+
21
+
22
+ def _parser() -> argparse.ArgumentParser:
23
+ parser = argparse.ArgumentParser(prog="primarysource ecma", description="Ecma International standards")
24
+ commands = parser.add_subparsers(dest="command", required=True)
25
+ imp = commands.add_parser("import", help="fetch (or read a file), verify and index one standard")
26
+ imp.add_argument("number", nargs="?", default="427", help="the standard's number (default 427)")
27
+ imp.add_argument("--edition", type=int, help="edition number (default: the newest catalogued)")
28
+ imp.add_argument("--file", type=Path, help="the PDF you downloaded yourself, instead of fetching it")
29
+ commands.add_parser("docs", help="the catalogue and what is imported")
30
+ commands.add_parser("outline", help="a standard's clauses with refs").add_argument("doc")
31
+ read = commands.add_parser("read", help="read by ref")
32
+ read.add_argument("ref")
33
+ read.add_argument("--max-tokens", type=int, default=2000)
34
+ search = commands.add_parser("search", help="find sentences containing words")
35
+ search.add_argument("query")
36
+ search.add_argument("--doc")
37
+ search.add_argument("--limit", type=int, default=10)
38
+ search.add_argument("--start", type=int, default=0)
39
+ commands.add_parser("cite", help="the citation for a ref").add_argument("ref")
40
+ return parser
41
+
42
+
43
+ def _out(text: str) -> int:
44
+ sys.stdout.write(text if text.endswith("\n") else text + "\n")
45
+ return 0
46
+
47
+
48
+ def main(argv: Sequence[str]) -> int:
49
+ args = _parser().parse_args(list(argv))
50
+ try:
51
+ if args.command == "import":
52
+ result = importer.import_edition(library.default_home(), args.number, args.edition, file=args.file)
53
+ return _out(
54
+ f"imported {result.key}: {result.sections} sections, {result.requests} request(s), {result.pdf}"
55
+ )
56
+ if args.command == "docs":
57
+ return _out(library.docs())
58
+ if args.command == "outline":
59
+ return _out(library.outline(args.doc))
60
+ if args.command == "read":
61
+ return _out(library.read(args.ref, max_tokens=args.max_tokens))
62
+ if args.command == "search":
63
+ return _out(library.search(args.query, doc=args.doc, limit=args.limit, start=args.start))
64
+ return _out(library.cite(args.ref))
65
+ except LibError as exc:
66
+ print(exc.as_text(), file=sys.stderr)
67
+ return 1
@@ -0,0 +1,25 @@
1
+ """How the ecma collection recognises an Ecma standard in a supplied PDF (`registry.Collection.identify`).
2
+
3
+ Covers and running headers read `ECMA-427` (`Standard ECMA-427`); Ecma's download is `ECMA-427_1st_edition.pdf`.
4
+ The importer takes the file the user downloaded (`--file`) and checks it against the catalogue's pinned digest.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import re
10
+
11
+ from primarysource.identify import Candidate, Probe, candidates
12
+
13
+ _ECMA = re.compile(r"\bECMA-(?P<num>\d{1,4})\b")
14
+
15
+
16
+ def _label(match: re.Match[str]) -> str:
17
+ return f"ECMA-{match['num']}"
18
+
19
+
20
+ def identify(probe: Probe) -> list[Candidate]:
21
+ def command(label: str) -> str:
22
+ where = probe.path if probe.path is not None else probe.name
23
+ return f'primarysource ecma import {label.removeprefix("ECMA-")} --file "{where}"'
24
+
25
+ return candidates(probe, "ecma", _ECMA, _label, command)
@@ -0,0 +1,108 @@
1
+ """Import an Ecma standard into `$PRIMARYSOURCE_HOME/ecma/`.
2
+
3
+ The PDF is fetched from ecma-international.org (one request) or taken from a file the user downloaded, checked
4
+ against the catalogue's pinned sha256 before anything is parsed or written, stored as `<number>-<edition>.pdf`,
5
+ and read once with `_pdf.extract` into `<number>-<edition>.json`, which is what the readers use. `install.json`
6
+ records the pin, the extractor and the time. Placement is a volume: this is the user's own copy, downloaded under
7
+ Ecma's terms (see `catalogue.json`), and is not for an image layer.
8
+
9
+ Run as `primarysource ecma import [NUMBER] [--file PDF]`.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ import os
16
+ from dataclasses import dataclass
17
+ from datetime import UTC, datetime
18
+ from pathlib import Path
19
+ from typing import Any
20
+
21
+ from primarysource.c2sp._fetch import Fetcher, Transport, verify
22
+ from primarysource.ecma import _pdf
23
+ from primarysource.ecma.catalogue import HOSTS, Catalogue, Edition
24
+ from primarysource.ecma.catalogue import catalogue as packaged_catalogue
25
+ from primarysource.errors import LibError
26
+ from primarysource.home import atomic_write
27
+
28
+ INSTALL_RECORD = "install.json"
29
+ MAX_PDF = 64 * 1024 * 1024
30
+
31
+
32
+ @dataclass(frozen=True, slots=True)
33
+ class ImportResult:
34
+ key: str
35
+ pdf: Path
36
+ extraction: Path
37
+ sections: int
38
+ requests: int
39
+
40
+
41
+ def read_record(home: Path) -> dict[str, Any]:
42
+ path = home / INSTALL_RECORD
43
+ try:
44
+ loaded = json.loads(path.read_text(encoding="utf-8"))
45
+ except (OSError, ValueError):
46
+ return {"editions": {}}
47
+ return loaded if isinstance(loaded, dict) and isinstance(loaded.get("editions"), dict) else {"editions": {}}
48
+
49
+
50
+ def import_edition(
51
+ home: Path,
52
+ number: str,
53
+ edition: int | None = None,
54
+ *,
55
+ file: Path | None = None,
56
+ cat: Catalogue | None = None,
57
+ transport: Transport | None = None,
58
+ fetcher: Fetcher | None = None,
59
+ ) -> ImportResult:
60
+ """Fetch (or read `file`), verify, store and extract one edition. Nothing is written unless the sha256
61
+ matches the pin and the PDF reads."""
62
+ cat = cat or packaged_catalogue()
63
+ found: Edition = cat.edition(number, edition)
64
+ requests = 0
65
+ if file is not None:
66
+ try:
67
+ data = file.read_bytes()
68
+ except OSError as exc:
69
+ raise LibError(f"cannot read {file}: {exc}", "pass the PDF you downloaded from the publisher") from exc
70
+ if len(data) > MAX_PDF:
71
+ raise LibError(f"{file} is larger than {MAX_PDF} bytes", "")
72
+ else:
73
+ fetcher = fetcher or Fetcher(HOSTS, transport)
74
+ data = fetcher.get(found.source_url, MAX_PDF)
75
+ requests = fetcher.requests
76
+ verify(data, found.sha256, found.designation)
77
+ if not data.startswith(b"%PDF-"):
78
+ raise LibError(f"{found.designation}: not a PDF", "the pinned file is a PDF; nothing was written")
79
+ pdf_path = home / f"{found.key}.pdf"
80
+ home.mkdir(parents=True, exist_ok=True)
81
+ part = home / f"{found.key}.pdf.part"
82
+ part.write_bytes(data)
83
+ try:
84
+ extraction = _pdf.extract(data, part)
85
+ except BaseException:
86
+ part.unlink(missing_ok=True)
87
+ raise
88
+ os.replace(part, pdf_path)
89
+ extraction.update(
90
+ {
91
+ "title": found.title,
92
+ "designation": found.designation,
93
+ "edition": found.edition_name,
94
+ "page_url": found.page_url,
95
+ "sha256": found.sha256,
96
+ }
97
+ )
98
+ json_path = home / f"{found.key}.json"
99
+ atomic_write(json_path, (json.dumps(extraction, ensure_ascii=False) + "\n").encode("utf-8"))
100
+ record = read_record(home)
101
+ record["collection"] = "ecma"
102
+ record["editions"][found.key] = {
103
+ "sha256": found.sha256,
104
+ "extractor": extraction["extractor"],
105
+ "imported": datetime.now(UTC).isoformat(timespec="seconds"),
106
+ }
107
+ atomic_write(home / INSTALL_RECORD, (json.dumps(record, indent=2) + "\n").encode("utf-8"))
108
+ return ImportResult(found.key, pdf_path, json_path, len(extraction["sections"]), requests)