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,117 @@
1
+ """The drafts the portfolio tracks: why each is tracked, the revision last read, and the pins. Metadata only.
2
+
3
+ `tracked.json` is package data. For each draft it holds what the importer was last run against: the latest
4
+ revision read, the datatracker state then, what replaced it, and the sha256 of the files fetched for each
5
+ revision read. Those digests are the pins: an import of a revision that has one refuses a different file. A
6
+ revision with no pin is trusted on first use and its digest recorded in the library's `meta.json`; add it here
7
+ after checking it (`primarysource draft import --print-pin`).
8
+
9
+ The snapshot says "as of `checked`". It is the catalogue's memory, not the datatracker's current word: `info`
10
+ prefers the library's own `status.json` and falls back to this when the draft was never fetched.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ from dataclasses import dataclass
17
+ from importlib import resources
18
+ from typing import Any
19
+
20
+ from primarysource.draft import names
21
+ from primarysource.errors import LibError
22
+
23
+ PLACEMENTS = ("layer", "volume", "none")
24
+
25
+
26
+ @dataclass(frozen=True, slots=True)
27
+ class Terms:
28
+ """What the IETF Trust's terms allow, and so where drafts may be placed (docs/draft.md)."""
29
+
30
+ licence: str
31
+ placement: str
32
+ terms_url: str
33
+ terms_read: str
34
+ summary: str
35
+
36
+
37
+ @dataclass(frozen=True, slots=True)
38
+ class Pin:
39
+ """sha256 of the files fetched for one revision; None for a file that was not fetched (no XML)."""
40
+
41
+ txt: str | None
42
+ xml: str | None
43
+
44
+
45
+ @dataclass(frozen=True, slots=True)
46
+ class Tracked:
47
+ name: str
48
+ why: str
49
+ consumers: tuple[str, ...]
50
+ rev_read: str
51
+ state: str
52
+ replaced_by: tuple[str, ...]
53
+ became_rfc: str | None
54
+ expires: str
55
+ checked: str
56
+ pins: dict[str, Pin]
57
+
58
+
59
+ def _digest(value: Any, where: str) -> str | None:
60
+ if value is None:
61
+ return None
62
+ if not isinstance(value, str) or len(value) != 64 or value.strip("0123456789abcdef"):
63
+ raise ValueError(f"{where}: a sha256 must be 64 lowercase hex digits or null")
64
+ return value
65
+
66
+
67
+ def parse(raw: str) -> tuple[Terms, dict[str, Tracked]]:
68
+ """Validate the catalogue text. A malformed catalogue is a packaging bug, so it raises ValueError."""
69
+ data = json.loads(raw)
70
+ if data.get("schema") != 1:
71
+ raise ValueError("catalogue schema must be 1")
72
+ t = data["terms"]
73
+ if t["placement"] not in PLACEMENTS:
74
+ raise ValueError(f"placement {t['placement']!r} is not one of {PLACEMENTS}")
75
+ terms = Terms(t["licence"], t["placement"], t["terms_url"], t["terms_read"], t["summary"])
76
+ drafts: dict[str, Tracked] = {}
77
+ for entry in data["drafts"]:
78
+ name = names.parse_name(entry["name"])
79
+ if name in drafts:
80
+ raise ValueError(f"duplicate draft {name}")
81
+ pins: dict[str, Pin] = {}
82
+ for rev, files in entry["pins"].items():
83
+ names.valid_revision(rev)
84
+ pins[rev] = Pin(_digest(files.get("txt"), f"{name}-{rev}"), _digest(files.get("xml"), f"{name}-{rev}"))
85
+ if entry["rev_read"]:
86
+ names.valid_revision(entry["rev_read"])
87
+ drafts[name] = Tracked(
88
+ name,
89
+ entry["why"],
90
+ tuple(entry["consumers"]),
91
+ entry["rev_read"],
92
+ entry["state"],
93
+ tuple(entry["replaced_by"]),
94
+ entry["became_rfc"],
95
+ entry["expires"],
96
+ entry["checked"],
97
+ pins,
98
+ )
99
+ return terms, drafts
100
+
101
+
102
+ TERMS, TRACKED = parse(resources.files("primarysource.draft").joinpath("tracked.json").read_text(encoding="utf-8"))
103
+
104
+
105
+ def pin_for(name: str, rev: str) -> Pin | None:
106
+ """The pinned digests for one revision of a tracked draft, or None."""
107
+ tracked = TRACKED.get(name)
108
+ return tracked.pins.get(rev) if tracked else None
109
+
110
+
111
+ def tracked(name: str) -> Tracked:
112
+ """The catalogue entry, or a LibError that lists what is tracked."""
113
+ found = TRACKED.get(names.parse_name(name))
114
+ if found is None:
115
+ known = ", ".join(sorted(TRACKED)) or "none"
116
+ raise LibError(f"{name} is not in the tracked catalogue (tracked: {known})", "")
117
+ return found
@@ -0,0 +1,149 @@
1
+ """`primarysource draft ...`: the collection's own commands.
2
+
3
+ primarysource draft import <name>[-rev] ... fetch drafts from the IETF (one request a second); --tracked for all
4
+ primarysource draft refresh [<name> ...] fetch only the datatracker's current status; --tracked for all
5
+ primarysource draft list tracked and stored drafts, with state and replacement
6
+ primarysource draft info <name> standing, revisions, expiry, replaced-by, licence legend
7
+ primarysource draft outline <name>[-rev] a stored draft's sections
8
+ primarysource draft read <ref> [--max-tokens N]
9
+ primarysource draft search <words> [--name N] [--limit N]
10
+ primarysource draft cite <ref>
11
+
12
+ `import` and `refresh` are the only commands that use the network. They refuse when `PRIMARYSOURCE_READONLY` is
13
+ set. The others read the library and are what the MCP tools call.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import argparse
19
+ import json
20
+ import sys
21
+ from collections.abc import Callable, Sequence
22
+
23
+ from primarysource.draft import catalogue, importer, names, views
24
+ from primarysource.draft.net import Fetcher
25
+ from primarysource.draft.store import Store
26
+ from primarysource.errors import LibError
27
+
28
+ fetcher_factory: Callable[[], Fetcher] = Fetcher
29
+ """How the importer gets its fetcher; tests replace it with one over a fake transport."""
30
+
31
+
32
+ def _parser() -> argparse.ArgumentParser:
33
+ parser = argparse.ArgumentParser(prog="primarysource draft", description="IETF Internet-Drafts")
34
+ commands = parser.add_subparsers(dest="command", required=True)
35
+ imp = commands.add_parser("import", help="fetch drafts from the IETF into the library")
36
+ imp.add_argument("drafts", nargs="*", help="draft-<words>[-NN]; without -NN the latest revision")
37
+ imp.add_argument("--tracked", action="store_true", help="every draft in the tracked catalogue (latest revision)")
38
+ imp.add_argument("--no-xml", action="store_true", help="do not fetch the author's .xml")
39
+ imp.add_argument("--sha256", help="expected sha256 of the .txt (one draft with an explicit revision only)")
40
+ imp.add_argument("--print-pin", action="store_true", help="print each revision's digests as tracked.json pins")
41
+ ref = commands.add_parser("refresh", help="fetch the datatracker's current status of drafts")
42
+ ref.add_argument("drafts", nargs="*")
43
+ ref.add_argument("--tracked", action="store_true", help="every draft in the tracked catalogue")
44
+ commands.add_parser("list", help="tracked and stored drafts")
45
+ commands.add_parser("info", help="standing of a draft").add_argument("name")
46
+ commands.add_parser("outline", help="a stored draft's sections").add_argument("name")
47
+ read = commands.add_parser("read", help="read by ref")
48
+ read.add_argument("ref")
49
+ read.add_argument("--max-tokens", type=int, default=2000)
50
+ search = commands.add_parser("search", help="search the stored drafts")
51
+ search.add_argument("query")
52
+ search.add_argument("--name")
53
+ search.add_argument("--limit", type=int, default=10)
54
+ commands.add_parser("cite", help="the citation for a ref").add_argument("ref")
55
+ return parser
56
+
57
+
58
+ def _emit(text: str) -> int:
59
+ out = sys.stdout
60
+ if hasattr(out, "reconfigure"):
61
+ out.reconfigure(encoding="utf-8") # refs carry §, and a Windows console pipe is not UTF-8 by default
62
+ out.write(text if text.endswith("\n") else text + "\n")
63
+ return 0
64
+
65
+
66
+ def _targets(args: argparse.Namespace) -> list[tuple[str, str | None]]:
67
+ found = [names.split_doc(d) for d in args.drafts]
68
+ if args.tracked:
69
+ found += [(n, None) for n in catalogue.TRACKED if n not in {f[0] for f in found}]
70
+ if not found:
71
+ raise LibError(
72
+ "name a draft, or pass --tracked", "e.g. primarysource draft import draft-ietf-sidrops-8210bis"
73
+ )
74
+ return found
75
+
76
+
77
+ def _import(args: argparse.Namespace, store: Store) -> int:
78
+ targets = _targets(args)
79
+ if args.sha256 and (len(targets) != 1 or targets[0][1] is None):
80
+ raise LibError(
81
+ "--sha256 needs exactly one draft with an explicit revision", "e.g. draft-x-y-03 --sha256 <hex>"
82
+ )
83
+ fetcher = fetcher_factory()
84
+ failed = 0
85
+ pins: dict[str, dict[str, dict[str, str]]] = {}
86
+ for name, rev in targets:
87
+ try:
88
+ report = importer.import_draft(
89
+ name, rev, fetcher=fetcher, store=store, want_xml=not args.no_xml, expected_sha256=args.sha256
90
+ )
91
+ except LibError as exc:
92
+ failed += 1
93
+ print(f"{name}: {exc.message}" + (f" ({exc.hint})" if exc.hint else ""), file=sys.stderr)
94
+ continue
95
+ verb = "imported" if report.new else "already stored"
96
+ pin = "pinned" if report.pinned else "not pinned (trusted on first use)"
97
+ print(
98
+ f"{report.name}-{report.rev}: {verb}, text {report.txt_bytes:,} bytes sha256 {report.txt_sha256}, "
99
+ f"xml {report.xml_state}, state {report.status.state}, {pin}, {report.requests} requests -> {report.path}"
100
+ )
101
+ if report.xml_sha256 or report.txt_sha256:
102
+ pins.setdefault(report.name, {})[report.rev] = {
103
+ "txt": report.txt_sha256,
104
+ **({"xml": report.xml_sha256} if report.xml_sha256 else {}),
105
+ }
106
+ if args.print_pin and pins:
107
+ print(json.dumps(pins, indent=1))
108
+ return 1 if failed else 0
109
+
110
+
111
+ def _refresh(args: argparse.Namespace, store: Store) -> int:
112
+ fetcher = fetcher_factory()
113
+ failed = 0
114
+ for name, _rev in _targets(args):
115
+ try:
116
+ status = importer.refresh(name, fetcher=fetcher, store=store)
117
+ except LibError as exc:
118
+ failed += 1
119
+ print(f"{name}: {exc.message}" + (f" ({exc.hint})" if exc.hint else ""), file=sys.stderr)
120
+ continue
121
+ extra = (f", replaced by {', '.join(status.replaced_by)}" if status.replaced_by else "") + (
122
+ f", became {status.became_rfc}" if status.became_rfc else ""
123
+ )
124
+ print(f"{name}: latest -{status.rev}, state {status.state}, expires {status.expires[:10] or '?'}{extra}")
125
+ return 1 if failed else 0
126
+
127
+
128
+ def main(argv: Sequence[str]) -> int:
129
+ args = _parser().parse_args(list(argv))
130
+ store = Store()
131
+ try:
132
+ if args.command == "import":
133
+ return _import(args, store)
134
+ if args.command == "refresh":
135
+ return _refresh(args, store)
136
+ if args.command == "list":
137
+ return _emit(views.list_tracked(store))
138
+ if args.command == "info":
139
+ return _emit(views.info(store, args.name))
140
+ if args.command == "outline":
141
+ return _emit(views.outline(store, args.name))
142
+ if args.command == "read":
143
+ return _emit(views.read(store, args.ref, max_tokens=args.max_tokens))
144
+ if args.command == "search":
145
+ return _emit(views.search(store, args.query, limit=args.limit, name=args.name))
146
+ return _emit(views.cite(store, args.ref))
147
+ except LibError as exc:
148
+ print(exc.as_text(), file=sys.stderr)
149
+ return 1
@@ -0,0 +1,21 @@
1
+ """IETF Internet-Drafts as a primarysource collection. Importing this needs nothing beyond the core."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from primarysource.draft.names import GRAMMAR
6
+ from primarysource.model import Unit
7
+ from primarysource.registry import Collection
8
+
9
+ COLLECTION = Collection(
10
+ name="draft",
11
+ title="IETF Internet-Drafts (from the datatracker and the IETF archive)",
12
+ prefix="draft_",
13
+ unit=Unit.PARAGRAPH,
14
+ grammar=GRAMMAR,
15
+ server="primarysource.draft.server:server",
16
+ cli="primarysource.draft.cli:main",
17
+ )
18
+ """Refs: `draft-ietf-sidrops-8210bis-17§5.1¶2`. The `-17` is the revision; without it a ref resolves to the latest
19
+ stored revision and the result says so. `§` names a section as the draft prints its number (`5.1`, `A.2` for
20
+ Appendix A.2) or, for an unnumbered section, a title slug (`abstract`, `security-considerations`). `¶n` counts the
21
+ paragraph blocks of that section (paragraphs, lists, figures, tables), as the rfc collection does."""
@@ -0,0 +1,158 @@
1
+ """Draft metadata from the IETF datatracker: three small JSON documents per draft, validated before use.
2
+
3
+ - `/doc/<name>/doc.json`, the datatracker's simplified document API (no key, no parameters): title, state,
4
+ latest revision, expiry, authors and the whole revision history with posting dates;
5
+ - `/api/v1/doc/relateddocument/` filtered by relationship, twice: which draft *replaces* this one, and which RFC
6
+ this draft *became* (the `replaces` and `became_rfc` relationship names).
7
+
8
+ Everything the server sends is data, never trusted: every field is type-checked and length-capped, a name
9
+ extracted from a resource URI must match the draft or RFC name grammar, and a response for a different draft
10
+ than the one asked about is refused.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import re
17
+ from dataclasses import replace
18
+ from datetime import UTC, datetime
19
+ from typing import Any
20
+ from urllib.parse import quote
21
+
22
+ from primarysource.draft import names
23
+ from primarysource.draft.net import ARCHIVE, DATATRACKER, Fetcher, Response
24
+ from primarysource.draft.status import Status
25
+ from primarysource.errors import LibError
26
+
27
+ _DRAFT_URI = re.compile(r"/api/v1/doc/document/(draft-[a-z0-9]+(?:-[a-z0-9]+)*)/")
28
+ _RFC_URI = re.compile(r"/api/v1/doc/document/rfc([0-9]{1,5})/")
29
+ _MAX_TEXT = 500
30
+ _MAX_REVISIONS = 100
31
+ _MAX_AUTHORS = 50
32
+ _MAX_LINKS = 20
33
+
34
+
35
+ def doc_url(name: str) -> str:
36
+ return f"https://{DATATRACKER}/doc/{quote(names.parse_name(name), safe='')}/doc.json"
37
+
38
+
39
+ def replaced_by_url(name: str) -> str:
40
+ return (
41
+ f"https://{DATATRACKER}/api/v1/doc/relateddocument/?target__name={quote(names.parse_name(name), safe='')}"
42
+ "&relationship__slug=replaces&format=json"
43
+ )
44
+
45
+
46
+ def became_rfc_url(name: str) -> str:
47
+ return (
48
+ f"https://{DATATRACKER}/api/v1/doc/relateddocument/?source__name={quote(names.parse_name(name), safe='')}"
49
+ "&relationship__slug=became_rfc&format=json"
50
+ )
51
+
52
+
53
+ def archive_url(name: str, rev: str, suffix: str) -> str:
54
+ """The IETF archive's copy of one revision: `suffix` is `txt` or `xml`."""
55
+ if suffix not in ("txt", "xml"):
56
+ raise ValueError(suffix)
57
+ return f"https://{ARCHIVE}/archive/id/{names.with_revision(name, rev)}.{suffix}"
58
+
59
+
60
+ def html_url(name: str, rev: str) -> str:
61
+ """The datatracker's rendering of one revision, which the cite string points at."""
62
+ return f"https://{DATATRACKER}/doc/html/{names.with_revision(name, rev)}"
63
+
64
+
65
+ def _json(reply: Response, what: str) -> dict[str, Any]:
66
+ try:
67
+ data = json.loads(reply.body.decode("utf-8"))
68
+ except (UnicodeDecodeError, ValueError) as exc:
69
+ raise LibError(f"the datatracker's answer for {what} is not JSON", "try again later") from exc
70
+ if not isinstance(data, dict):
71
+ raise LibError(f"the datatracker's answer for {what} is not a JSON object", "")
72
+ return data
73
+
74
+
75
+ def _text(data: dict[str, Any], key: str, required: bool = False) -> str:
76
+ value = data.get(key)
77
+ if value is None and not required:
78
+ return ""
79
+ if not isinstance(value, str):
80
+ raise LibError(f"the datatracker's {key!r} is not text", "")
81
+ return " ".join(value.split())[:_MAX_TEXT]
82
+
83
+
84
+ def parse_doc(reply: Response, name: str, checked: datetime) -> Status:
85
+ """`doc.json` -> a `Status` without the relations (`replaced_by`, `became_rfc`), which come separately."""
86
+ data = _json(reply, name)
87
+ if data.get("name") != name:
88
+ raise LibError(f"asked the datatracker for {name} and it answered for {str(data.get('name'))[:60]!r}", "")
89
+ rev = _text(data, "rev", required=True)
90
+ names.valid_revision(rev)
91
+ history: list[tuple[str, str]] = []
92
+ raw_history = data.get("rev_history")
93
+ if not isinstance(raw_history, list):
94
+ raise LibError("the datatracker's revision history is not a list", "")
95
+ for item in raw_history[:_MAX_REVISIONS]:
96
+ if isinstance(item, dict) and isinstance(item.get("rev"), str) and re.fullmatch(r"[0-9]{2}", item["rev"]):
97
+ history.append((item["rev"], _text(item, "published")))
98
+ history.sort()
99
+ authors: list[str] = []
100
+ for person in data.get("authors") or []:
101
+ if isinstance(person, dict) and isinstance(person.get("name"), str) and person["name"].strip():
102
+ authors.append(" ".join(person["name"].split())[:100])
103
+ group = data.get("group")
104
+ pages = data.get("pages")
105
+ return Status(
106
+ name=name,
107
+ title=_text(data, "title", required=True),
108
+ state=_text(data, "state", required=True),
109
+ rev=rev,
110
+ expires=_text(data, "expires"),
111
+ replaced_by=(),
112
+ became_rfc=None,
113
+ revisions=tuple(history),
114
+ authors=tuple(authors[:_MAX_AUTHORS]),
115
+ group=_text(group, "acronym") if isinstance(group, dict) else "",
116
+ stream=_text(data, "stream"),
117
+ pages=pages if isinstance(pages, int) and not isinstance(pages, bool) else None,
118
+ checked=checked.replace(microsecond=0).isoformat(),
119
+ )
120
+
121
+
122
+ def _related(reply: Response, field: str, pattern: re.Pattern[str]) -> list[str]:
123
+ data = _json(reply, "a relationship query")
124
+ objects = data.get("objects")
125
+ if not isinstance(objects, list):
126
+ raise LibError("the datatracker's relationship answer has no object list", "")
127
+ found: list[str] = []
128
+ for item in objects[:_MAX_LINKS]:
129
+ match = (
130
+ pattern.fullmatch(item.get(field, ""))
131
+ if isinstance(item, dict) and isinstance(item.get(field), str)
132
+ else None
133
+ )
134
+ if match:
135
+ found.append(match.group(1))
136
+ return found
137
+
138
+
139
+ def fetch_status(fetcher: Fetcher, name: str, now: datetime | None = None) -> Status:
140
+ """Three requests: the document, who replaces it, and whether it became an RFC. A 404 on the document means
141
+ the datatracker has never heard of the name."""
142
+ name = names.parse_name(name)
143
+ checked = now or datetime.now(UTC)
144
+ reply = fetcher.get(doc_url(name))
145
+ if reply.status == 404:
146
+ raise LibError(
147
+ f"the datatracker has no draft named {name}", "check the spelling, or search datatracker.ietf.org"
148
+ )
149
+ if reply.status != 200:
150
+ raise LibError(f"the datatracker answered {reply.status} for {name}", "try again later")
151
+ base = parse_doc(reply, name, checked)
152
+ replaced = fetcher.get(replaced_by_url(name))
153
+ became = fetcher.get(became_rfc_url(name))
154
+ if replaced.status != 200 or became.status != 200:
155
+ raise LibError(f"the datatracker's relationship queries for {name} failed", "try again later")
156
+ by = tuple(sorted(_related(replaced, "source", _DRAFT_URI)))
157
+ rfcs = _related(became, "target", _RFC_URI)
158
+ return replace(base, replaced_by=by, became_rfc=f"RFC{int(rfcs[0])}" if rfcs else None)
@@ -0,0 +1,163 @@
1
+ """Import named drafts from the IETF into the library: `primarysource draft import <name>[-rev]`.
2
+
3
+ For one draft the importer makes at most five requests, one second apart (net.Fetcher), in this order:
4
+
5
+ 1. `doc.json`, the replaced-by query and the became-RFC query (datatracker.fetch_status): the draft's state,
6
+ latest revision, expiry and revision history. The snapshot is written first, so a draft found to be dead is
7
+ recorded as dead even if the text fetch then fails;
8
+ 2. the revision's `.txt` from the IETF archive: required, because it is what is segmented and cited;
9
+ 3. the revision's `.xml`, the author's source: optional (many drafts and every pre-xml2rfc draft have none), and
10
+ skipped with `want_xml=False`.
11
+
12
+ Each file is verified before anything is written. A digest pinned in the catalogue (or given as
13
+ `expected_sha256`) must match; a file with no pin is trusted on first use and its digest recorded in the
14
+ revision's `meta.json`. A text that is not text (NUL bytes, absurd lines, an HTML error page behind a 200) is
15
+ refused. A revision already stored is not fetched again: the archive's revisions do not change.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import hashlib
21
+ from dataclasses import dataclass
22
+ from datetime import UTC, datetime
23
+ from pathlib import Path
24
+
25
+ from primarysource.draft import catalogue, datatracker, licence, names, segment, xmlmeta
26
+ from primarysource.draft.net import Fetcher, Response
27
+ from primarysource.draft.status import Status
28
+ from primarysource.draft.store import RevMeta, Store, readonly
29
+ from primarysource.errors import LibError
30
+
31
+
32
+ @dataclass(frozen=True, slots=True)
33
+ class Report:
34
+ """What one import did. `new` is False when the revision was already stored (only the status was refreshed)."""
35
+
36
+ name: str
37
+ rev: str
38
+ path: Path
39
+ new: bool
40
+ txt_bytes: int
41
+ txt_sha256: str
42
+ xml_state: str
43
+ xml_sha256: str
44
+ pinned: bool
45
+ status: Status
46
+ requests: int
47
+
48
+
49
+ def sha256(data: bytes) -> str:
50
+ return hashlib.sha256(data).hexdigest()
51
+
52
+
53
+ def _body(reply: Response, url: str, kind: str) -> bytes | None:
54
+ """The body of a 200 for the right kind of file. None for a 404, and for an optional file (`xml`) that came
55
+ back as something else; a LibError for a `txt` that did, because the archive answers a missing file with an
56
+ HTML page and that is not the draft."""
57
+ if reply.status == 404:
58
+ return None
59
+ if reply.status != 200:
60
+ raise LibError(f"the IETF archive answered {reply.status} for {url}", "try again later")
61
+ kind_ok = "text/plain" if kind == "txt" else "xml"
62
+ if kind_ok not in reply.content_type.lower() or "html" in reply.content_type.lower() or not reply.body.strip():
63
+ if kind == "xml":
64
+ return None
65
+ raise LibError(
66
+ f"{url} came back as {reply.content_type or 'no content type'!r} or empty, not a text draft", ""
67
+ )
68
+ return reply.body
69
+
70
+
71
+ def _check(got: str, pinned: str | None, expected: str | None, what: str) -> bool:
72
+ """Compare a digest with the explicit one (it wins) or the catalogue's; True if something pinned it."""
73
+ want = expected or pinned
74
+ if want is not None and got != want:
75
+ raise LibError(
76
+ f"{what} has sha256 {got}, not the {want} pinned for it: the archive's copy changed or the download "
77
+ "is damaged, and nothing was stored",
78
+ "try again; if it persists, the pin in tracked.json or --sha256 is what to re-check",
79
+ )
80
+ return want is not None
81
+
82
+
83
+ def refresh(name: str, *, fetcher: Fetcher, store: Store, now: datetime | None = None) -> Status:
84
+ """Fetch and store the datatracker's current word on `name` (three requests). Needs no stored revision."""
85
+ if readonly():
86
+ raise LibError("the library is read-only (PRIMARYSOURCE_READONLY): nothing is fetched or written", "")
87
+ status = datatracker.fetch_status(fetcher, names.parse_name(name), now)
88
+ store.write_status(status)
89
+ return status
90
+
91
+
92
+ def import_draft(
93
+ name: str,
94
+ rev: str | None = None,
95
+ *,
96
+ fetcher: Fetcher,
97
+ store: Store,
98
+ want_xml: bool = True,
99
+ expected_sha256: str | None = None,
100
+ now: datetime | None = None,
101
+ ) -> Report:
102
+ """Import one revision of `name`: the latest the datatracker knows when `rev` is None."""
103
+ name = names.parse_name(name)
104
+ if rev is not None:
105
+ names.valid_revision(rev)
106
+ started = fetcher.requests
107
+ status = refresh(name, fetcher=fetcher, store=store, now=now)
108
+ known = [r for r, _when in status.revisions]
109
+ rev = rev or status.rev
110
+ if known and rev not in known:
111
+ raise LibError(f"{name} has no revision {rev} (the datatracker lists: {', '.join(known)})", "")
112
+ if store.has(name, rev):
113
+ meta = store.meta(name, rev)
114
+ return Report(
115
+ name, rev, store.path(name, rev), False, meta.txt_bytes, meta.txt_sha256, meta.xml_state, meta.xml_sha256,
116
+ catalogue.pin_for(name, rev) is not None, status, fetcher.requests - started,
117
+ ) # fmt: skip
118
+ pin = catalogue.pin_for(name, rev)
119
+ txt_url = datatracker.archive_url(name, rev, "txt")
120
+ txt = _body(fetcher.get(txt_url), txt_url, "txt")
121
+ if txt is None:
122
+ raise LibError(
123
+ f"the IETF archive has no text for {name}-{rev}",
124
+ "a draft submitted as XML only is not supported yet (docs/draft.md)",
125
+ )
126
+ pinned = _check(sha256(txt), pin.txt if pin else None, expected_sha256, f"{name}-{rev}.txt")
127
+ text, encoding = segment.decode(txt)
128
+ found = segment.segment(text)
129
+ xml: bytes | None = None
130
+ info = xmlmeta.XmlInfo("skipped")
131
+ if want_xml:
132
+ xml_url = datatracker.archive_url(name, rev, "xml")
133
+ xml = _body(fetcher.get(xml_url), xml_url, "xml")
134
+ if xml is None:
135
+ info = xmlmeta.XmlInfo("absent")
136
+ else:
137
+ _check(sha256(xml), pin.xml if pin else None, None, f"{name}-{rev}.xml")
138
+ info = xmlmeta.inspect(xml)
139
+ if info.state == xmlmeta.REFUSED:
140
+ xml = None # hostile or unusual source: recorded as refused, not stored
141
+ legend = licence.detect(text, info.ipr)
142
+ meta = RevMeta(
143
+ name=name,
144
+ rev=rev,
145
+ title=status.title,
146
+ published=status.published(rev),
147
+ authors=status.authors,
148
+ txt_sha256=sha256(txt),
149
+ txt_bytes=len(txt),
150
+ encoding=encoding,
151
+ xml_state=info.state,
152
+ xml_sha256=sha256(xml) if xml is not None else "",
153
+ xml_bytes=len(xml) if xml is not None else 0,
154
+ restriction=legend.restriction,
155
+ copyright_year=legend.year,
156
+ ipr=legend.ipr,
157
+ imported=(now or datetime.now(UTC)).replace(microsecond=0).isoformat(),
158
+ )
159
+ path = store.install(meta, txt, xml, found)
160
+ return Report(
161
+ name, rev, path, True, len(txt), meta.txt_sha256, info.state, meta.xml_sha256, pinned, status,
162
+ fetcher.requests - started,
163
+ ) # fmt: skip