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,89 @@
1
+ """A per-edition full-text index: SQLite FTS5, built once at import, opened read-only.
2
+
3
+ One row per section (its title and its blocks), ranked by BM25 with the title weighted over the body. This is the
4
+ interim form of the core's `cl.index_fts5`; it keeps the rules the roadmap already names for that row (a sanitiser
5
+ that quotes user text term by term so FTS5 operators cannot raise, strict AND then a relaxed OR that says so, a
6
+ file that is built beside its destination and renamed into place, a read-only open).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import contextlib
12
+ import re
13
+ import sqlite3
14
+ from collections.abc import Sequence
15
+ from dataclasses import dataclass
16
+ from pathlib import Path
17
+
18
+ from primarysource.errors import LibError
19
+ from primarysource.pkgdocs.model import Doc
20
+
21
+ SCHEMA_VERSION = 1
22
+ MAX_TERMS = 12
23
+ WEIGHTS = "0.0, 0.0, 8.0, 1.0" # doc and sec are not searched; a title match outweighs a body match
24
+ _TERM = re.compile(r"\w+", re.UNICODE)
25
+
26
+
27
+ @dataclass(frozen=True, slots=True)
28
+ class Hit:
29
+ doc: str
30
+ section: str
31
+ title: str
32
+ snippet: str
33
+ score: float
34
+
35
+
36
+ def build(path: Path, docs: Sequence[Doc]) -> int:
37
+ """Write the index for `docs` at `path` (which must not exist). Returns the number of sections indexed."""
38
+ rows = 0
39
+ with contextlib.closing(sqlite3.connect(path)) as db:
40
+ db.execute(f"PRAGMA user_version = {SCHEMA_VERSION}")
41
+ db.execute(
42
+ "CREATE VIRTUAL TABLE sections USING fts5("
43
+ "doc UNINDEXED, sec UNINDEXED, title, body, tokenize='porter unicode61')"
44
+ )
45
+ for doc in docs:
46
+ for section in doc.sections:
47
+ db.execute(
48
+ "INSERT INTO sections(doc, sec, title, body) VALUES (?, ?, ?, ?)",
49
+ (doc.id, section.id, section.title, "\n".join(section.units)),
50
+ )
51
+ rows += 1
52
+ db.commit()
53
+ return rows
54
+
55
+
56
+ def _match(terms: Sequence[str], joiner: str) -> str:
57
+ return f" {joiner} ".join('"' + t.replace('"', '""') + '"' for t in terms)
58
+
59
+
60
+ def search(path: Path, query: str, limit: int = 10, offset: int = 0, doc_prefix: str = "") -> tuple[list[Hit], bool]:
61
+ """Sections matching `query`, best first, and whether the match was relaxed (OR instead of AND). An empty
62
+ list means nothing matched; a missing or unreadable index is a `LibError` that names the remedy."""
63
+ terms = _TERM.findall(query)[:MAX_TERMS]
64
+ if not terms:
65
+ raise LibError(f"nothing to search for in {query!r}", "give at least one word")
66
+ if not path.is_file():
67
+ raise LibError(f"there is no search index at {path}", "", "import the edition again")
68
+ try:
69
+ with contextlib.closing(sqlite3.connect(f"{path.resolve().as_uri()}?mode=ro", uri=True)) as db:
70
+ if db.execute("PRAGMA user_version").fetchone()[0] != SCHEMA_VERSION:
71
+ raise LibError("the search index has a different schema version", "", "import the edition again")
72
+ for relaxed, joiner in ((False, "AND"), (True, "OR")):
73
+ if relaxed and len(terms) == 1:
74
+ break
75
+ like = doc_prefix.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + "%"
76
+ found = db.execute(
77
+ "SELECT doc, sec, title, snippet(sections, 3, '[', ']', ' ... ', 24), " # noqa: S608
78
+ f"bm25(sections, {WEIGHTS}) "
79
+ "FROM sections WHERE sections MATCH ? AND doc LIKE ? ESCAPE '\\' "
80
+ f"ORDER BY bm25(sections, {WEIGHTS}) LIMIT ? OFFSET ?",
81
+ (_match(terms, joiner), like, limit, offset),
82
+ ).fetchall()
83
+ if found:
84
+ return [
85
+ Hit(str(r[0]), str(r[1]), str(r[2]), " ".join(str(r[3]).split()), float(r[4])) for r in found
86
+ ], relaxed
87
+ except sqlite3.DatabaseError as exc:
88
+ raise LibError(f"the search index is unreadable ({exc})", "", "import the edition again") from exc
89
+ return [], False
@@ -0,0 +1,76 @@
1
+ """The document a reader returns and the store keeps.
2
+
3
+ Like `primarysource.model.Document` but each section carries an `anchor`, because Markdown, reStructuredText,
4
+ AsciiDoc and LaTeX address sections by a label or a slug as well as by position. When the core's `Section` gains
5
+ `anchor` (ROADMAP `cl.model_anchor`) this module reduces to a conversion.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from collections.abc import Mapping
11
+ from dataclasses import dataclass, field
12
+ from typing import Any
13
+
14
+ from primarysource.errors import LibError
15
+ from primarysource.model import Unit
16
+
17
+
18
+ @dataclass(frozen=True, slots=True)
19
+ class Sec:
20
+ """One addressable part of a document. `id` is an ordinal path (`2.1`); `units` are in reading order and
21
+ `¶1` is `units[0]`. `depth` is the nesting depth, 1 for a top-level section."""
22
+
23
+ id: str
24
+ title: str
25
+ depth: int
26
+ anchor: str
27
+ units: tuple[str, ...] = ()
28
+
29
+
30
+ @dataclass(frozen=True, slots=True)
31
+ class Doc:
32
+ """A document: an id, a title, the unit kind, its sections in order, free-form `meta` (source path, status,
33
+ licence note) and the `warnings` a reader raised about what it could not represent."""
34
+
35
+ id: str
36
+ title: str
37
+ unit: Unit
38
+ sections: tuple[Sec, ...] = ()
39
+ meta: Mapping[str, str] = field(default_factory=dict)
40
+ warnings: tuple[str, ...] = ()
41
+
42
+ def section(self, section_id: str) -> Sec | None:
43
+ for candidate in self.sections:
44
+ if candidate.id == section_id:
45
+ return candidate
46
+ return None
47
+
48
+ def with_meta(self, **more: str) -> Doc:
49
+ return Doc(self.id, self.title, self.unit, self.sections, {**self.meta, **more}, self.warnings)
50
+
51
+ def to_json(self) -> dict[str, Any]:
52
+ return {
53
+ "id": self.id,
54
+ "title": self.title,
55
+ "unit": self.unit.value,
56
+ "meta": dict(self.meta),
57
+ "warnings": list(self.warnings),
58
+ "sections": [[s.id, s.title, s.depth, s.anchor, list(s.units)] for s in self.sections],
59
+ }
60
+
61
+ @staticmethod
62
+ def from_json(data: Mapping[str, Any]) -> Doc:
63
+ try:
64
+ sections = tuple(
65
+ Sec(str(r[0]), str(r[1]), int(r[2]), str(r[3]), tuple(str(u) for u in r[4])) for r in data["sections"]
66
+ )
67
+ return Doc(
68
+ str(data["id"]),
69
+ str(data["title"]),
70
+ Unit(data["unit"]),
71
+ sections,
72
+ {str(k): str(v) for k, v in dict(data["meta"]).items()},
73
+ tuple(str(w) for w in data["warnings"]),
74
+ )
75
+ except (KeyError, IndexError, TypeError, ValueError) as exc:
76
+ raise LibError(f"a stored document is damaged ({exc!r})", "", "import the edition again") from exc
@@ -0,0 +1 @@
1
+ """Minimal, bounded readers: bytes in, a `Doc` out. See `primarysource.pkgdocs` for what "bounded" promises."""
@@ -0,0 +1,134 @@
1
+ """What every reader needs: strict decoding, slugs and the section builder."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ import unicodedata
7
+
8
+ from primarysource.errors import LibError
9
+ from primarysource.model import Unit
10
+ from primarysource.pkgdocs.model import Doc, Sec
11
+
12
+ _SLUG_DROP = re.compile(r"[^\w\- ]", re.UNICODE)
13
+
14
+
15
+ def decode(data: bytes, what: str) -> str:
16
+ """UTF-8 text with line endings normalised to `\\n`. A NUL byte or invalid UTF-8 is an error, never a guess:
17
+ a reader that substituted characters would quietly change what a citation says."""
18
+ if b"\x00" in data:
19
+ raise LibError(f"{what} contains a NUL byte; it is not text", "check that the source is the expected file")
20
+ try:
21
+ text = data.decode("utf-8-sig")
22
+ except UnicodeDecodeError as exc:
23
+ raise LibError(
24
+ f"{what} is not valid UTF-8 ({exc.reason} at byte {exc.start})",
25
+ "the collection reads UTF-8 sources only; report the file rather than converting it",
26
+ ) from exc
27
+ if "\r" in text:
28
+ text = text.replace("\r\n", "\n").replace("\r", "\n")
29
+ return text
30
+
31
+
32
+ _ROLE = re.compile(r":[A-Za-z][\w:+.-]*:`([^`]*)`")
33
+
34
+
35
+ def clean_title(title: str) -> str:
36
+ """A heading as plain text: RST roles and inline-literal backticks removed, whitespace collapsed."""
37
+ return " ".join(_ROLE.sub(chr(92) + "1", title).replace("`", "").split())
38
+
39
+
40
+ def slugify(title: str) -> str:
41
+ """GitHub's heading slug: lowercase, punctuation dropped, spaces to hyphens. This is the rule used here, not
42
+ a claim that any specification defines it."""
43
+ text = unicodedata.normalize("NFKC", title).strip().lower()
44
+ return _SLUG_DROP.sub("", text).replace(" ", "-")
45
+
46
+
47
+ class Builder:
48
+ """Collects headings and units into sections with ordinal-path ids and unique anchors.
49
+
50
+ `heading(level, ...)` takes the format's own level; levels may skip (h1 to h3) and ids follow the nesting
51
+ actually seen, so a skipped level does not leave a gap in the path. Text before the first heading goes in
52
+ section `0` titled with the document title. With `base="5"` the document is one numbered unit of a larger
53
+ work (a chapter): `root()` opens section `5` and every later heading is `5.<n>`.
54
+ """
55
+
56
+ def __init__(self, base: str = "") -> None:
57
+ self._base = base
58
+ self._ids: list[str] = []
59
+ self._titles: list[str] = []
60
+ self._depths: list[int] = []
61
+ self._anchors_of: list[str] = []
62
+ self._units: list[list[str]] = []
63
+ self._stack: list[int] = [] # levels of the open sections
64
+ self._counts: list[int] = []
65
+ self._used: set[str] = set()
66
+ self._current: list[str] | None = None
67
+ self._preamble_title = ""
68
+ self._rooted = False
69
+
70
+ def set_preamble_title(self, title: str) -> None:
71
+ self._preamble_title = title
72
+
73
+ def _unique(self, anchor: str) -> str:
74
+ if not anchor:
75
+ anchor = "section"
76
+ candidate, n = anchor, 0
77
+ while candidate in self._used:
78
+ n += 1
79
+ candidate = f"{anchor}-{n}"
80
+ self._used.add(candidate)
81
+ return candidate
82
+
83
+ def _open(self, section_id: str, title: str, depth: int, anchor: str) -> None:
84
+ units: list[str] = []
85
+ self._ids.append(section_id)
86
+ self._titles.append(title)
87
+ self._depths.append(depth)
88
+ self._anchors_of.append(self._unique(anchor))
89
+ self._units.append(units)
90
+ self._current = units
91
+
92
+ def root(self, title: str, anchor: str = "") -> None:
93
+ """Open the numbered root section (`base`, or `0`) with the document's own title."""
94
+ self._rooted = True
95
+ self._stack = []
96
+ title = clean_title(title)
97
+ self._open(self._base or "0", title, 1, anchor or slugify(title))
98
+
99
+ def heading(self, level: int, title: str, anchor: str = "") -> None:
100
+ title = clean_title(title)
101
+ while self._stack and self._stack[-1] >= level:
102
+ self._stack.pop()
103
+ depth = len(self._stack) + 1
104
+ del self._counts[depth:]
105
+ while len(self._counts) < depth:
106
+ self._counts.append(0)
107
+ self._counts[depth - 1] += 1
108
+ path = ".".join(str(n) for n in self._counts[:depth])
109
+ section_id = f"{self._base}.{path}" if self._base else path
110
+ self._stack.append(level)
111
+ self._open(section_id, title, depth + (1 if self._rooted else 0), anchor or slugify(title))
112
+
113
+ def unit(self, text: str) -> None:
114
+ if not text.strip():
115
+ return
116
+ if self._current is None:
117
+ self._open(self._base or "0", self._preamble_title, 1, slugify(self._preamble_title) or "preamble")
118
+ if self._current is not None:
119
+ self._current.append(text)
120
+
121
+ def sections(self) -> tuple[Sec, ...]:
122
+ return tuple(
123
+ Sec(i, t, d, a, tuple(u))
124
+ for i, t, d, a, u in zip(
125
+ self._ids, self._titles, self._depths, self._anchors_of, self._units, strict=True
126
+ )
127
+ )
128
+
129
+ def doc(self, doc_id: str, title: str, *, unit: Unit = Unit.BLOCK, warnings: tuple[str, ...] = ()) -> Doc:
130
+ sections = self.sections()
131
+ if sections and not sections[0].title:
132
+ first = sections[0]
133
+ sections = (Sec(first.id, title or doc_id, first.depth, first.anchor, first.units), *sections[1:])
134
+ return Doc(doc_id, title or doc_id, unit, sections, {}, warnings)
@@ -0,0 +1,160 @@
1
+ """A minimal AsciiDoc reader for git's Documentation: sections, blocks, anchors; nothing is executed or opened.
2
+
3
+ What it reads:
4
+
5
+ - the document title (`= Title`, or a line underlined with `=`) is the document's title, not a section;
6
+ - sections by `==` ... `======` or by an underline of `-` (level 1), `~` (2), `^` (3), `+` (4) whose length is
7
+ within one of the title's, as asciidoc requires;
8
+ - explicit anchors `[[id]]`, `[[id,text]]`, `[#id]` and `[id=...]` before a heading, else the GitHub-style slug;
9
+ - blocks (the unit, so `¶n` counts blocks): a run of non-blank lines; a delimited block (`----`, `....`, `====`,
10
+ `****`, `____`, `++++`, a `|===` table) as one block with its delimiters dropped; and every labelled-list term
11
+ (`-a::`) or list marker (`* `, `. `, `1. `) starts a new block even without a blank line before it;
12
+ - a block title (`.Title`) leads the block it titles.
13
+
14
+ What it does not do, by design:
15
+
16
+ - `include::path[]` is **never read**. It becomes the block `[include] path`, so the text says where the rest is;
17
+ the included file is its own document in the same collection;
18
+ - `ifdef::`/`ifndef::`/`ifeval::`/`endif::` lines are dropped and their content kept unconditionally, and the
19
+ document carries a warning with the count, because the condition's result is the build's, not the text's;
20
+ - attribute entries (`:name: value`), comments (`//`, `////` blocks), block attribute lines (`[verse]`) and the
21
+ `+` list continuation marker are dropped;
22
+ - inline markup (`linkgit:`, backticks, `'emphasis'`) is left as written.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import re
28
+
29
+ from primarysource.model import Unit
30
+ from primarysource.pkgdocs.model import Doc
31
+ from primarysource.pkgdocs.readers._common import Builder, decode
32
+
33
+ _UNDERLINE_LEVEL = {"=": 0, "-": 1, "~": 2, "^": 3, "+": 4}
34
+ _ATX = re.compile(r"(={1,6}) +(\S.*)")
35
+ _DELIM = re.compile(r"(-{4,}|\.{4,}|={4,}|\*{4,}|_{4,}|\+{4,}|/{4,}|[|!,:]={3,})")
36
+ _LIST = re.compile(r"\s*(?:\*{1,5}|-|\.{1,5}|\d{1,3}\.|[a-zA-Z]\.)\s+\S")
37
+ _TERM = re.compile(r"\S.*?(?:::|;;|:::|::::)(?:\s|$)")
38
+ _ATTR_ENTRY = re.compile(r":!?[\w][\w-]*!?:(?:\s.*)?")
39
+ _COND_PREFIXES = ("ifdef::", "ifndef::", "ifeval::", "endif::")
40
+ _ANCHOR = re.compile(r"\[\[([A-Za-z_][\w.:-]*)(?:,[^\]]*)?\]\]\s*")
41
+ _ATTRLINE = re.compile(r"\[(?!\[)[^\]]*\]\s*")
42
+ _BLOCKTITLE = re.compile(r"\.[^\s.].*")
43
+
44
+
45
+ def _is_underline(line: str, title: str) -> int | None:
46
+ """The level an underline gives `title`, or None when `line` is not its underline."""
47
+ if len(line) < 2 or line[0] not in _UNDERLINE_LEVEL or line.count(line[0]) != len(line):
48
+ return None
49
+ if abs(len(line) - len(title)) > 1 or not title.strip() or title[0] in " \t[.*-+":
50
+ return None
51
+ return _UNDERLINE_LEVEL[line[0]]
52
+
53
+
54
+ def read_asciidoc(data: bytes, doc_id: str, title: str | None = None) -> Doc:
55
+ """Read one AsciiDoc file. Raises `LibError` for NUL bytes or invalid UTF-8; everything else reads."""
56
+ text = decode(data, f"AsciiDoc file {doc_id!r}")
57
+ lines = text.expandtabs(8).split("\n")
58
+ builder = Builder()
59
+ doc_title = title or ""
60
+ title_set = bool(title)
61
+ pending_anchor = ""
62
+ pending_title = ""
63
+ conditionals = 0
64
+ para: list[str] = []
65
+
66
+ def flush() -> None:
67
+ nonlocal pending_title
68
+ if para:
69
+ block = "\n".join(para)
70
+ if pending_title:
71
+ block = pending_title + "\n" + block
72
+ pending_title = ""
73
+ builder.unit(block)
74
+ para.clear()
75
+
76
+ def heading(level: int, name: str) -> None:
77
+ nonlocal doc_title, title_set, pending_anchor
78
+ name = name.strip()
79
+ if level == 0 and not title_set and not doc_title:
80
+ doc_title = name
81
+ title_set = True
82
+ builder.set_preamble_title(name)
83
+ pending_anchor = ""
84
+ return
85
+ builder.heading(max(level, 1), name, pending_anchor)
86
+ pending_anchor = ""
87
+
88
+ i, count = 0, len(lines)
89
+ while i < count:
90
+ line = lines[i].rstrip()
91
+ i += 1
92
+ stripped = line.strip()
93
+ if not stripped:
94
+ flush()
95
+ continue
96
+ # an underlined title: exactly one paragraph line above, this line is its underline
97
+ if len(para) == 1:
98
+ level = _is_underline(line, para[0])
99
+ if level is not None and not _LIST.match(para[0]):
100
+ name = para.pop()
101
+ heading(level, name)
102
+ continue
103
+ if line.startswith(_COND_PREFIXES) and line.endswith("]") and "[" in line:
104
+ conditionals += 1
105
+ continue
106
+ if line.startswith("//") and not line.startswith("///"):
107
+ continue
108
+ if _ATTR_ENTRY.fullmatch(stripped) and not para:
109
+ continue
110
+ match = _DELIM.fullmatch(line)
111
+ if match and not (len(para) == 1 and _is_underline(line, para[0]) is not None):
112
+ flush()
113
+ body: list[str] = []
114
+ while i < count and lines[i].rstrip() != line:
115
+ body.append(lines[i].rstrip())
116
+ i += 1
117
+ i += 1 # the closing delimiter, or the end of the file for an unclosed block
118
+ if line.startswith("/"):
119
+ continue
120
+ block = "\n".join(body).strip("\n")
121
+ if pending_title:
122
+ block = pending_title + "\n" + block
123
+ pending_title = ""
124
+ builder.unit(block)
125
+ continue
126
+ if line == "--":
127
+ flush()
128
+ continue
129
+ if line == "+":
130
+ continue
131
+ atx = _ATX.fullmatch(line)
132
+ if atx and not para:
133
+ heading(len(atx.group(1)) - 1, atx.group(2))
134
+ continue
135
+ if line.startswith("include::") and line.endswith("]") and "[" in line:
136
+ flush()
137
+ builder.unit("[include] " + line[len("include::") : line.index("[")])
138
+ continue
139
+ anchor = _ANCHOR.fullmatch(line)
140
+ if anchor and not para:
141
+ pending_anchor = anchor.group(1)
142
+ continue
143
+ if _ATTRLINE.fullmatch(line) and not para:
144
+ found = re.search(r"#([A-Za-z_][\w.:-]*)|\bid=([A-Za-z_][\w.:-]*)", line)
145
+ if found:
146
+ pending_anchor = found.group(1) or found.group(2)
147
+ continue
148
+ if not para and _BLOCKTITLE.fullmatch(line):
149
+ pending_title = line
150
+ continue
151
+ if para and (_LIST.match(line) or _TERM.match(line) and not line.startswith(" ")):
152
+ flush()
153
+ para.append(line)
154
+ flush()
155
+ warnings: tuple[str, ...] = ()
156
+ if conditionals:
157
+ warnings = (f"{conditionals} ifdef/ifndef/ifeval/endif lines dropped; their content is kept unconditionally",)
158
+ if not doc_title:
159
+ doc_title = doc_id
160
+ return builder.doc(doc_id, doc_title, unit=Unit.BLOCK, warnings=warnings)