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,19 @@
1
+ """The RFC series as a primarysource collection. Importing this needs nothing beyond the core."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from primarysource.model import Unit
6
+ from primarysource.refs import RefGrammar
7
+ from primarysource.registry import Collection
8
+
9
+ # Tool names stay bare (search, read, cite, ...): rfclib's published interface. A prefix is the owner's call
10
+ # (PLAN.md §7, "Tool prefix").
11
+ COLLECTION = Collection(
12
+ name="rfc",
13
+ title="RFC series (RFC, BCP, STD, FYI) from the RFC Editor",
14
+ prefix="",
15
+ unit=Unit.PARAGRAPH,
16
+ grammar=RefGrammar(r"(?:RFC|BCP|STD|FYI)\d+"),
17
+ server="primarysource.rfc.mcp.server:mcp",
18
+ cli="primarysource.rfc.cli:main",
19
+ )
@@ -0,0 +1,138 @@
1
+ """Paths, network policy and official source constants.
2
+
3
+ All URLs point at RFC Editor first-party infrastructure (rfc-editor.org),
4
+ the authoritative publisher/archive of the RFC Series for all five streams.
5
+ See RFC 9280 (RFC Editor Model v3), RFC 4714, RFC 7841.
6
+
7
+ Where the library lives: $RFCLIB_HOME if set, else the platform's per-user
8
+ data directory (platformdirs: %LOCALAPPDATA%\\rfclib on Windows, not
9
+ roaming, since the library is gigabytes; $XDG_DATA_HOME/rfclib or
10
+ ~/.local/share/rfclib on Linux; ~/Library/Application Support/rfclib on
11
+ macOS). Never the working directory: MCP clients start servers from
12
+ wherever they like, so a cwd-relative home would silently point every
13
+ session at a different, empty library.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import os
19
+ from dataclasses import dataclass
20
+ from pathlib import Path
21
+
22
+ from primarysource.errors import LibError
23
+
24
+ from . import __version__
25
+
26
+ BASE_URL = "https://www.rfc-editor.org"
27
+ MANIFEST_URL = f"{BASE_URL}/rfc-index.xml"
28
+ MANIFEST_XSD_URL = f"{BASE_URL}/rfc-index.xsd"
29
+ RSS_URL = f"{BASE_URL}/rfcrss.xml"
30
+ ATOM_URL = f"{BASE_URL}/rfcatom.xml"
31
+ RSYNC_HOST = "rsync.rfc-editor.org"
32
+ RSYNC_DOC_URL = f"{BASE_URL}/series/rfc-download/"
33
+
34
+ USER_AGENT = f"rfclib/{__version__} (local RFC library sync; polite bulk fetch)"
35
+
36
+ NETWORK_MODES = ("auto", "on-demand", "offline")
37
+
38
+
39
+ def _default_home() -> Path:
40
+ # RFCLIB_HOME, else $PRIMARYSOURCE_HOME/rfc, else an existing per-user
41
+ # rfclib library, else the per-user primarysource/rfc
42
+ # (primarysource.home.collection_home).
43
+ from primarysource.home import collection_home
44
+ return collection_home("rfc", legacy_app="rfclib").expanduser()
45
+
46
+
47
+ def legacy_home() -> Path | None:
48
+ """A pre-platformdirs library in ./data (the old default), if one is
49
+ sitting in the working directory and RFCLIB_HOME is not set: the CLI
50
+ points at it instead of silently starting an empty library."""
51
+ if os.environ.get("RFCLIB_HOME"):
52
+ return None
53
+ old = Path.cwd() / "data"
54
+ if (old / "rfclib.db").is_file() and old.resolve() != \
55
+ _default_home().resolve():
56
+ return old
57
+ return None
58
+
59
+
60
+ class NetworkDisabled(LibError, RuntimeError):
61
+ """A fetch was needed but RFCLIB_NETWORK=offline (or read-only mode)
62
+ forbids it. `action` says what a human runs to get the document in."""
63
+
64
+
65
+ def read_only() -> bool:
66
+ """RFCLIB_READONLY=1 (or `rfclib mcp --read-only`): this process must not
67
+ write the library. The MCP server then opens the database read-only and
68
+ never fetches, so a shared HTTP server stays the only writer."""
69
+ return any(
70
+ (os.environ.get(var) or "").strip().lower() in ("1", "true", "yes", "on")
71
+ for var in ("RFCLIB_READONLY", "PRIMARYSOURCE_READONLY"))
72
+
73
+
74
+ def network_mode() -> str:
75
+ """RFCLIB_NETWORK: auto (default: fetch documents on demand, refresh the
76
+ catalog when stale) | on-demand (fetch only when a document is asked
77
+ for) | offline (never touch the network; answer from the cache).
78
+ A read-only process is always offline: a fetch would have to be stored."""
79
+ if read_only():
80
+ return "offline"
81
+ mode = (os.environ.get("RFCLIB_NETWORK") or "auto").strip().lower()
82
+ return mode if mode in NETWORK_MODES else "auto"
83
+
84
+
85
+ def require_network(what: str) -> None:
86
+ if read_only():
87
+ raise NetworkDisabled(
88
+ f"{what} is not in this library and this server is read-only "
89
+ "(RFCLIB_READONLY)", "ask the shared rfclib server for it",
90
+ "rfclib sync (without RFCLIB_READONLY)")
91
+ if network_mode() == "offline":
92
+ raise NetworkDisabled(
93
+ f"{what} is not available locally and RFCLIB_NETWORK=offline "
94
+ "forbids fetching it", "",
95
+ "rfclib sync while online, or unset RFCLIB_NETWORK")
96
+
97
+
98
+ @dataclass(frozen=True)
99
+ class Paths:
100
+ """Layout of the local library. Override the root with RFCLIB_HOME.
101
+
102
+ home/
103
+ rfclib.db SQLite index (metadata, relations, file inventory)
104
+ meta/ rfc-index.xml manifest copy, subject page cache
105
+ mirror/ document files, rfc-editor layout (rfc/, bcp/, ...)
106
+ models/ embedding model, loaded offline
107
+ """
108
+
109
+ home: Path
110
+
111
+ @classmethod
112
+ def default(cls) -> "Paths":
113
+ return cls(_default_home())
114
+
115
+ @property
116
+ def db(self) -> Path:
117
+ return self.home / "rfclib.db"
118
+
119
+ @property
120
+ def meta(self) -> Path:
121
+ return self.home / "meta"
122
+
123
+ @property
124
+ def manifest_xml(self) -> Path:
125
+ return self.meta / "rfc-index.xml"
126
+
127
+ @property
128
+ def mirror(self) -> Path:
129
+ return self.home / "mirror"
130
+
131
+ @property
132
+ def models(self) -> Path:
133
+ return self.home / "models"
134
+
135
+ def ensure(self) -> "Paths":
136
+ for p in (self.home, self.meta, self.mirror):
137
+ p.mkdir(parents=True, exist_ok=True)
138
+ return self
@@ -0,0 +1,203 @@
1
+ """Verified-errata overlay from the RFC Editor errata service.
2
+
3
+ Two sources. The primary one is the RFC Editor's published bulk API,
4
+ www.rfc-editor.org/api/v1/errata.json (every erratum, ~12 MB, one request;
5
+ robots.txt allows /api/): `sync_bulk` loads it so every document's errata
6
+ status is authoritative offline, including "none". The fallback is the
7
+ per-document errata.rfc-editor.org search page, fetched on demand only (that
8
+ host forbids crawling), parsed from its records presentation.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import html as htmllib
14
+ import re
15
+ import sqlite3
16
+ import time
17
+ from datetime import datetime, timezone
18
+
19
+ from .config import USER_AGENT, network_mode, require_network
20
+ from .sync.state import _now, get_meta, set_meta
21
+
22
+ ERRATA_URL = "https://errata.rfc-editor.org/search/?rfc_number={num}"
23
+ BULK_URL = "https://www.rfc-editor.org/api/v1/errata.json"
24
+ BULK_META = "errata_bulk_synced_at"
25
+
26
+ _RECORD_SPLIT = re.compile(r'Errata-ID:\s*<a href="/eid(\d+)/?"')
27
+ _TAG_RE = re.compile(r"<[^>]+>")
28
+
29
+
30
+ def _text(fragment: str) -> str:
31
+ return htmllib.unescape(_TAG_RE.sub("", fragment)).strip()
32
+
33
+
34
+ def _field(block: str, label: str) -> str | None:
35
+ """Value of a <dt>label</dt><dd>value</dd> pair inside the metadata dl."""
36
+ m = re.search(
37
+ rf"<dt[^>]*>\s*{re.escape(label)}\s*</dt>\s*<dd[^>]*>(.*?)</dd>",
38
+ block, re.S)
39
+ return _text(m.group(1)) if m else None
40
+
41
+
42
+ def parse_errata(page: str) -> list[dict]:
43
+ """Parse the errata service HTML (Full records view) into dicts."""
44
+ parts = _RECORD_SPLIT.split(page)
45
+ # parts[0] = preamble; then alternating eid, block
46
+ out: list[dict] = []
47
+ for eid, block in zip(parts[1::2], parts[2::2]):
48
+ # Cut anything after the last record (page footer).
49
+ block = block.split("Report New Erratum")[0]
50
+ entry: dict = {"eid": int(eid),
51
+ "status": _field(block, "Status:") or "",
52
+ "type": _field(block, "Type:") or "",
53
+ "reported_by": _field(block, "Reported By:"),
54
+ "reported_date": _field(block, "Date Reported:"),
55
+ "section": None, "says": None,
56
+ "should_say": None, "notes": None,
57
+ "handler": None, "handler_date": None}
58
+ if (v := _field(block, "Verified by:")):
59
+ entry["handler"] = v
60
+ entry["handler_date"] = _field(block, "Date Verified:")
61
+ elif (v := _field(block, "Rejected by:")):
62
+ entry["handler"] = v
63
+ entry["handler_date"] = _field(block, "Date Rejected:")
64
+
65
+ # "Section X says:" paragraph followed by a <pre> quote
66
+ m = re.search(r"<p>\s*(?:Section\s+([^<]*?))?\s*says:\s*</p>\s*"
67
+ r"<pre[^>]*>(.*?)</pre>", block, re.S)
68
+ if m:
69
+ entry["section"] = (m.group(1) or "").strip().rstrip(":") or None
70
+ entry["says"] = _text(m.group(2))
71
+ m = re.search(r"<p>\s*It should say:\s*</p>\s*<pre[^>]*>(.*?)</pre>",
72
+ block, re.S)
73
+ if m:
74
+ entry["should_say"] = _text(m.group(1))
75
+ m = re.search(r"<p>\s*Notes:\s*</p>\s*<pre[^>]*>(.*?)</pre>",
76
+ block, re.S)
77
+ if m:
78
+ entry["notes"] = _text(m.group(1))
79
+ out.append(entry)
80
+ return out
81
+
82
+
83
+ def fetch(rfc_number: int) -> str:
84
+ """One polite request to the errata service; caller throttles."""
85
+ import httpx # lazy: serving from an existing library never needs it
86
+ with httpx.Client(headers={"User-Agent": USER_AGENT},
87
+ follow_redirects=True,
88
+ timeout=httpx.Timeout(30.0)) as client:
89
+ r = client.get(ERRATA_URL.format(num=rfc_number))
90
+ r.raise_for_status()
91
+ return r.text
92
+
93
+
94
+ def store(conn: sqlite3.Connection, doc_id: str,
95
+ entries: list[dict]) -> int:
96
+ conn.execute("DELETE FROM errata WHERE doc_id=?", (doc_id,))
97
+ rows = [(e["eid"], doc_id, e["status"], e.get("type"), e.get("section"),
98
+ e.get("says"), e.get("should_say"), e.get("notes"),
99
+ e.get("reported_by"), e.get("reported_date"),
100
+ e.get("handler"), e.get("handler_date"), _now())
101
+ for e in entries]
102
+ conn.executemany(
103
+ """INSERT OR REPLACE INTO errata(eid, doc_id, status, type, section,
104
+ says, should_say, notes, reported_by, reported_date,
105
+ handler, handler_date, fetched_at)
106
+ VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)""", rows)
107
+ conn.commit()
108
+ return len(rows)
109
+
110
+
111
+ def sync_bulk(conn: sqlite3.Connection, data: list[dict] | None = None) -> dict:
112
+ """Replace the errata table with the RFC Editor's bulk feed."""
113
+ if data is None:
114
+ require_network("the errata feed")
115
+ import httpx # lazy: serving from an existing library never needs it
116
+ with httpx.Client(headers={"User-Agent": USER_AGENT},
117
+ follow_redirects=True,
118
+ timeout=httpx.Timeout(120.0)) as client:
119
+ r = client.get(BULK_URL)
120
+ r.raise_for_status()
121
+ data = r.json()
122
+ now = _now()
123
+ rows = []
124
+ for e in data:
125
+ doc = (e.get("doc-id") or "").upper().replace(" ", "")
126
+ eid = str(e.get("errata_id") or "")
127
+ if not doc or not eid.isdigit():
128
+ continue
129
+ rows.append((int(eid), doc, e.get("errata_status_code") or "",
130
+ e.get("errata_type_code"), e.get("section"),
131
+ e.get("orig_text"), e.get("correct_text"),
132
+ e.get("notes"), e.get("submitter_name"),
133
+ e.get("submit_date"), e.get("verifier_name"),
134
+ e.get("update_date"), now))
135
+ conn.execute("DELETE FROM errata")
136
+ conn.executemany(
137
+ """INSERT OR REPLACE INTO errata(eid, doc_id, status, type, section,
138
+ says, should_say, notes, reported_by, reported_date,
139
+ handler, handler_date, fetched_at)
140
+ VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)""", rows)
141
+ conn.commit()
142
+ set_meta(conn, BULK_META, now)
143
+ by = {}
144
+ for r in conn.execute("SELECT status, COUNT(*) c FROM errata "
145
+ "GROUP BY status"):
146
+ by[r["status"]] = r["c"]
147
+ return {"errata": len(rows), "documents": conn.execute(
148
+ "SELECT COUNT(DISTINCT doc_id) FROM errata").fetchone()[0],
149
+ "by_status": by, "synced_at": now}
150
+
151
+
152
+ def bulk_synced_at(conn: sqlite3.Connection) -> str | None:
153
+ return get_meta(conn, BULK_META)
154
+
155
+
156
+ def cached(conn: sqlite3.Connection, doc_id: str,
157
+ status: str | None = None) -> list[dict]:
158
+ q = "SELECT * FROM errata WHERE doc_id=?"
159
+ args: list = [doc_id]
160
+ if status:
161
+ q += " AND status=?"
162
+ args.append(status)
163
+ q += " ORDER BY eid"
164
+ return [dict(r) for r in conn.execute(q, args)]
165
+
166
+
167
+ def refreshed_at(conn: sqlite3.Connection, doc_id: str) -> str | None:
168
+ row = conn.execute(
169
+ "SELECT MIN(fetched_at) t FROM errata WHERE doc_id=?",
170
+ (doc_id,)).fetchone()
171
+ return row["t"] if row and row["t"] else None
172
+
173
+
174
+ def ensure(conn: sqlite3.Connection, doc_id: str, refresh: bool = False,
175
+ max_age_days: float = 7.0) -> dict:
176
+ """Cached errata for doc_id, fetching from the service when absent
177
+ (or when refresh=True / cache older than max_age_days)."""
178
+ if not doc_id.startswith("RFC"):
179
+ return {"doc_id": doc_id, "results": cached(conn, doc_id),
180
+ "source": "cache",
181
+ "note": "errata exist for RFC-series documents only"}
182
+ # a bulk sync is authoritative for every document, including "none"
183
+ fetched_at = refreshed_at(conn, doc_id) or bulk_synced_at(conn)
184
+ fresh = False
185
+ if fetched_at:
186
+ try:
187
+ t = datetime.strptime(fetched_at, "%Y-%m-%dT%H:%M:%SZ")\
188
+ .replace(tzinfo=timezone.utc)
189
+ fresh = ((datetime.now(timezone.utc) - t).total_seconds()
190
+ < max_age_days * 86400)
191
+ except ValueError:
192
+ fresh = False
193
+ if fresh and not refresh:
194
+ return {"doc_id": doc_id, "results": cached(conn, doc_id),
195
+ "source": "cache", "fetched_at": fetched_at}
196
+ if network_mode() == "offline":
197
+ return {"doc_id": doc_id, "results": cached(conn, doc_id),
198
+ "source": "cache (offline)", "fetched_at": fetched_at}
199
+ time.sleep(0.25) # politeness floor on the upstream service
200
+ page = fetch(int(doc_id[3:]))
201
+ store(conn, doc_id, parse_errata(page))
202
+ return {"doc_id": doc_id, "results": cached(conn, doc_id),
203
+ "source": "live", "fetched_at": refreshed_at(conn, doc_id)}
@@ -0,0 +1,82 @@
1
+ """Filesystem helpers that behave the same on Windows, macOS and Linux.
2
+
3
+ atomic_write_bytes: write to a temp file in the destination directory, then
4
+ os.replace() it into place, so a reader (another MCP server process, the
5
+ reader UI) never sees a half-written RFC and a crash leaves no torn file.
6
+ On Windows os.replace fails while another process holds the target open
7
+ (antivirus, search indexers, a concurrent reader), so it retries briefly.
8
+
9
+ bulk_lock: one long-running writer at a time per library (init, sync,
10
+ index build). Short writes such as on-demand indexing of a single RFC rely
11
+ on SQLite's own locking (busy_timeout) instead.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import contextlib
17
+ import os
18
+ import time
19
+ from pathlib import Path
20
+
21
+ STALE_LOCK_SECONDS = 6 * 3600
22
+
23
+
24
+ def atomic_write_bytes(dest: Path, data: bytes, retries: int = 10) -> None:
25
+ dest.parent.mkdir(parents=True, exist_ok=True)
26
+ tmp = dest.with_name(f".{dest.name}.{os.getpid()}.tmp")
27
+ with open(tmp, "wb") as fh:
28
+ fh.write(data)
29
+ fh.flush()
30
+ os.fsync(fh.fileno())
31
+ for attempt in range(retries):
32
+ try:
33
+ os.replace(tmp, dest)
34
+ return
35
+ except PermissionError:
36
+ if attempt == retries - 1:
37
+ with contextlib.suppress(OSError):
38
+ tmp.unlink()
39
+ raise
40
+ time.sleep(0.05 * (attempt + 1))
41
+
42
+
43
+ class LockHeld(RuntimeError):
44
+ pass
45
+
46
+
47
+ @contextlib.contextmanager
48
+ def bulk_lock(home: Path, name: str = "write"):
49
+ """Exclusive lock file <home>/.<name>.lock (O_EXCL create, works on every
50
+ OS and on network filesystems). A lock older than STALE_LOCK_SECONDS is
51
+ taken over: its holder crashed."""
52
+ home.mkdir(parents=True, exist_ok=True)
53
+ lock = home / f".{name}.lock"
54
+ for _ in range(2):
55
+ try:
56
+ fd = os.open(lock, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
57
+ break
58
+ except FileExistsError:
59
+ try:
60
+ age = time.time() - lock.stat().st_mtime
61
+ except OSError:
62
+ continue
63
+ if age > STALE_LOCK_SECONDS:
64
+ with contextlib.suppress(OSError):
65
+ lock.unlink()
66
+ continue
67
+ holder = ""
68
+ with contextlib.suppress(OSError):
69
+ holder = lock.read_text("utf-8").strip()
70
+ raise LockHeld(f"another rfclib process is updating this library "
71
+ f"({holder or lock}); wait for it to finish") \
72
+ from None
73
+ else:
74
+ raise LockHeld(f"could not take {lock}")
75
+ try:
76
+ os.write(fd, f"pid {os.getpid()} since {time.strftime('%H:%M:%S')}"
77
+ .encode())
78
+ os.close(fd)
79
+ yield lock
80
+ finally:
81
+ with contextlib.suppress(OSError):
82
+ lock.unlink()
@@ -0,0 +1,213 @@
1
+ """Cross-reference graph mined from the local RFC .txt corpus.
2
+
3
+ RFCs cite each other with bracketed tags ([RFC9000]) inside numbered
4
+ References sections (normative vs informative by subsection), and point
5
+ at specific sections with "Section 7.4.1 of [RFC9110]" / "[RFC9110],
6
+ Section 4.2" phrasing. Mining those patterns from text we already
7
+ store yields the full document-level citation graph plus section-level
8
+ anchors, with zero extra downloads (no XML mirror needed).
9
+
10
+ Build is incremental: ref_src keeps each source txt's sha256 so
11
+ re-running only re-mines changed documents.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import re
17
+ import sqlite3
18
+ from pathlib import Path
19
+
20
+ from .sync.downloader import sha256_file
21
+ from .sync.state import file_path, mirror_of
22
+
23
+ _SERIES = r"(?:RFC|BCP|STD|FYI)"
24
+
25
+ # [RFC9000], [BCP 14], [STD 1] ... (spaced or not)
26
+ _REF_TAG = re.compile(rf"\[({_SERIES})\s?(\d{{1,5}})\]")
27
+
28
+ # Body header of the references area, e.g. "19. References",
29
+ # " 19.1. Normative References". TOC lines are excluded because
30
+ # they end with dot-leaders + page number, not with "References".
31
+ _HDR_REF = re.compile(
32
+ r"^\s*(\d+(?:\.\d+)*)?\.?\s{1,3}(Normative|Informative)?\s*"
33
+ r"References:?\s*$")
34
+
35
+ # Any numbered body header (used to leave the references area).
36
+ _HDR_NUM = re.compile(r"^\s*(\d+(?:\.\d+)*)\.?\s{1,3}[A-Z]")
37
+
38
+ # Section-anchored citations, the common phrasings:
39
+ # "Section 7.4.1 of [RFC9110]" "Sections 4.1 ... of RFC 9001"
40
+ # "[RFC9110], Section 4.2" "[RFC9110] Section 4.2"
41
+ # "RFC 9001, Section 5.4" "Appendix C of [RFC6962]"
42
+ _SEC_OF = re.compile(
43
+ rf"\b[Ss]ections?\s+((?:\d+|[A-Z])[\w.\-]{{0,12}})\s+of\s+\[?\s*"
44
+ rf"({_SERIES})\s?(\d{{1,5}})\s*\]?")
45
+ _SEC_TAG = re.compile(
46
+ rf"\[({_SERIES})\s?(\d{{1,5}})\](?:,|\s+)?[Ss]ections?\s+"
47
+ r"((?:\d+|[A-Z])[\w.\-]{0,12})")
48
+ _SEC_PLAIN = re.compile(
49
+ rf"\b({_SERIES})\s?(\d{{1,5}})\s*[,\u2014]+\s+[Ss]ections?\s+"
50
+ r"((?:\d+|[A-Z])[\w.\-]{0,12})")
51
+ _SEC_APPX = re.compile(
52
+ rf"\bAppendix\s+([A-Z](?:\.\d+)?)\s+of\s+\[?\s*({_SERIES})\s?"
53
+ r"(\d{1,5})\s*\]?")
54
+
55
+
56
+ def _clean_sec(sec: str) -> str:
57
+ return sec.strip().rstrip(".,:;")[:16]
58
+
59
+
60
+ def extract_refs(text: str, known: set[str]) -> list[tuple[str, str, str]]:
61
+ """Mine (dst, kind, sec) edges from one RFC's txt.
62
+
63
+ kind is normative|informative|reference for tags inside the
64
+ References section, mention for section-anchored citations found
65
+ anywhere in the document. dst is only emitted when the target id
66
+ exists in `known`.
67
+ """
68
+ edges: set[tuple[str, str, str]] = set()
69
+ zone: str | None = None # None|reference|normative|informative
70
+ zone_prefix: str | None = None # e.g. '19' while inside section 19(.x)
71
+
72
+ for line in text.splitlines():
73
+ m = _HDR_REF.match(line)
74
+ if m:
75
+ num, sub = m.group(1), m.group(2)
76
+ zone = ("normative" if sub == "Normative" else
77
+ "informative" if sub == "Informative" else "reference")
78
+ zone_prefix = num
79
+ continue
80
+ h = _HDR_NUM.match(line)
81
+ if h and zone is not None:
82
+ num = h.group(1)
83
+ if not (zone_prefix and (num == zone_prefix
84
+ or num.startswith(zone_prefix + "."))):
85
+ zone = None # left the references section
86
+ continue
87
+
88
+ # TOC lines (dot leaders) mirror headings + can contain tags;
89
+ # they never carry real citations, skip them entirely.
90
+ if ". ." in line:
91
+ continue
92
+
93
+ if zone is not None:
94
+ for series, num in _REF_TAG.findall(line):
95
+ dst = series + num
96
+ if dst in known:
97
+ edges.add((dst, zone, ""))
98
+
99
+ for sec, series, num in _SEC_OF.findall(line):
100
+ dst = series + num
101
+ if dst in known:
102
+ edges.add((dst, "mention", _clean_sec(sec)))
103
+ for sec, series, num in _SEC_APPX.findall(line):
104
+ dst = series + num
105
+ if dst in known:
106
+ edges.add((dst, "mention", _clean_sec(sec)))
107
+ for series, num, sec in _SEC_TAG.findall(line):
108
+ dst = series + num
109
+ if dst in known:
110
+ edges.add((dst, "mention", _clean_sec(sec)))
111
+ for series, num, sec in _SEC_PLAIN.findall(line):
112
+ dst = series + num
113
+ if dst in known:
114
+ edges.add((dst, "mention", _clean_sec(sec)))
115
+
116
+ return sorted(edges)
117
+
118
+
119
+ def build(conn: sqlite3.Connection, doc_ids: list[str] | None = None,
120
+ force: bool = False, progress=None) -> dict:
121
+ """(Re-)mine ref edges for docs with a local txt; sha-incremental."""
122
+ known = {r[0] for r in conn.execute("SELECT doc_id FROM docs")}
123
+ src_sha = {r["doc_id"]: r["sha256"] for r in
124
+ conn.execute("SELECT doc_id, sha256 FROM ref_src")}
125
+ rows = conn.execute("SELECT doc_id, path FROM files WHERE fmt='txt'")\
126
+ .fetchall()
127
+ if doc_ids:
128
+ want = set(doc_ids)
129
+ rows = [r for r in rows if r["doc_id"] in want]
130
+ built = skipped = total_edges = 0
131
+ mirror = mirror_of(conn)
132
+ for i, r in enumerate(rows, 1):
133
+ path = file_path(conn, r["path"], mirror)
134
+ if not path.is_file():
135
+ continue
136
+ sha = sha256_file(path)
137
+ if not force and src_sha.get(r["doc_id"]) == sha:
138
+ skipped += 1
139
+ continue
140
+ text = path.read_text(encoding="utf-8", errors="replace")
141
+ edges = extract_refs(text, known)
142
+ conn.execute("DELETE FROM ref_edges WHERE src=?", (r["doc_id"],))
143
+ conn.executemany(
144
+ "INSERT OR IGNORE INTO ref_edges(src,dst,kind,sec) VALUES(?,?,?,?)",
145
+ [(r["doc_id"], d, k, s) for d, k, s in edges])
146
+ conn.execute(
147
+ "INSERT INTO ref_src(doc_id,sha256) VALUES(?,?) "
148
+ "ON CONFLICT(doc_id) DO UPDATE SET sha256=excluded.sha256",
149
+ (r["doc_id"], sha))
150
+ built += 1
151
+ total_edges += len(edges)
152
+ if progress:
153
+ progress(i, len(rows), r["doc_id"], len(edges))
154
+ if i % 250 == 0:
155
+ conn.commit()
156
+ conn.commit()
157
+ return {"built": built, "skipped_unchanged": skipped,
158
+ "new_edges": total_edges,
159
+ "total_edges": conn.execute(
160
+ "SELECT COUNT(*) FROM ref_edges").fetchone()[0]}
161
+
162
+
163
+ _RANK = {"normative": 0, "informative": 1, "reference": 2, "mention": 3}
164
+
165
+
166
+ def _num(doc_id: str) -> int:
167
+ m = re.match(r"\D*(\d+)", doc_id)
168
+ return int(m.group(1)) if m else 0
169
+
170
+
171
+ def _sec_key(s: str):
172
+ return [int(p) if p.isdigit() else p
173
+ for p in re.split(r"[.\-]", s)]
174
+
175
+
176
+ def get_refs(conn: sqlite3.Connection, doc_id: str,
177
+ limit: int = 100) -> dict:
178
+ """Merged citation view: outbound (this doc cites) and inbound."""
179
+ def side(col_own: str, col_peer: str) -> tuple[list[dict], int]:
180
+ agg: dict[str, dict] = {}
181
+ for r in conn.execute(
182
+ f"SELECT {col_peer} peer, kind, sec FROM ref_edges "
183
+ f"WHERE {col_own}=?", (doc_id,)):
184
+ e = agg.setdefault(r["peer"], {"doc_id": r["peer"],
185
+ "kinds": set(), "sections": set()})
186
+ e["kinds"].add(r["kind"])
187
+ if r["sec"]:
188
+ e["sections"].add(r["sec"])
189
+ titles = {}
190
+ if agg:
191
+ titles = {t["doc_id"]: (t["title"], bool(t["is_current"]))
192
+ for t in conn.execute(
193
+ "SELECT doc_id, title, is_current FROM docs "
194
+ "WHERE doc_id IN (%s)"
195
+ % ",".join("?" * len(agg)), list(agg))}
196
+ out = []
197
+ for peer, e in agg.items():
198
+ title, current = titles.get(peer, (None, True))
199
+ kinds = sorted(e["kinds"], key=lambda k: _RANK.get(k, 9))
200
+ out.append({"doc_id": peer, "title": title,
201
+ "is_current": current, "kinds": kinds,
202
+ "sections": sorted(e["sections"], key=_sec_key)[:12]})
203
+ out.sort(key=lambda x: (_RANK.get(x["kinds"][0], 9),
204
+ -_num(x["doc_id"])))
205
+ return out[:limit], len(out)
206
+
207
+ outbound, n_out = side("src", "dst")
208
+ inbound, n_in = side("dst", "src")
209
+ return {"doc_id": doc_id,
210
+ "outbound": outbound, "outbound_count": n_out,
211
+ "inbound": inbound, "inbound_count": n_in,
212
+ "built": conn.execute(
213
+ "SELECT COUNT(*) FROM ref_src").fetchone()[0] > 0}
@@ -0,0 +1 @@
1
+ """Chunking and retrieval subpackage: section chunker, FTS5, vectors."""