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,466 @@
1
+ """Query API shared by the CLI and the MCP server. Everything reads the local
2
+ DB/mirror; content missing locally is fetched on demand from rfc-editor.org."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import hashlib
7
+ import json
8
+ import re
9
+ import sqlite3
10
+ from pathlib import Path
11
+
12
+ from . import subjects as subj
13
+ from .config import BASE_URL
14
+ from .sync import downloader
15
+ from .sync.state import file_path
16
+
17
+ DEFAULT_READ_CHARS = 6000
18
+
19
+
20
+ class NotFound(Exception):
21
+ pass
22
+
23
+
24
+ def normalize_doc_id(raw: str) -> str:
25
+ """Accept 'rfc 9000', '9000', 'RFC9000', 'bcp-14', 'std1' -> 'RFC9000' etc."""
26
+ s = re.sub(r"[\s_/,]+", "", raw.strip().lower())
27
+ if re.fullmatch(r"\d{1,5}", s):
28
+ s = "rfc" + s
29
+ m = re.fullmatch(r"(rfc|bcp|std|fyi|ien)(\d{1,5})", s)
30
+ if not m:
31
+ raise ValueError(f"not a valid RFC-series id: {raw!r}")
32
+ return m.group(1).upper() + m.group(2)
33
+
34
+
35
+ def get_doc_row(conn: sqlite3.Connection, doc_id: str) -> sqlite3.Row:
36
+ row = conn.execute("SELECT * FROM docs WHERE doc_id = ?", (doc_id,)).fetchone()
37
+ if row is None:
38
+ raise NotFound(doc_id)
39
+ return row
40
+
41
+
42
+ def resolve(conn: sqlite3.Connection, doc_id: str) -> str:
43
+ """Follow is-also aliases (BCP/STD/FYI -> underlying RFC). Returns the
44
+ doc_id whose content should be served."""
45
+ cur = doc_id
46
+ for _ in range(6):
47
+ if cur.startswith("RFC"):
48
+ return cur # reached a content-bearing RFC; stop (avoids alias loops)
49
+ rows = conn.execute(
50
+ "SELECT dst FROM doc_relations WHERE src=? AND kind='alias'", (cur,)
51
+ ).fetchall()
52
+ if not rows:
53
+ return cur
54
+ targets = [r["dst"] for r in rows]
55
+ best = next((t for t in targets if t.startswith("RFC")), None)
56
+ if best is None or best == cur:
57
+ return cur
58
+ cur = best
59
+ return cur
60
+
61
+
62
+ def _relations(conn: sqlite3.Connection, doc_id: str) -> dict[str, list[str]]:
63
+ # The manifest records each obsoletes/updates pair from both sides; union
64
+ # the direct and inverse views and de-duplicate.
65
+ out: dict[str, set[str]] = {}
66
+ for r in conn.execute(
67
+ "SELECT kind, dst FROM doc_relations WHERE src=?", (doc_id,)):
68
+ out.setdefault(r["kind"], set()).add(r["dst"])
69
+ inverse = {"obsoletes": "obsoleted_by", "obsoleted_by": "obsoletes",
70
+ "updates": "updated_by", "updated_by": "updates", "alias": "alias"}
71
+ for r in conn.execute(
72
+ "SELECT kind, src FROM doc_relations WHERE dst=?", (doc_id,)):
73
+ out.setdefault(inverse[r["kind"]], set()).add(r["src"])
74
+ return {k: sorted(v) for k, v in out.items()}
75
+
76
+
77
+ def _row_to_meta(conn: sqlite3.Connection, row: sqlite3.Row) -> dict:
78
+ d = dict(row)
79
+ d["authors"] = json.loads(d.get("authors") or "[]")
80
+ d["formats_offered"] = json.loads(d.get("formats") or "[]")
81
+ d.pop("formats", None)
82
+ d["formats_local"] = [
83
+ r["fmt"] for r in conn.execute(
84
+ "SELECT fmt FROM files WHERE doc_id=? ORDER BY fmt", (d["doc_id"],))]
85
+ d["relations"] = _relations(conn, d["doc_id"])
86
+ d["subjects"] = subj.subjects_of(conn, d["doc_id"])
87
+ d["canonical_url"] = (
88
+ f"{BASE_URL}/info/{d['doc_id'].lower()}" if d["series"] == "RFC"
89
+ else f"{BASE_URL}/{d['series'].lower()}/{d['doc_id'].lower()}/")
90
+ return d
91
+
92
+
93
+ def get_metadata(conn: sqlite3.Connection, doc_id: str) -> dict:
94
+ row = get_doc_row(conn, doc_id)
95
+ meta = _row_to_meta(conn, row)
96
+ if row["series"] != "RFC":
97
+ aliases = [a for a in meta["relations"].get("alias", []) if a != doc_id]
98
+ if aliases:
99
+ targets = []
100
+ for a in aliases:
101
+ try:
102
+ targets.append(get_metadata(conn, a))
103
+ except NotFound:
104
+ pass
105
+ if targets:
106
+ meta["alias_of"] = targets
107
+ return meta
108
+
109
+
110
+ def search(conn: sqlite3.Connection, q: str, limit: int = 10,
111
+ include_obsolete: bool = False, stream: str | None = None,
112
+ status: str | None = None,
113
+ subject: str | None = None) -> list[dict]:
114
+ limit = max(1, min(limit, 50))
115
+ words = re.findall(r"[A-Za-z0-9]+", q)
116
+ params: list = []
117
+ where = []
118
+ if not include_obsolete:
119
+ where.append("d.is_current = 1")
120
+ if stream:
121
+ where.append("d.stream = ?")
122
+ params.append(stream.upper())
123
+ if status:
124
+ where.append("(d.current_status LIKE ? OR d.status LIKE ?)")
125
+ params += [f"%{status.upper()}%", f"%{status.upper()}%"]
126
+ if subject:
127
+ slugs = subj._subtree_slugs(conn, subj.resolve_subject(conn, subject))
128
+ where.append("d.doc_id IN (SELECT doc_id FROM doc_subjects "
129
+ f"WHERE slug IN ({','.join('?' * len(slugs))}))")
130
+ params.extend(slugs)
131
+ tail = (" AND " + " AND ".join(where)) if where else ""
132
+
133
+ rows: list[sqlite3.Row] = []
134
+ if words:
135
+ match = " AND ".join(f'"{w}"' for w in words)
136
+ try:
137
+ rows = conn.execute(
138
+ f"""SELECT d.*, bm25(docs_fts) AS rank FROM docs_fts
139
+ JOIN docs d ON d.doc_id = docs_fts.doc_id
140
+ WHERE docs_fts MATCH ?{tail}
141
+ ORDER BY rank LIMIT ?""",
142
+ [match, *params, limit]).fetchall()
143
+ except sqlite3.OperationalError:
144
+ rows = []
145
+ if not rows and words: # LIKE fallback (doc-number fragments etc.)
146
+ like = f"%{q.strip()}%"
147
+ rows = conn.execute(
148
+ f"""SELECT d.* FROM docs d
149
+ WHERE (d.doc_id LIKE ? OR d.title LIKE ?){tail}
150
+ ORDER BY d.pub_date DESC LIMIT ?""",
151
+ [like, like, *params, limit]).fetchall()
152
+ return [_row_to_meta(conn, r) for r in rows]
153
+
154
+
155
+ def _content_path(conn: sqlite3.Connection, doc_id: str, fmt: str,
156
+ mirror_root: Path, fetch: bool) -> Path | None:
157
+ row = conn.execute(
158
+ "SELECT path FROM files WHERE doc_id=? AND fmt=?",
159
+ (doc_id, fmt)).fetchone()
160
+ if row:
161
+ local = file_path(conn, row["path"], mirror_root)
162
+ if local.exists():
163
+ return local
164
+ if not fetch:
165
+ return None
166
+ results = downloader.fetch_docs(conn, mirror_root, [doc_id], [fmt])
167
+ conn.commit()
168
+ for d, f, status in results:
169
+ if status == "ok":
170
+ rel = downloader.doc_relpath(d, f)
171
+ if rel:
172
+ return mirror_root / rel
173
+ return None
174
+
175
+
176
+ def get_document(conn: sqlite3.Connection, doc_id: str, fmt: str = "txt",
177
+ mirror_root: Path | None = None, start_line: int = 1,
178
+ max_lines: int = 400) -> dict:
179
+ """Return (possibly paginated) document text plus metadata flags."""
180
+ row = get_doc_row(conn, doc_id)
181
+ meta = _row_to_meta(conn, row)
182
+ root = mirror_root if mirror_root is not None else Path("mirror")
183
+ content_id = doc_id
184
+
185
+ path = _content_path(conn, doc_id, fmt, root, fetch=True)
186
+ if path is None:
187
+ target = resolve(conn, doc_id) # e.g. BCP14 -> RFC2119
188
+ if target != doc_id:
189
+ content_id = target
190
+ path = _content_path(conn, target, fmt, root, fetch=True)
191
+
192
+ if path is None:
193
+ return {
194
+ "doc_id": doc_id,
195
+ "metadata": meta,
196
+ "content": None,
197
+ "note": f"no {fmt.upper()} rendering available from RFC Editor "
198
+ f"for this document; try format='txt' or get_metadata",
199
+ }
200
+
201
+ text = path.read_text(encoding="utf-8", errors="replace")
202
+ if text.startswith("\ufeff"):
203
+ text = text.lstrip("\ufeff")
204
+ lines = text.splitlines()
205
+ total = len(lines)
206
+ start = max(1, start_line)
207
+ chunk = "\n".join(lines[start - 1: start - 1 + max_lines])
208
+ return {
209
+ "doc_id": doc_id,
210
+ "content_served_via": content_id if content_id != doc_id else None,
211
+ "format": fmt,
212
+ "path": str(path),
213
+ "total_lines": total,
214
+ "start_line": start,
215
+ "end_line": min(total, start + max_lines - 1),
216
+ "truncated": start - 1 + max_lines < total,
217
+ "content": chunk,
218
+ "is_obsolete": not bool(row["is_current"]),
219
+ "obsoleted_by": meta["relations"].get("obsoleted_by", []),
220
+ "metadata": {k: meta[k] for k in
221
+ ("title", "status", "current_status", "stream", "pub_date",
222
+ "authors", "doi", "errata_url", "canonical_url")},
223
+ }
224
+
225
+
226
+ def span_hash(text: str) -> str:
227
+ """16 hex chars of sha256 over the canonical span text (what quote()
228
+ returns after page-furniture stripping). Short enough to live in a code
229
+ comment, long enough to detect drift; files.sha256 stays authoritative
230
+ for the whole document."""
231
+ return hashlib.sha256(text.encode("utf-8", "replace")).hexdigest()[:16]
232
+
233
+
234
+ def _url_of(meta: dict) -> str:
235
+ return meta.get("canonical_url") or ""
236
+
237
+
238
+ def anchor_url(meta: dict, frag: str = "", served: str | None = None) -> str:
239
+ """URL that honours a section/paragraph fragment. The /info/ page has no
240
+ section anchors, so fragments point at the RFC's HTML rendering (of the
241
+ RFC that carries the text, for BCP/STD aliases)."""
242
+ if not frag:
243
+ return _url_of(meta)
244
+ doc = served or meta["doc_id"]
245
+ if not doc.startswith("RFC"):
246
+ return _url_of(meta)
247
+ return f"{BASE_URL}/rfc/{doc.lower()}.html#{frag}"
248
+
249
+
250
+ def _served_text(conn: sqlite3.Connection, doc_id: str, fmt: str,
251
+ mirror_root: Path) -> tuple[str | None, str, Path | None]:
252
+ """(text, content_doc_id, path), following BCP/STD aliases to the RFC
253
+ that carries the text. Missing files are fetched on demand."""
254
+ path = _content_path(conn, doc_id, fmt, mirror_root, fetch=True)
255
+ served = doc_id
256
+ if path is None:
257
+ target = resolve(conn, doc_id)
258
+ if target != doc_id:
259
+ served = target
260
+ path = _content_path(conn, target, fmt, mirror_root, fetch=True)
261
+ if path is None:
262
+ return None, served, None
263
+ text = path.read_text(encoding="utf-8", errors="replace")
264
+ return text.lstrip("\ufeff"), served, path
265
+
266
+
267
+ def _fit(body: str, max_chars: int) -> tuple[str, int | None]:
268
+ """Cut a text block at a line boundary; return (text, lines_shown+1)."""
269
+ if len(body) <= max_chars:
270
+ return body, None
271
+ cut = body.rfind("\n", 0, max_chars)
272
+ if cut <= 0:
273
+ cut = max_chars
274
+ shown = body[:cut]
275
+ return shown, shown.count("\n") + 1
276
+
277
+
278
+ def read_document(conn: sqlite3.Connection, doc_id: str, *,
279
+ mirror_root: Path, fmt: str = "txt",
280
+ section: str | None = None, start_line: int | None = None,
281
+ count: int | None = None, mode: str = "clean",
282
+ max_chars: int = DEFAULT_READ_CHARS,
283
+ deep: bool = True) -> str:
284
+ """Plain text of a document or ONE section of it — returned as a string,
285
+ because JSON-escaping a text block roughly doubles what it costs to read.
286
+
287
+ section: '3.4', 'Appendix C', 'C.1', a range ('3.1/3.3') or a title
288
+ substring ('ABNF') — resolved through the section index, so there is no
289
+ line arithmetic and no page furniture to pay for.
290
+ mode: 'clean' (default) drops running headers/footers and [Page N]
291
+ markers (chunker.strip_page_furniture); 'raw' is byte-exact, which is
292
+ what quote()/verify() use.
293
+ max_chars is a hard budget and the result always ends with a footer
294
+ naming the line to continue from — nothing disappears silently.
295
+ Non-txt formats come back as a raw slice (section addressing is txt-only).
296
+ """
297
+ from .index import search as _s
298
+ from .index.chunker import strip_page_furniture
299
+ row = get_doc_row(conn, doc_id)
300
+ text, served, _path = _served_text(conn, doc_id, fmt, mirror_root)
301
+ meta = _row_to_meta(conn, row)
302
+ if text is None:
303
+ return (f"NO CONTENT: {doc_id} has no {fmt.upper()} rendering at the "
304
+ f"RFC Editor. Try format='txt', or get_metadata.")
305
+ obsolete = "" if meta["is_current"] else (
306
+ " | OBSOLETE (superseded by "
307
+ + (", ".join(meta["relations"].get("obsoleted_by", [])) or "n/a") + ")")
308
+ lines = text.splitlines()
309
+ total = len(lines)
310
+ label, frag, sec_note = "", "", ""
311
+
312
+ if fmt != "txt":
313
+ start = max(1, start_line or 1)
314
+ end = min(total, start + (count or 200) - 1)
315
+ shown, nxt = _fit("\n".join(lines[start - 1:end]), max_chars)
316
+ return (f"{doc_id} ({fmt}) | lines {start}-{end} of {total}{obsolete}\n"
317
+ f"{_url_of(meta)}\n{shown}"
318
+ + ("" if nxt is None else
319
+ f"\n[truncated at {max_chars} chars — continue with "
320
+ f"start_line={start + nxt}]"))
321
+
322
+ if section:
323
+ span = _resolve_section_arg(conn, served, section)
324
+ if not span.get("ok"):
325
+ extra = ""
326
+ if span.get("suggestions"):
327
+ extra = "\nsimilar sections: " + ", ".join(span["suggestions"])
328
+ if span.get("outline"):
329
+ extra += "\noutline: " + "; ".join(span["outline"][:20])
330
+ return (f"NO SUCH SECTION: {doc_id} {section}\n"
331
+ f"{span.get('reason')}{extra}\n"
332
+ f"retry: read(doc_id='{doc_id}', section='<one of above>')")
333
+ start, end = span["start_line"], span["end_line"]
334
+ if not deep:
335
+ rows = _s.section_rows(conn, served, section, deep=False)
336
+ if rows:
337
+ end = max(r["end_line"] for r in rows)
338
+ sec_note = " (own text only, deep=False)"
339
+ label = f" \u00a7{span['sec_num']}" + (
340
+ f" \"{span['sec_title']}\"" if span.get("sec_title") else "")
341
+ frag = span.get("url_frag", "")
342
+ else:
343
+ start = max(1, start_line or 1)
344
+ end = min(total, start + (count or 300) - 1)
345
+
346
+ body = "\n".join(lines[start - 1:end])
347
+ if mode == "clean":
348
+ body = strip_page_furniture(body)
349
+ shown, nxt = _fit(body, max_chars)
350
+ head = (f"{doc_id}{label} | lines {start}-{end} of {total}{sec_note} | "
351
+ f"mode={mode} | sha256:{span_hash(body)}{obsolete}\n"
352
+ f"{anchor_url(meta, frag, served)}\n")
353
+ foot = ""
354
+ if nxt is not None:
355
+ foot = (f"\n[truncated at {max_chars} chars — continue with "
356
+ f"start_line={start + nxt}"
357
+ + (f", section='{section}'" if section else "") + "]")
358
+ return head + shown + foot
359
+
360
+
361
+ def _resolve_section_arg(conn: sqlite3.Connection, doc_id: str,
362
+ section: str) -> dict:
363
+ """resolve_span plus two conveniences callers always want: a range
364
+ ('3.1/3.3') and a title substring ('ABNF', 'Security Considerations')."""
365
+ from .index import search as _s
366
+ if not section.strip().startswith("Appendix"):
367
+ for sep in ("-", "\u2013", "/"):
368
+ if sep not in section:
369
+ continue
370
+ a, b = [p.strip() for p in section.split(sep, 1)][:2]
371
+ r1 = _s.resolve_span(conn, doc_id, a)
372
+ r2 = _s.resolve_span(conn, doc_id, b)
373
+ if r1.get("ok") and r2.get("ok"):
374
+ return {"ok": True,
375
+ "sec_num": f"{r1['sec_num']}-{r2['sec_num']}",
376
+ "sec_title": "range", "start_line": r1["start_line"],
377
+ "end_line": r2["end_line"],
378
+ "url_frag": r1["url_frag"], "subsections": []}
379
+ span = _s.resolve_span(conn, doc_id, section)
380
+ if not span.get("ok") and span.get("indexed"):
381
+ like = f"%{(section or '').strip().lower()}%"
382
+ row = conn.execute(
383
+ "SELECT sec_num, MIN(start_line) a, MAX(end_line) b FROM chunks "
384
+ "WHERE doc_id=? AND lower(sec_title) LIKE ? AND sec_num <> '' "
385
+ "GROUP BY sec_num ORDER BY a", (doc_id, like)).fetchone()
386
+ if row:
387
+ return {"ok": True, "sec_num": row["sec_num"],
388
+ "sec_title": section.strip().title(),
389
+ "start_line": row["a"], "end_line": row["b"],
390
+ "url_frag": _s.url_frag(row["sec_num"]),
391
+ "subsections": []}
392
+ return span
393
+
394
+
395
+ def grep_document(conn: sqlite3.Connection, doc_id: str, pattern: str, *,
396
+ mirror_root: Path, ctx: int = 0, max_hits: int = 25,
397
+ ignore_case: bool = True,
398
+ max_chars: int = DEFAULT_READ_CHARS) -> str:
399
+ """Every line matching a regex inside ONE document, labelled with the
400
+ section it belongs to — the cheap way to locate an ABNF rule or keyword
401
+ instead of paging through a spec hoping to land on it.
402
+
403
+ Invalid regexes fall back to a literal search. A hit is citable as-is
404
+ (section + line); follow up with read(section=...) rather than widening
405
+ the grep.
406
+ """
407
+ import bisect
408
+ import re as _re
409
+ get_doc_row(conn, doc_id)
410
+ try:
411
+ rx = _re.compile(pattern, _re.I if ignore_case else 0)
412
+ shown_pat = f"/{pattern}/"
413
+ except _re.error:
414
+ rx = _re.compile(_re.escape(pattern), _re.I if ignore_case else 0)
415
+ shown_pat = f"{pattern!r} (literal)"
416
+ text, served, _p = _served_text(conn, doc_id, "txt", mirror_root)
417
+ if text is None:
418
+ return f"NO CONTENT: {doc_id} has no local text copy."
419
+ lines = text.splitlines()
420
+ marks = [(r["start_line"],
421
+ (f"{r['sec_num']} {r['sec_title']}".strip() or "front matter"))
422
+ for r in conn.execute(
423
+ "SELECT DISTINCT start_line, sec_num, sec_title FROM chunks "
424
+ "WHERE doc_id=? ORDER BY start_line", (served,))]
425
+ starts = [m[0] for m in marks]
426
+
427
+ def sec_of(n: int) -> str:
428
+ i = bisect.bisect_right(starts, n) - 1
429
+ return marks[i][1] if i >= 0 else "(not section-indexed)"
430
+
431
+ out, hits, shown_hits, i = [], 0, 0, 0
432
+ while i < len(lines):
433
+ if not rx.search(lines[i]):
434
+ i += 1
435
+ continue
436
+ hits += 1
437
+ if shown_hits < max_hits:
438
+ lo, hi = max(0, i - ctx), min(len(lines), i + ctx + 1)
439
+ out.append(f"{i + 1} [{sec_of(i + 1)}]")
440
+ out.extend((">> " if j == i else " ") + lines[j][:160]
441
+ for j in range(lo, hi))
442
+ shown_hits += 1
443
+ i += 1
444
+ if not hits:
445
+ return (f"NO MATCH for {shown_pat} in {doc_id} ({len(lines)} lines). "
446
+ f"Try search_content to search the whole corpus, or loosen "
447
+ f"the pattern.")
448
+ body, _n = _fit("\n".join(out), max_chars)
449
+ more = (f"\n[showing first {shown_hits} of {hits} matches — narrow the "
450
+ f"pattern, or read the section above]" if shown_hits < hits else "")
451
+ return (f"{hits} match(es) for {shown_pat} in {doc_id} "
452
+ f"[{shown_hits} shown]\n{body}{more}")
453
+
454
+
455
+ def list_recent(conn: sqlite3.Connection, n: int = 20,
456
+ series: str = "RFC") -> list[dict]:
457
+ n = max(1, min(n, 100))
458
+ rows = conn.execute(
459
+ """SELECT * FROM docs
460
+ WHERE series = ?
461
+ ORDER BY pub_date DESC,
462
+ CAST(SUBSTR(doc_id, LENGTH(series) + 1) AS INTEGER) DESC
463
+ LIMIT ?""",
464
+ (series.upper(), n)).fetchall()
465
+ return [_row_to_meta(conn, r) for r in rows]
466
+