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,457 @@
1
+ """RFC Editor subject taxonomy (https://www.rfc-editor.org/subjects/).
2
+
3
+ The RFC Editor curates ~640 nested subjects ("web > http > http2") and
4
+ assigns every document to one or more of them. The taxonomy is only
5
+ published as HTML, so we mirror it into two tables:
6
+
7
+ subjects(slug, name, parent, description, depth, doc_count)
8
+ the tree as shown on /subjects/ (doc_count = upstream count,
9
+ includes descendants)
10
+ doc_subjects(doc_id, slug)
11
+ per-document assignments mined from each /subjects/<slug>/ page
12
+
13
+ `sync()` fetches the index page plus one page per subject (rate-limited,
14
+ HTML cached under meta/subjects/ so re-parses are offline-safe) and
15
+ rebuilds both tables atomically. Query helpers back the MCP browsing
16
+ tools (list_subjects / get_subject / related).
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import re
22
+ import sqlite3
23
+ import time
24
+ from datetime import datetime, timezone
25
+ from html.parser import HTMLParser
26
+ from pathlib import Path
27
+ from typing import TYPE_CHECKING, Callable, Optional
28
+
29
+ if TYPE_CHECKING:
30
+ import httpx
31
+
32
+ from .config import BASE_URL, USER_AGENT, Paths, require_network
33
+
34
+ SUBJECTS_URL = f"{BASE_URL}/subjects/"
35
+
36
+ SCHEMA = """
37
+ CREATE TABLE IF NOT EXISTS subjects(
38
+ slug TEXT PRIMARY KEY,
39
+ name TEXT NOT NULL,
40
+ parent TEXT, -- parent slug, NULL = top level
41
+ description TEXT,
42
+ depth INTEGER NOT NULL DEFAULT 0,
43
+ doc_count INTEGER, -- upstream count (incl. descendants)
44
+ ord INTEGER -- upstream tree order for browsing
45
+ );
46
+ CREATE INDEX IF NOT EXISTS idx_subjects_parent ON subjects(parent);
47
+
48
+ CREATE TABLE IF NOT EXISTS doc_subjects(
49
+ doc_id TEXT NOT NULL,
50
+ slug TEXT NOT NULL,
51
+ PRIMARY KEY (doc_id, slug)
52
+ );
53
+ CREATE INDEX IF NOT EXISTS idx_docsubj_slug ON doc_subjects(slug, doc_id);
54
+ """
55
+
56
+ _TREE_UL_CLASS = "ml-6 border-l-1" # nested child-subject lists in the index
57
+ _SUBJECT_HREF = re.compile(r"^/subjects/([^/]+)/?$")
58
+ _INFO_HREF = re.compile(r"/info/([A-Za-z]{2,4}\d+)/?")
59
+ _SERIES_ID = re.compile(r"^(rfc|bcp|std|fyi|ien)(\d{1,5})$", re.I)
60
+
61
+
62
+ class SubjectNotFound(Exception):
63
+ pass
64
+
65
+
66
+ def ensure_schema(conn: sqlite3.Connection) -> None:
67
+ # Look before writing: this runs on read paths (info, browse, search),
68
+ # where a read-only server must not need a write, and executescript
69
+ # would also commit whatever transaction the caller has open.
70
+ cols = [r[1] for r in conn.execute("PRAGMA table_info(subjects)")]
71
+ if "ord" in cols and conn.execute(
72
+ "SELECT 1 FROM sqlite_master WHERE name='idx_docsubj_slug'"
73
+ ).fetchone():
74
+ return
75
+ conn.executescript(SCHEMA)
76
+ cols = [r[1] for r in conn.execute("PRAGMA table_info(subjects)")]
77
+ if "ord" not in cols: # migration: pre-order column for tree browsing
78
+ conn.execute("ALTER TABLE subjects ADD COLUMN ord INTEGER")
79
+
80
+
81
+ def subjects_ready(conn: sqlite3.Connection) -> bool:
82
+ row = conn.execute(
83
+ "SELECT COUNT(*) c FROM sqlite_master WHERE type='table' "
84
+ "AND name='subjects'").fetchone()
85
+ if not row or not row["c"]:
86
+ return False
87
+ return conn.execute("SELECT COUNT(*) c FROM subjects").fetchone()["c"] > 0
88
+
89
+
90
+
91
+ # --------------------------------------------------------------------------
92
+ # HTML parsing
93
+
94
+ class _IndexParser(HTMLParser):
95
+ """Parse the /subjects/ tree page into subject rows.
96
+
97
+ Only <ul> elements whose class contains the distinctive
98
+ 'ml-6 border-l-1' marker (the nested child lists) count toward
99
+ nesting depth, so unrelated page <ul>s are ignored. Each subject
100
+ entry is <a href="/slug/">NAME</a> <span>NN RFCs</span>
101
+ <p class="inline"> : description</p> in document order; the nested
102
+ child <ul> (if any) follows the parent's description inside the
103
+ parent <li>. Vue comment markers (<!--[-->...) split text runs, so
104
+ names/descriptions are buffered until the closing tag.
105
+ """
106
+
107
+ def __init__(self) -> None:
108
+ super().__init__(convert_charrefs=True)
109
+ self.rows: list[dict] = []
110
+ self._ul_stack: list[bool] = []
111
+ self._depth = 0
112
+ self._last_at_depth: dict[int, Optional[str]] = {}
113
+ self._cur: Optional[dict] = None
114
+ self._in_name = False
115
+ self._in_count = False
116
+ self._in_desc = False
117
+ self._buf = ""
118
+
119
+ def _flush(self) -> None:
120
+ if self._cur is not None:
121
+ self.rows.append(self._cur)
122
+ self._cur = None
123
+
124
+ def handle_starttag(self, tag, attrs):
125
+ a = dict(attrs)
126
+ if tag == "ul":
127
+ is_tree = _TREE_UL_CLASS in (a.get("class") or "")
128
+ self._ul_stack.append(is_tree)
129
+ if is_tree:
130
+ self._depth += 1
131
+ elif tag == "a":
132
+ m = _SUBJECT_HREF.match(a.get("href") or "")
133
+ if m:
134
+ self._flush()
135
+ slug = m.group(1).lower()
136
+ parent = self._last_at_depth.get(self._depth - 1) \
137
+ if self._depth > 0 else None
138
+ self._last_at_depth[self._depth] = slug
139
+ self._cur = {"slug": slug, "name": "", "parent": parent,
140
+ "depth": self._depth, "description": None,
141
+ "doc_count": None}
142
+ self._in_name, self._buf = True, ""
143
+ elif tag == "span" and self._cur is not None and not self._in_name:
144
+ self._in_count, self._buf = True, ""
145
+ elif tag == "p" and self._cur is not None \
146
+ and (a.get("class") or "") == "inline":
147
+ self._in_desc, self._buf = True, ""
148
+
149
+ def handle_endtag(self, tag):
150
+ if tag == "ul" and self._ul_stack:
151
+ if self._ul_stack.pop():
152
+ self._depth -= 1
153
+ elif tag == "a" and self._in_name and self._cur is not None:
154
+ self._cur["name"] = " ".join(self._buf.split())
155
+ self._in_name = False
156
+ elif tag == "span" and self._in_count and self._cur is not None:
157
+ m = re.match(r"(\d+)", self._buf.strip())
158
+ if m:
159
+ self._cur["doc_count"] = int(m.group(1))
160
+ self._in_count = False
161
+ elif tag == "p" and self._in_desc:
162
+ if self._cur is not None:
163
+ desc = " ".join(self._buf.split())
164
+ self._cur["description"] = re.sub(r"^:\s*", "", desc) or None
165
+ self._in_desc = False
166
+
167
+ def handle_data(self, data):
168
+ if self._in_name or self._in_count or self._in_desc:
169
+ self._buf += data
170
+
171
+ def close(self):
172
+ super().close()
173
+ self._flush()
174
+
175
+
176
+ def parse_index(html_text: str) -> list[dict]:
177
+ p = _IndexParser()
178
+ p.feed(html_text)
179
+ p.close()
180
+ seen: set[str] = set()
181
+ out = []
182
+ for r in p.rows:
183
+ if r["slug"] not in seen: # tree order defines hierarchy
184
+ seen.add(r["slug"])
185
+ out.append(r)
186
+ return out
187
+
188
+
189
+ def parse_subject_docs(html_text: str) -> list[str]:
190
+ """Normalized doc_ids listed on a /subjects/<slug>/ page."""
191
+ out: list[str] = []
192
+ seen: set[str] = set()
193
+ for m in _INFO_HREF.finditer(html_text):
194
+ mm = _SERIES_ID.match(m.group(1))
195
+ if not mm:
196
+ continue
197
+ norm = mm.group(1).upper() + mm.group(2)
198
+ if norm not in seen:
199
+ seen.add(norm)
200
+ out.append(norm)
201
+ return out
202
+
203
+
204
+
205
+ # --------------------------------------------------------------------------
206
+ # sync
207
+
208
+ def _subject_dir(paths: Paths) -> Path:
209
+ d = paths.meta / "subjects"
210
+ d.mkdir(parents=True, exist_ok=True)
211
+ return d
212
+
213
+
214
+ def _get(client: httpx.Client, url: str) -> str:
215
+ require_network(f"the RFC Editor subject page {url}")
216
+ r = client.get(url)
217
+ r.raise_for_status()
218
+ return r.text
219
+
220
+
221
+ def sync(conn: sqlite3.Connection, paths: Paths, force: bool = False,
222
+ delay: float = 0.1, limit: int = 0,
223
+ progress: Optional[Callable[[str], None]] = None) -> dict:
224
+ """Mirror the subject taxonomy: index page + one page per subject.
225
+
226
+ limit>0 restricts to the first N subjects (smoke runs; the index is
227
+ always fetched). Cached HTML under meta/subjects/ is reused unless
228
+ force=True. Both tables are rebuilt atomically at the end."""
229
+ import httpx # lazy: serving from an existing library never needs it
230
+ say = progress or (lambda m: None)
231
+ ensure_schema(conn)
232
+ cache = _subject_dir(paths)
233
+
234
+ index_path = cache / "_index.html"
235
+ if force or not index_path.exists():
236
+ say("fetching subject index ...")
237
+ with httpx.Client(headers={"User-Agent": USER_AGENT},
238
+ follow_redirects=True, timeout=60.0) as client:
239
+ index_path.write_text(_get(client, SUBJECTS_URL), "utf-8")
240
+ rows = parse_index(index_path.read_text("utf-8"))
241
+ if not rows:
242
+ raise RuntimeError(
243
+ "subject index parsed to 0 rows - upstream layout may have "
244
+ "changed; check data/meta/subjects/_index.html")
245
+ if limit > 0:
246
+ rows = rows[:limit]
247
+ say(f"{len(rows)} subjects to crawl")
248
+
249
+ doc_map: dict[str, list[str]] = {}
250
+ failed: list[str] = []
251
+ with httpx.Client(headers={"User-Agent": USER_AGENT},
252
+ follow_redirects=True, timeout=60.0) as client:
253
+ for i, r in enumerate(rows, 1):
254
+ page = cache / f"{r['slug']}.html"
255
+ try:
256
+ if force or not page.exists():
257
+ page.write_text(
258
+ _get(client, f"{BASE_URL}/subjects/{r['slug']}/"),
259
+ "utf-8")
260
+ time.sleep(delay)
261
+ doc_map[r["slug"]] = parse_subject_docs(
262
+ page.read_text("utf-8"))
263
+ except Exception as exc: # noqa: BLE001 - report, keep crawling
264
+ failed.append(f"{r['slug']}:{type(exc).__name__}")
265
+ doc_map[r["slug"]] = []
266
+ if i % 50 == 0:
267
+ say(f"crawled {i}/{len(rows)} subject pages")
268
+
269
+ conn.execute("DELETE FROM subjects")
270
+ conn.execute("DELETE FROM doc_subjects")
271
+ n_assign = 0
272
+ for i, r in enumerate(rows):
273
+ conn.execute(
274
+ "INSERT INTO subjects(slug,name,parent,description,depth,"
275
+ "doc_count,ord) VALUES(?,?,?,?,?,?,?)",
276
+ (r["slug"], r["name"], r["parent"], r["description"],
277
+ r["depth"], r["doc_count"], i))
278
+ for doc_id in doc_map.get(r["slug"], []):
279
+ conn.execute(
280
+ "INSERT OR IGNORE INTO doc_subjects(doc_id,slug) VALUES(?,?)",
281
+ (doc_id, r["slug"]))
282
+ n_assign += 1
283
+ now = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
284
+ conn.execute(
285
+ "INSERT INTO meta(key,value) VALUES(?,?) "
286
+ "ON CONFLICT(key) DO UPDATE SET value=excluded.value",
287
+ ("subjects_synced_at", now))
288
+ conn.commit()
289
+ distinct = conn.execute(
290
+ "SELECT COUNT(DISTINCT doc_id) c FROM doc_subjects").fetchone()["c"]
291
+ return {"subjects": len(rows), "assignments": n_assign,
292
+ "distinct_docs": distinct, "failed": failed}
293
+
294
+
295
+
296
+ # --------------------------------------------------------------------------
297
+ # queries
298
+
299
+ def _subtree_slugs(conn: sqlite3.Connection, slug: str) -> list[str]:
300
+ """slug plus all descendant slugs (iterative BFS, depth-safe)."""
301
+ out = [slug]
302
+ frontier = [slug]
303
+ while frontier:
304
+ ph = ",".join("?" * len(frontier))
305
+ kids = [r["slug"] for r in conn.execute(
306
+ f"SELECT slug FROM subjects WHERE parent IN ({ph})", frontier)]
307
+ kids = [k for k in kids if k not in out]
308
+ out.extend(kids)
309
+ frontier = kids
310
+ return out
311
+
312
+
313
+ def resolve_subject(conn: sqlite3.Connection, name: str) -> str:
314
+ """Exact slug > exact name > unique substring. Raises SubjectNotFound."""
315
+ ensure_schema(conn)
316
+ s = name.strip().lower()
317
+ row = conn.execute(
318
+ "SELECT slug FROM subjects WHERE slug=?", (s,)).fetchone()
319
+ if row:
320
+ return row["slug"]
321
+ row = conn.execute(
322
+ "SELECT slug FROM subjects WHERE lower(name)=? LIMIT 1",
323
+ (s,)).fetchone()
324
+ if row:
325
+ return row["slug"]
326
+ like = f"%{s}%"
327
+ rows = conn.execute(
328
+ "SELECT slug, name FROM subjects WHERE slug LIKE ? OR lower(name)"
329
+ " LIKE ? ORDER BY depth LIMIT 6", (like, like)).fetchall()
330
+ if len(rows) == 1:
331
+ return rows[0]["slug"]
332
+ if rows:
333
+ opts = ", ".join(f"{r['slug']} ({r['name']})" for r in rows)
334
+ raise SubjectNotFound(
335
+ f"subject {name!r} is ambiguous; candidates: {opts}")
336
+ raise SubjectNotFound(f"no subject matches {name!r}")
337
+
338
+
339
+ def list_subjects(conn: sqlite3.Connection, contains: str | None = None,
340
+ limit: int = 700) -> list[dict]:
341
+ ensure_schema(conn)
342
+ limit = max(1, min(limit, 700))
343
+ if contains:
344
+ like = f"%{contains.strip().lower()}%"
345
+ rows = conn.execute(
346
+ """SELECT slug, name, parent, depth, doc_count, description
347
+ FROM subjects
348
+ WHERE lower(slug) LIKE ? OR lower(name) LIKE ?
349
+ OR lower(COALESCE(description,'')) LIKE ?
350
+ ORDER BY ord LIMIT ?""",
351
+ (like, like, like, limit)).fetchall()
352
+ else:
353
+ rows = conn.execute(
354
+ """SELECT slug, name, parent, depth, doc_count, description
355
+ FROM subjects ORDER BY ord LIMIT ?""",
356
+ (limit,)).fetchall()
357
+ return [dict(r) for r in rows]
358
+
359
+
360
+ def get_subject(conn: sqlite3.Connection, name: str,
361
+ include_obsolete: bool = False, limit: int = 50,
362
+ offset: int = 0) -> dict:
363
+ """Grouped listing for one subject incl. descendant subjects, mirroring
364
+ the upstream /subjects/<slug>/ page but with local status/date data."""
365
+ limit = max(1, min(limit, 200))
366
+ offset = max(0, offset)
367
+ slug = resolve_subject(conn, name)
368
+ info = conn.execute(
369
+ "SELECT * FROM subjects WHERE slug=?", (slug,)).fetchone()
370
+ children = [dict(r) for r in conn.execute(
371
+ "SELECT slug, name, doc_count FROM subjects WHERE parent=? "
372
+ "ORDER BY slug", (slug,))]
373
+ slugs = _subtree_slugs(conn, slug)
374
+ ph = ",".join("?" * len(slugs))
375
+ cond = "" if include_obsolete else "AND d.is_current = 1"
376
+ total = conn.execute(
377
+ f"""SELECT COUNT(DISTINCT ds.doc_id) c FROM doc_subjects ds
378
+ JOIN docs d ON d.doc_id = ds.doc_id
379
+ WHERE ds.slug IN ({ph}) {cond}""", slugs).fetchone()["c"]
380
+ docs = [dict(r) for r in conn.execute(
381
+ f"""SELECT DISTINCT d.doc_id, d.title, d.current_status, d.stream,
382
+ d.pub_date
383
+ FROM doc_subjects ds JOIN docs d ON d.doc_id = ds.doc_id
384
+ WHERE ds.slug IN ({ph}) {cond}
385
+ ORDER BY d.pub_date DESC, d.doc_id DESC
386
+ LIMIT ? OFFSET ?""", [*slugs, limit, offset]).fetchall()]
387
+ return {
388
+ "subject": {"slug": info["slug"], "name": info["name"],
389
+ "parent": info["parent"],
390
+ "description": info["description"],
391
+ "upstream_doc_count": info["doc_count"]},
392
+ "children": children,
393
+ "docs_total": total, "offset": offset, "docs": docs,
394
+ "next_offset": offset + limit if offset + limit < total else None,
395
+ "canonical_url": f"{BASE_URL}/subjects/{slug}/",
396
+ }
397
+
398
+
399
+
400
+ def subjects_of(conn: sqlite3.Connection, doc_id: str) -> list[dict]:
401
+ ensure_schema(conn)
402
+ return [{"slug": r["slug"], "name": r["name"]} for r in conn.execute(
403
+ "SELECT ds.slug, s.name FROM doc_subjects ds "
404
+ "JOIN subjects s ON s.slug = ds.slug "
405
+ "WHERE ds.doc_id = ? ORDER BY s.depth, s.slug", (doc_id,))]
406
+
407
+
408
+ def subjects_map(conn: sqlite3.Connection,
409
+ doc_ids: list[str]) -> dict[str, list[str]]:
410
+ """doc_id -> [subject names] for a batch (one query; search hits)."""
411
+ ensure_schema(conn)
412
+ if not doc_ids:
413
+ return {}
414
+ out: dict[str, list[str]] = {d: [] for d in doc_ids}
415
+ ph = ",".join("?" * len(doc_ids))
416
+ for r in conn.execute(
417
+ f"""SELECT ds.doc_id, s.name FROM doc_subjects ds
418
+ JOIN subjects s ON s.slug = ds.slug
419
+ WHERE ds.doc_id IN ({ph})
420
+ ORDER BY s.depth, s.slug""", doc_ids):
421
+ out[r["doc_id"]].append(r["name"])
422
+ return out
423
+
424
+
425
+ def related(conn: sqlite3.Connection, doc_id: str, limit: int = 10) -> dict:
426
+ """Subject-based neighborhood: docs sharing subjects (ranked by count
427
+ of shared subjects) + sibling subjects popular in that neighborhood.
428
+ Complements get_refs (citation graph) with topical similarity."""
429
+ ensure_schema(conn)
430
+ own = [r["slug"] for r in conn.execute(
431
+ "SELECT slug FROM doc_subjects WHERE doc_id=?", (doc_id,))]
432
+ if not own:
433
+ return {"doc_id": doc_id, "subjects": [], "sibling_subjects": [],
434
+ "nearby_docs": [],
435
+ "note": "no subjects assigned for this doc (or taxonomy "
436
+ "not synced; run `rfclib subjects sync`)"}
437
+ ph = ",".join("?" * len(own))
438
+ siblings = [dict(r) for r in conn.execute(
439
+ f"""SELECT ds.slug, s.name, COUNT(*) AS shares
440
+ FROM doc_subjects ds
441
+ JOIN subjects s ON s.slug = ds.slug
442
+ WHERE ds.slug NOT IN ({ph})
443
+ AND ds.doc_id IN (SELECT doc_id FROM doc_subjects
444
+ WHERE slug IN ({ph}))
445
+ GROUP BY ds.slug ORDER BY shares DESC, ds.slug LIMIT 5""",
446
+ [*own, *own]).fetchall()]
447
+ nearby = [dict(r) for r in conn.execute(
448
+ f"""SELECT d.doc_id, d.title, d.current_status, d.pub_date,
449
+ COUNT(*) AS shared_subjects
450
+ FROM doc_subjects ds
451
+ JOIN docs d ON d.doc_id = ds.doc_id
452
+ WHERE ds.slug IN ({ph}) AND ds.doc_id != ? AND d.is_current = 1
453
+ GROUP BY d.doc_id
454
+ ORDER BY shared_subjects DESC, d.pub_date DESC
455
+ LIMIT ?""", [*own, doc_id, max(1, min(limit, 50))]).fetchall()]
456
+ return {"doc_id": doc_id, "subjects": own,
457
+ "sibling_subjects": siblings, "nearby_docs": nearby}
@@ -0,0 +1 @@
1
+ """Sync subsystem: manifest, state DB, downloader, mirror scanner."""
@@ -0,0 +1,132 @@
1
+ """Async incremental downloader against the official RFC Editor HTTP site.
2
+
3
+ The initial bulk pull is expected to come from the official rsync service
4
+ (see scripts/mirror.ps1); this module handles incremental updates and can do
5
+ a full pull on its own if rsync is unavailable. Files land in the same
6
+ rfc-editor.org layout the rsync modules use, so both producers are compatible.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import asyncio
12
+ import hashlib
13
+ import re
14
+ import sqlite3
15
+ from pathlib import Path
16
+ from typing import TYPE_CHECKING
17
+
18
+ if TYPE_CHECKING:
19
+ import httpx
20
+
21
+ from ..config import BASE_URL, USER_AGENT, require_network
22
+ from ..fsutil import atomic_write_bytes
23
+ from .state import file_path, register_file
24
+
25
+ VALID_FORMATS = ("txt", "html", "xml", "pdf", "json")
26
+
27
+ _DOC_RE = re.compile(r"^(RFC|BCP|STD|FYI|IEN)(\d+)$")
28
+
29
+
30
+ def doc_relpath(doc_id: str, fmt: str) -> str | None:
31
+ """Local mirror-relative path, matching the rfc-editor module layout."""
32
+ m = _DOC_RE.match(doc_id)
33
+ if not m:
34
+ return None
35
+ series, num = m.group(1).lower(), m.group(2)
36
+ return f"{series}/{series}{num}.{fmt}"
37
+
38
+
39
+ def doc_url(doc_id: str, fmt: str) -> str | None:
40
+ m = _DOC_RE.match(doc_id)
41
+ if not m:
42
+ return None
43
+ series, num = m.group(1).lower(), m.group(2)
44
+ return f"{BASE_URL}/{series}/{series}{num}.{fmt}"
45
+
46
+
47
+ def sha256_file(path: Path) -> str:
48
+ h = hashlib.sha256()
49
+ with path.open("rb") as fh:
50
+ for chunk in iter(lambda: fh.read(65536), b""):
51
+ h.update(chunk)
52
+ return h.hexdigest()
53
+
54
+
55
+ def _has_local(conn: sqlite3.Connection, doc_id: str, fmt: str) -> bool:
56
+ row = conn.execute(
57
+ "SELECT path FROM files WHERE doc_id=? AND fmt=?", (doc_id, fmt)
58
+ ).fetchone()
59
+ return bool(row) and file_path(conn, row["path"]).exists()
60
+
61
+
62
+ async def _fetch_one(client: httpx.AsyncClient, sem: asyncio.Semaphore,
63
+ conn: sqlite3.Connection, mirror_root: Path,
64
+ doc_id: str, fmt: str) -> tuple[str, str, str]:
65
+ url = doc_url(doc_id, fmt)
66
+ rel = doc_relpath(doc_id, fmt)
67
+ if url is None or rel is None:
68
+ return doc_id, fmt, "skipped:unknown-id"
69
+ dest = mirror_root / rel
70
+ try:
71
+ async with sem:
72
+ r = await client.get(url)
73
+ if r.status_code == 404:
74
+ return doc_id, fmt, "missing:404"
75
+ r.raise_for_status()
76
+ atomic_write_bytes(dest, r.content)
77
+ register_file(conn, doc_id, fmt, str(dest), sha256_file(dest),
78
+ len(r.content), url, "http")
79
+ return doc_id, fmt, "ok"
80
+ except Exception as exc: # noqa: BLE001 - report, don't crash the batch
81
+ return doc_id, fmt, f"error:{type(exc).__name__}:{exc}"
82
+
83
+
84
+ async def fetch_docs_async(conn: sqlite3.Connection, mirror_root: Path,
85
+ doc_ids: list[str], formats: list[str],
86
+ concurrency: int = 8,
87
+ force: bool = False) -> list[tuple[str, str, str]]:
88
+ """Fetch requested formats for doc_ids, skipping ones already on disk."""
89
+ todo: list[tuple[str, str]] = []
90
+ for doc_id in doc_ids:
91
+ for fmt in formats:
92
+ if force or not _has_local(conn, doc_id, fmt):
93
+ todo.append((doc_id, fmt))
94
+
95
+ results: list[tuple[str, str, str]] = []
96
+ if not todo:
97
+ return results
98
+ require_network(f"{todo[0][0]} ({todo[0][1]})" if len(todo) == 1 else
99
+ f"{len(todo)} document files ({todo[0][0]}, ...)")
100
+
101
+ import httpx # lazy: serving from an existing library never needs it
102
+ sem = asyncio.Semaphore(concurrency)
103
+ limits = httpx.Limits(max_connections=concurrency)
104
+ async with httpx.AsyncClient(
105
+ headers={"User-Agent": USER_AGENT},
106
+ follow_redirects=True,
107
+ timeout=httpx.Timeout(60.0),
108
+ limits=limits,
109
+ ) as client:
110
+ tasks = [_fetch_one(client, sem, conn, mirror_root, d, f) for d, f in todo]
111
+ done = 0
112
+ for coro in asyncio.as_completed(tasks):
113
+ results.append(await coro)
114
+ done += 1
115
+ if done % 250 == 0:
116
+ conn.commit() # incremental: crash-safe & resumable
117
+ conn.commit()
118
+ return results
119
+
120
+
121
+ def fetch_docs(conn: sqlite3.Connection, mirror_root: Path, doc_ids: list[str],
122
+ formats: list[str], concurrency: int = 8,
123
+ force: bool = False) -> list[tuple[str, str, str]]:
124
+ """Sync wrapper around fetch_docs_async."""
125
+ return asyncio.run(fetch_docs_async(conn, mirror_root, doc_ids, formats,
126
+ concurrency=concurrency, force=force))
127
+
128
+
129
+ def fetch_doc_sync(conn: sqlite3.Connection, mirror_root: Path, doc_id: str,
130
+ fmt: str) -> tuple[str, str, str]:
131
+ """On-demand single-document fetch (used by the MCP server)."""
132
+ return fetch_docs(conn, mirror_root, [doc_id], [fmt])