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,189 @@
1
+ """A small, strict Markdown reader: headings, blocks, front matter. The core's `cl.reader_markdown`.
2
+
3
+ Written for the C2SP specifications (`primarysource/c2sp/markdown.py`, now a re-export of this module). It reads
4
+ the subset those specifications use and nothing else is interpreted: ATX headings (`#` to `######`),
5
+ setext headings over one paragraph line, fenced code (backticks or tildes), blank-line-separated blocks, and a
6
+ leading `---` front-matter block of `key: value` lines. HTML, links and images are never followed or rendered;
7
+ a heading's title is its text with link targets, emphasis marks and code ticks removed.
8
+
9
+ Input is bounded (`MAX_BYTES`, `MAX_LINE`, `MAX_SECTIONS`) because the text of a specification is untrusted data:
10
+ a document over a bound is refused with a `LibError`, never truncated.
11
+
12
+ Units: `¶n` counts blocks. A block is a blank-line-separated chunk of lines, or one whole fenced code block
13
+ (blank lines inside a fence do not split it). A section's units are every block from its heading to the next
14
+ heading of the same or a higher level, subsection headings included as blocks, its own heading excluded.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import re
20
+ import unicodedata
21
+ from dataclasses import dataclass
22
+
23
+ from primarysource.errors import LibError
24
+
25
+ MAX_BYTES = 4 * 1024 * 1024
26
+ MAX_LINE = 20_000
27
+ MAX_SECTIONS = 20_000
28
+
29
+ _ATX = re.compile(r"^ {0,3}(#{1,6})(?:[ \t]+(.*?))?(?:[ \t]+#+)?[ \t]*$")
30
+ _FENCE = re.compile(r"^ {0,3}(`{3,}|~{3,})")
31
+ _SETEXT = re.compile(r"^ {0,3}(=+|-+)[ \t]*$")
32
+ _LINK = re.compile(r"!?\[([^\]]*)\]\([^)]*\)")
33
+ _REFLINK = re.compile(r"\[([^\]]+)\]\[[^\]]*\]")
34
+ _TAG = re.compile(r"<[^>\n]*>")
35
+ _MARKS = re.compile(r"[`*_~]+")
36
+ _SLUG_DROP = re.compile(r"[^\w\- ]", re.UNICODE)
37
+
38
+
39
+ @dataclass(frozen=True, slots=True)
40
+ class Heading:
41
+ """One section of a document, in document order. `units` are its blocks (see the module docstring)."""
42
+
43
+ title: str
44
+ slug: str
45
+ level: int
46
+ units: tuple[str, ...]
47
+
48
+
49
+ @dataclass(frozen=True, slots=True)
50
+ class MarkdownDocument:
51
+ """What the reader found. `preamble` is the blocks before the first heading; `title` is the first level-1
52
+ heading, else the front matter's `title`, else empty."""
53
+
54
+ front: dict[str, str]
55
+ title: str
56
+ preamble: tuple[str, ...]
57
+ headings: tuple[Heading, ...]
58
+
59
+ def units(self) -> tuple[str, ...]:
60
+ """The whole document as units: the preamble, then every block of every top-level section, with each
61
+ heading as a block of its own."""
62
+ if not self.headings:
63
+ return self.preamble
64
+ top = min(h.level for h in self.headings)
65
+ out = list(self.preamble)
66
+ for heading in self.headings:
67
+ if heading.level == top:
68
+ out.append(f"{'#' * heading.level} {heading.title}")
69
+ out.extend(heading.units)
70
+ return tuple(out)
71
+
72
+
73
+ def plain(text: str) -> str:
74
+ """A heading's text without Markdown: links reduced to their text, tags, ticks and emphasis marks removed."""
75
+ text = _LINK.sub(r"\1", text)
76
+ text = _REFLINK.sub(r"\1", text)
77
+ text = _TAG.sub("", text)
78
+ text = _MARKS.sub("", text)
79
+ return " ".join(text.split())
80
+
81
+
82
+ def slugify(title: str) -> str:
83
+ """The GitHub-style anchor of a title: lower case, punctuation dropped, spaces to hyphens."""
84
+ folded = unicodedata.normalize("NFKC", title).lower()
85
+ return _SLUG_DROP.sub("", folded).strip().replace(" ", "-")
86
+
87
+
88
+ def _front_matter(lines: list[str]) -> tuple[dict[str, str], int]:
89
+ """Parse a leading `---` block of `key: value` lines; return it and the index of the first body line."""
90
+ if not lines or lines[0].strip() != "---":
91
+ return {}, 0
92
+ for end in range(1, min(len(lines), 60)):
93
+ if lines[end].strip() == "---":
94
+ front: dict[str, str] = {}
95
+ for line in lines[1:end]:
96
+ key, sep, value = line.partition(":")
97
+ if sep and re.fullmatch(r"[A-Za-z][\w-]*", key.strip()):
98
+ front[key.strip()] = value.strip().strip("\"'")
99
+ return front, end + 1
100
+ return {}, 0
101
+
102
+
103
+ def decode(data: bytes) -> str:
104
+ """UTF-8 text from bytes, or a `LibError`: over the bound, not UTF-8, or containing a NUL."""
105
+ if len(data) > MAX_BYTES:
106
+ raise LibError(f"document is {len(data)} bytes, over the {MAX_BYTES} byte bound", "")
107
+ try:
108
+ text = data.decode("utf-8-sig")
109
+ except UnicodeDecodeError as exc:
110
+ raise LibError("document is not valid UTF-8", "re-import it; the pinned file is UTF-8") from exc
111
+ if "\x00" in text:
112
+ raise LibError("document contains a NUL byte", "re-import it; the pinned file is plain text")
113
+ return text
114
+
115
+
116
+ def parse(data: bytes | str) -> MarkdownDocument:
117
+ """Parse one document. Raises `LibError` for a document that is not UTF-8 text or exceeds a bound."""
118
+ text = decode(data) if isinstance(data, bytes) else data
119
+ lines = text.replace("\r\n", "\n").replace("\r", "\n").split("\n")
120
+ if any(len(line) > MAX_LINE for line in lines):
121
+ raise LibError(f"document has a line over {MAX_LINE} characters", "")
122
+ front, first = _front_matter(lines)
123
+
124
+ # pass 1: split into (kind, level, text) items: headings and blocks, fences kept whole
125
+ items: list[tuple[str, int, str]] = []
126
+ paragraph: list[str] = []
127
+ fence: tuple[str, int] | None = None
128
+ fenced: list[str] = []
129
+
130
+ def flush() -> None:
131
+ if paragraph:
132
+ items.append(("block", 0, "\n".join(paragraph)))
133
+ paragraph.clear()
134
+
135
+ for line in lines[first:]:
136
+ if fence is not None:
137
+ fenced.append(line)
138
+ close = _FENCE.match(line)
139
+ if close and close.group(1)[0] == fence[0] and len(close.group(1)) >= fence[1] and not line.strip(" `~"):
140
+ items.append(("block", 0, "\n".join(fenced)))
141
+ fence, fenced = None, []
142
+ continue
143
+ opening = _FENCE.match(line)
144
+ if opening:
145
+ flush()
146
+ fence, fenced = (opening.group(1)[0], len(opening.group(1))), [line]
147
+ continue
148
+ if not line.strip():
149
+ flush()
150
+ continue
151
+ atx = _ATX.match(line)
152
+ if atx:
153
+ flush()
154
+ items.append(("heading", len(atx.group(1)), plain(atx.group(2) or "")))
155
+ continue
156
+ setext = _SETEXT.match(line)
157
+ if setext and len(paragraph) == 1 and not paragraph[0].lstrip().startswith(("-", "*", ">", "|")):
158
+ items.append(("heading", 1 if setext.group(1)[0] == "=" else 2, plain(paragraph[0])))
159
+ paragraph.clear()
160
+ continue
161
+ paragraph.append(line)
162
+ if fence is not None: # an unclosed fence runs to the end of the document, as in CommonMark
163
+ items.append(("block", 0, "\n".join(fenced)))
164
+ flush()
165
+
166
+ # pass 2: assign blocks to sections
167
+ spans = [(body, level, index) for index, (kind, level, body) in enumerate(items) if kind == "heading"]
168
+ if len(spans) > MAX_SECTIONS:
169
+ raise LibError(f"document has {len(spans)} headings, over the {MAX_SECTIONS} bound", "")
170
+ first_heading = spans[0][2] if spans else len(items)
171
+ preamble = tuple(body for kind, _, body in items[:first_heading] if kind == "block")
172
+ headings: list[Heading] = []
173
+ seen: dict[str, int] = {}
174
+ for n, (title, level, start) in enumerate(spans):
175
+ end = len(items)
176
+ for _, later_level, later in spans[n + 1 :]:
177
+ if later_level <= level:
178
+ end = later
179
+ break
180
+ units = tuple(
181
+ f"{'#' * item_level} {body}".rstrip() if kind == "heading" else body
182
+ for kind, item_level, body in items[start + 1 : end]
183
+ )
184
+ slug = slugify(title)
185
+ count = seen.get(slug, 0)
186
+ seen[slug] = count + 1
187
+ headings.append(Heading(title, slug if count == 0 else f"{slug}-{count}", level, units))
188
+ top = next((h.title for h in headings if h.level == 1), "")
189
+ return MarkdownDocument(front, top or front.get("title", ""), preamble, tuple(headings))
@@ -0,0 +1,187 @@
1
+ """Read a publisher's PDF into bookmarked sections of sentences (`cl.reader_pdf_pypdf`).
2
+
3
+ `pypdf` is an ingest-time extra (PLAN.md §5.13): it is imported inside the functions that need it, never at
4
+ module level, so a server that answers from stored extractions imports this module without it. Asking for a PDF
5
+ read without it is a `LibError` naming the extra, not an `ImportError`.
6
+
7
+ Lifted from the ecma collection's private reader (`primarysource/ecma/_pdf.py`, now a re-export), with its tests.
8
+ The bookmarks always come from `pypdf`; the page text comes from `pdftotext` (poppler or Xpdf) when it is on PATH
9
+ and a path to the PDF is given, else from `pypdf`. Backends give different text, so the backend's name and
10
+ version are in every extraction (`extractor`) and are what an index puts into its source hash (PLAN.md §4).
11
+ Figures, tables and equations are whatever the backend extracts: expect broken columns.
12
+
13
+ Cutting the page texts into sections is `primarysource.readers.bookmarks` (`cl.segment_bookmarks`).
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import io
19
+ import re
20
+ import shutil
21
+ import subprocess # noqa: S404 - runs the user's own pdftotext on a PDF whose sha256 the caller pinned
22
+ from collections.abc import Sequence
23
+ from pathlib import Path
24
+ from types import ModuleType
25
+ from typing import Any, NamedTuple
26
+
27
+ from primarysource.errors import LibError
28
+ from primarysource.readers.bookmarks import Outline, cut, cut_numbered
29
+
30
+ MAX_BYTES = 64 * 1024 * 1024
31
+ MAX_PAGES = 2000
32
+ MAX_OUTLINE = 6000
33
+ INSTALL_HINT = 'pip install "primarysource[ecma]" (or [bbf], [ieee], [itu], [iso]); each brings pypdf'
34
+
35
+ _PDFTOTEXT_VERSION = re.compile(rb"pdftotext\s+version\s+(\S+)", re.IGNORECASE)
36
+
37
+
38
+ class Backend(NamedTuple):
39
+ """The program that produced a text: what an index hashes so a change of backend rebuilds it."""
40
+
41
+ name: str
42
+ version: str
43
+
44
+ def label(self) -> str:
45
+ """`name version`, the form stored as an extraction's `extractor`."""
46
+ return f"{self.name} {self.version}"
47
+
48
+
49
+ def _pypdf() -> ModuleType:
50
+ try:
51
+ import pypdf
52
+ except ImportError as exc: # ModuleNotFoundError, or None in sys.modules
53
+ raise LibError("reading a PDF needs pypdf, which is not installed", INSTALL_HINT) from exc
54
+ return pypdf
55
+
56
+
57
+ def pypdf_backend() -> Backend:
58
+ """`pypdf` and its version, or a `LibError` naming the extra when it is not installed."""
59
+ return Backend("pypdf", str(_pypdf().__version__))
60
+
61
+
62
+ def pdftotext_backend() -> Backend | None:
63
+ """`pdftotext` and the version it reports, or None when it is not on PATH. A version it does not report is
64
+ `unknown`."""
65
+ exe = shutil.which("pdftotext")
66
+ if exe is None:
67
+ return None
68
+ try:
69
+ done = subprocess.run([exe, "-v"], capture_output=True, timeout=30, check=False) # noqa: S603 - fixed argv
70
+ except (OSError, subprocess.SubprocessError):
71
+ return None
72
+ found = _PDFTOTEXT_VERSION.search(done.stderr + b"\n" + done.stdout)
73
+ return Backend("pdftotext", found.group(1).decode("ascii", errors="replace") if found else "unknown")
74
+
75
+
76
+ def _page_of(reader: Any, item: Any) -> int:
77
+ """The 0-based page a bookmark points at, or -1 if its destination is unusable."""
78
+ try:
79
+ return int(reader.get_destination_page_number(item))
80
+ except Exception: # noqa: BLE001 - pypdf raises many types for a broken destination
81
+ return -1
82
+
83
+
84
+ def outline(reader: Any) -> Outline:
85
+ """A `pypdf.PdfReader`'s bookmark tree flattened to (title, 0-based page, depth), in document order, bounded
86
+ by `MAX_OUTLINE` entries and 12 levels."""
87
+ flat: Outline = []
88
+
89
+ def walk(items: Sequence[Any], depth: int) -> None:
90
+ for item in items:
91
+ if len(flat) >= MAX_OUTLINE or depth > 12:
92
+ return
93
+ if isinstance(item, list):
94
+ walk(item, depth + 1)
95
+ continue
96
+ page = _page_of(reader, item)
97
+ title = " ".join(str(item.title).split())
98
+ if title and page >= 0:
99
+ flat.append((title, page, depth))
100
+
101
+ walk(reader.outline, 0)
102
+ return flat
103
+
104
+
105
+ def pdftotext_pages(path: Path, count: int, *, layout: bool = False) -> list[str] | None:
106
+ """The page texts from `pdftotext` if it is on PATH and returns `count` pages, else None. Its text keeps word
107
+ spacing that `pypdf` loses on some fonts, and puts headings where they are read. `layout` keeps the page's
108
+ columns (`-layout`), which the numbered-heading splitter reads contents rows and table cells by."""
109
+ exe = shutil.which("pdftotext")
110
+ if exe is None:
111
+ return None
112
+ argv = [exe, *(["-layout"] if layout else []), "-enc", "UTF-8", "-q", str(path), "-"]
113
+ try:
114
+ done = subprocess.run(argv, capture_output=True, timeout=300, check=False) # noqa: S603 - no shell
115
+ except (OSError, subprocess.SubprocessError):
116
+ return None
117
+ if done.returncode != 0:
118
+ return None
119
+ pages = done.stdout.decode("utf-8", errors="replace").split("\f")
120
+ if pages and not pages[-1].strip():
121
+ pages.pop()
122
+ return pages if len(pages) == count else None
123
+
124
+
125
+ def extract(data: bytes, path: Path | None = None, *, numbered: bool = False) -> dict[str, Any]:
126
+ """Read a PDF into `{"title", "pages", "extractor", "pypdf", "headings_found", "sections": [{"id", "title",
127
+ "depth", "page", "text"}]}`.
128
+
129
+ `text` is the section's sentences joined by newlines. `extractor` is the backend that gave the page text, as
130
+ `name version` (`Backend.label`); `pypdf` is the version that gave the bookmarks. If `path` names the PDF on
131
+ disk and `pdftotext` is on PATH, its page text is used; otherwise `pypdf`'s, in content-stream order first,
132
+ and if fewer than 70% of the bookmarked headings are found in it as lines (some typesetters emit headings out
133
+ of order) the pages are re-read in `layout` mode and the better result is kept. With `numbered`, a PDF without
134
+ bookmarks is cut at numbered headings instead (`bookmarks.cut_numbered`), and `segmenter` says which was used.
135
+ Raises `LibError` for a PDF that is too large, cannot be read, has no bookmarks (and, with `numbered`, fewer
136
+ than 3 numbered headings), or when `pypdf` is not installed."""
137
+ if len(data) > MAX_BYTES:
138
+ raise LibError(f"PDF is {len(data)} bytes, over the {MAX_BYTES} byte bound", "")
139
+ pypdf = _pypdf()
140
+ try:
141
+ reader = pypdf.PdfReader(io.BytesIO(data), strict=False)
142
+ count = len(reader.pages)
143
+ if count > MAX_PAGES:
144
+ raise LibError(f"PDF has {count} pages, over the {MAX_PAGES} bound", "")
145
+ bookmarks = outline(reader)
146
+ if not bookmarks and not numbered:
147
+ raise LibError("the PDF has no bookmarks to split it into sections", "this reader needs a bookmarked PDF")
148
+ meta = reader.metadata
149
+ title = str(meta.title) if meta is not None and meta.title else ""
150
+ external = pdftotext_pages(path, count, layout=not bookmarks) if path is not None else None
151
+ backend = Backend("pypdf", str(pypdf.__version__))
152
+ if not bookmarks:
153
+ if external is not None:
154
+ backend = pdftotext_backend() or Backend("pdftotext", "unknown")
155
+ texts = external
156
+ if texts is None:
157
+ texts = [str(p.extract_text(extraction_mode="layout") or "") for p in reader.pages]
158
+ sections, located = cut_numbered(texts)
159
+ if located < 3:
160
+ raise LibError(
161
+ "the PDF has no bookmarks, and fewer than 3 numbered headings were found in its text",
162
+ "this reader needs a bookmarked PDF or numbered clause headings",
163
+ )
164
+ elif external is not None:
165
+ backend = pdftotext_backend() or Backend("pdftotext", "unknown")
166
+ sections, located = cut(external, bookmarks)
167
+ else:
168
+ sections, located = cut([str(reader.pages[i].extract_text() or "") for i in range(count)], bookmarks)
169
+ if bookmarks and external is None and located * 10 < len(bookmarks) * 7:
170
+ layout = [str(reader.pages[i].extract_text(extraction_mode="layout") or "") for i in range(count)]
171
+ better, better_located = cut(layout, bookmarks)
172
+ if better_located > located:
173
+ sections, located = better, better_located
174
+ except LibError:
175
+ raise
176
+ except Exception as exc: # noqa: BLE001 - pypdf raises many types on hostile input
177
+ message = f"could not read the PDF: {type(exc).__name__}: {exc}"
178
+ raise LibError(message, "check the file is the publisher's PDF") from exc
179
+ return {
180
+ "title": title,
181
+ "pages": count,
182
+ "extractor": backend.label(),
183
+ "pypdf": pypdf.__version__,
184
+ "headings_found": located,
185
+ "segmenter": "bookmarks" if bookmarks else "numbered headings",
186
+ "sections": sections,
187
+ }
@@ -0,0 +1,230 @@
1
+ """The one hardened XML reader (`cl.reader_xml_hardened`), built on `xml.parsers.expat` and nothing else.
2
+
3
+ Three copies were compared: `primarysource/iana/xmlsafe.py`, `primarysource/bbf/_xml.py` and devicespy's
4
+ `devicespy/xmlsafe.py` (read, not imported; devicespy commit e26c820). This keeps the strictest behaviour of each:
5
+ devicespy's design and typed refusals, iana's text bound, bbf's scan for declarations before parsing. The iana and
6
+ bbf modules are now thin wrappers over this one that keep their own bounds and messages.
7
+
8
+ It refuses rather than defends:
9
+
10
+ - any document type declaration, entity or parameter-entity declaration, notation or external reference, so no
11
+ entity can be declared, expansion attacks are impossible and nothing is ever fetched. The raw bytes are scanned
12
+ for `<!DOCTYPE`, `<!ENTITY`, `<!ELEMENT`, `<!ATTLIST` and `<!NOTATION` before expat sees them (also in a
13
+ UTF-16 or UTF-32 decoding, so a declaration hidden by the encoding is named as one), and every expat handler
14
+ that a declaration would reach refuses too;
15
+ - any processing instruction other than the XML declaration, unless `ignore_processing_instructions` is set, in
16
+ which case they are dropped and never acted on (IANA registry files open with two);
17
+ - any declared encoding other than UTF-8, and input that looks like UTF-16 or UTF-32;
18
+ - input over `limit` bytes (checked before a parser exists), nesting over `max_depth`, more than `max_elements`
19
+ elements, more than `max_attributes` attributes on one element, an attribute value over
20
+ `max_attribute_length` characters, and text or a tail in one element over `max_text` characters;
21
+ - empty, truncated and malformed input, an undefined entity and a second root element.
22
+
23
+ XInclude and XSLT are never processed: ElementTree has no such step and only `Element` objects are built here.
24
+ Every refusal is an `XmlRefused`, which is a `LibError` with a hint, in one of four kinds: `XmlTooLarge`,
25
+ `XmlTooDeep`, `XmlForbidden`, `XmlMalformed`.
26
+
27
+ Names come out in ElementTree's `{namespace}local` form, unless `namespaces=False`, when they are the raw
28
+ qualified names (`dm:document`) and an undeclared prefix is not an error.
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ import re
34
+ import xml.etree.ElementTree as ET # noqa: S405 - only used to build elements; parsing is expat with DTDs refused
35
+ from typing import NoReturn
36
+ from xml.parsers import expat # noqa: S406 - hardened below: declarations refused, no entities, bounded
37
+
38
+ from primarysource.errors import LibError
39
+
40
+ __all__ = [
41
+ "DEFAULT_HINT",
42
+ "XmlForbidden",
43
+ "XmlMalformed",
44
+ "XmlRefused",
45
+ "XmlTooDeep",
46
+ "XmlTooLarge",
47
+ "localname",
48
+ "parse",
49
+ ]
50
+
51
+ DEFAULT_HINT = "the file is damaged or not the expected document; fetch or import it again"
52
+ _SEP = "}"
53
+ _DECLARATION = re.compile(rb"<!\s*(?:DOCTYPE|ENTITY|ELEMENT|ATTLIST|NOTATION)", re.IGNORECASE)
54
+ _DECLARATION_TEXT = re.compile(r"<!\s*(?:DOCTYPE|ENTITY|ELEMENT|ATTLIST|NOTATION)", re.IGNORECASE)
55
+ _WIDE_CODECS = ("utf-16", "utf-16-le", "utf-16-be", "utf-32", "utf-32-le", "utf-32-be")
56
+ _FORBIDDEN_DECLARATIONS = "document type declarations, entities and notations are not accepted"
57
+
58
+
59
+ class XmlRefused(LibError): # noqa: N818 - the name is the contract, as in devicespy
60
+ """The document was refused. `reason` says why in one short phrase; the message is `XML refused: <reason>`."""
61
+
62
+ def __init__(self, reason: str, hint: str = DEFAULT_HINT) -> None:
63
+ super().__init__(f"XML refused: {reason}", hint)
64
+ self.reason = reason
65
+
66
+
67
+ class XmlTooLarge(XmlRefused):
68
+ """A size bound was exceeded: bytes, elements, attributes, attribute length or text length."""
69
+
70
+
71
+ class XmlTooDeep(XmlRefused):
72
+ """Element nesting went past `max_depth`."""
73
+
74
+
75
+ class XmlForbidden(XmlRefused):
76
+ """A construct this reader never accepts: a declaration, an entity, a processing instruction, an encoding."""
77
+
78
+
79
+ class XmlMalformed(XmlRefused):
80
+ """Not well-formed XML, including empty and truncated input."""
81
+
82
+
83
+ def _qname(name: str) -> str:
84
+ # expat with a namespace separator yields "uri}local" (the local part cannot contain it).
85
+ if _SEP in name:
86
+ uri, local = name.rsplit(_SEP, 1)
87
+ return "{" + uri + "}" + local
88
+ return name
89
+
90
+
91
+ def localname(tag: str) -> tuple[str, str]:
92
+ """Split an ElementTree name `{namespace}local` into `(namespace, local)`; no namespace gives `("", name)`."""
93
+ if tag.startswith("{"):
94
+ uri, sep, local = tag[1:].partition("}")
95
+ if sep:
96
+ return uri, local
97
+ return "", tag
98
+
99
+
100
+ class _Builder:
101
+ def __init__(
102
+ self,
103
+ hint: str,
104
+ max_depth: int,
105
+ max_elements: int,
106
+ max_attributes: int,
107
+ max_attribute_length: int,
108
+ max_text: int,
109
+ ) -> None:
110
+ self.hint = hint
111
+ self.max_depth = max_depth
112
+ self.max_elements = max_elements
113
+ self.max_attributes = max_attributes
114
+ self.max_attribute_length = max_attribute_length
115
+ self.max_text = max_text
116
+ self.stack: list[ET.Element] = []
117
+ self.root: ET.Element | None = None
118
+ self.count = 0
119
+ self.tail_of: ET.Element | None = None
120
+
121
+ def refuse(self, *_args: object) -> NoReturn:
122
+ raise XmlForbidden(_FORBIDDEN_DECLARATIONS, self.hint)
123
+
124
+ def refuse_pi(self, *_args: object) -> NoReturn:
125
+ raise XmlForbidden("processing instructions are not accepted", self.hint)
126
+
127
+ def xml_decl(self, _version: str, encoding: str | None, _standalone: int) -> None:
128
+ if encoding is not None and encoding.lower() not in ("utf-8", "utf8"):
129
+ raise XmlForbidden("only UTF-8 is accepted, the document declares " + encoding, self.hint)
130
+
131
+ def start(self, name: str, attrs: dict[str, str]) -> None:
132
+ if len(self.stack) >= self.max_depth:
133
+ raise XmlTooDeep(f"nesting deeper than {self.max_depth}", self.hint)
134
+ self.count += 1
135
+ if self.count > self.max_elements:
136
+ raise XmlTooLarge(f"more than {self.max_elements} elements", self.hint)
137
+ if len(attrs) > self.max_attributes:
138
+ raise XmlTooLarge(f"more than {self.max_attributes} attributes on one element", self.hint)
139
+ for value in attrs.values():
140
+ if len(value) > self.max_attribute_length:
141
+ raise XmlTooLarge(f"an attribute value is longer than {self.max_attribute_length}", self.hint)
142
+ element = ET.Element(_qname(name), {_qname(k): v for k, v in attrs.items()})
143
+ if self.stack:
144
+ self.stack[-1].append(element)
145
+ else:
146
+ self.root = element
147
+ self.stack.append(element)
148
+ self.tail_of = None
149
+
150
+ def end(self, _name: str) -> None:
151
+ self.tail_of = self.stack.pop()
152
+
153
+ def data(self, text: str) -> None:
154
+ if self.tail_of is not None:
155
+ self.tail_of.tail = (self.tail_of.tail or "") + text
156
+ if len(self.tail_of.tail) > self.max_text:
157
+ raise XmlTooLarge(f"text longer than {self.max_text}", self.hint)
158
+ elif self.stack:
159
+ top = self.stack[-1]
160
+ top.text = (top.text or "") + text
161
+ if len(top.text) > self.max_text:
162
+ raise XmlTooLarge(f"text longer than {self.max_text}", self.hint)
163
+
164
+
165
+ def _looks_wide(head: bytes) -> bool:
166
+ return head[:2] in (b"\xfe\xff", b"\xff\xfe") or b"\x00" in head
167
+
168
+
169
+ def _declares_in_wide_text(raw: bytes) -> bool:
170
+ return any(_DECLARATION_TEXT.search(raw.decode(codec, errors="ignore")) for codec in _WIDE_CODECS)
171
+
172
+
173
+ def parse(
174
+ raw: bytes,
175
+ limit: int,
176
+ *,
177
+ max_depth: int = 32,
178
+ max_elements: int = 50_000,
179
+ max_attributes: int = 16,
180
+ max_attribute_length: int = 4096,
181
+ max_text: int = 1_000_000,
182
+ ignore_processing_instructions: bool = False,
183
+ namespaces: bool = True,
184
+ hint: str = DEFAULT_HINT,
185
+ ) -> ET.Element:
186
+ """Parse `raw` into its root Element, or raise `XmlRefused` (a `LibError` carrying `hint`).
187
+
188
+ `limit` is the largest acceptable `len(raw)` and is checked before any parsing. The whole document is passed
189
+ at once. The defaults are the strictest any of the three source copies used; a caller with larger documents
190
+ passes its own bounds. The root returned is never a placeholder: every refusal raises."""
191
+ if not isinstance(raw, bytes | bytearray | memoryview):
192
+ raise TypeError("raw must be bytes")
193
+ if limit < 0:
194
+ raise ValueError("limit must not be negative")
195
+ if len(raw) > limit:
196
+ raise XmlTooLarge(f"{len(raw):,} bytes exceeds the limit of {limit:,}", hint)
197
+ data = bytes(raw)
198
+ if not data:
199
+ raise XmlMalformed("empty document", hint)
200
+ if _DECLARATION.search(data):
201
+ raise XmlForbidden(_FORBIDDEN_DECLARATIONS, hint)
202
+ if _looks_wide(data[:4]):
203
+ if _declares_in_wide_text(data):
204
+ raise XmlForbidden(_FORBIDDEN_DECLARATIONS, hint)
205
+ raise XmlForbidden("only UTF-8 is accepted, the input looks like UTF-16 or UTF-32", hint)
206
+ builder = _Builder(hint, max_depth, max_elements, max_attributes, max_attribute_length, max_text)
207
+ parser = expat.ParserCreate(None, _SEP) if namespaces else expat.ParserCreate()
208
+ parser.buffer_text = True
209
+ parser.SetParamEntityParsing(expat.XML_PARAM_ENTITY_PARSING_NEVER)
210
+ parser.StartDoctypeDeclHandler = builder.refuse
211
+ parser.EntityDeclHandler = builder.refuse
212
+ parser.ExternalEntityRefHandler = builder.refuse
213
+ parser.NotationDeclHandler = builder.refuse
214
+ if not ignore_processing_instructions:
215
+ parser.ProcessingInstructionHandler = builder.refuse_pi
216
+ parser.XmlDeclHandler = builder.xml_decl
217
+ parser.StartElementHandler = builder.start
218
+ parser.EndElementHandler = builder.end
219
+ parser.CharacterDataHandler = builder.data
220
+ try:
221
+ parser.Parse(data, True)
222
+ except XmlRefused:
223
+ raise
224
+ except expat.ExpatError as error:
225
+ raise XmlMalformed("not well-formed XML: " + expat.ErrorString(error.code), hint) from None
226
+ except (ValueError, RecursionError) as error:
227
+ raise XmlMalformed(f"not well-formed XML: {type(error).__name__}", hint) from None
228
+ if builder.root is None:
229
+ raise XmlMalformed("no root element", hint)
230
+ return builder.root