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,146 @@
1
+ """A draft's life cycle as the datatracker last reported it, and what that means for someone about to cite it.
2
+
3
+ `Status` is a snapshot with the time it was taken. It is stored beside the draft's revisions and printed by
4
+ `info`, and every read and cite result puts its verdict in the `warning:` line, so a caller never quotes a dead
5
+ draft without being told: RFC 2026 §2.2 says an Internet-Draft is removed after six months unchanged, has no
6
+ formal status and is subject to change or removal at any time.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ from dataclasses import asdict, dataclass
13
+ from datetime import UTC, date, datetime
14
+ from typing import Any
15
+
16
+ from primarysource.errors import LibError
17
+
18
+ STALE_DAYS = 14
19
+
20
+ ACTIVE = "active"
21
+ EXPIRED = "expired"
22
+ REPLACED = "replaced"
23
+ RFC = "rfc"
24
+ WITHDRAWN = "withdrawn"
25
+ UNKNOWN = "unknown"
26
+
27
+ _LEVELS = {
28
+ "active": ACTIVE,
29
+ "expired": EXPIRED,
30
+ "replaced": REPLACED,
31
+ "rfc": RFC,
32
+ "withdrawn by submitter": WITHDRAWN,
33
+ "withdrawn by ietf": WITHDRAWN,
34
+ }
35
+
36
+
37
+ @dataclass(frozen=True, slots=True)
38
+ class Status:
39
+ """What the datatracker said about `name` at `checked`. `rev` is the latest revision it knew then;
40
+ `revisions` are (revision, publication date) pairs; `replaced_by` are the drafts that replace this one and
41
+ `became_rfc` the RFC it was published as (`RFC8210`), if any."""
42
+
43
+ name: str
44
+ title: str
45
+ state: str
46
+ rev: str
47
+ expires: str
48
+ replaced_by: tuple[str, ...]
49
+ became_rfc: str | None
50
+ revisions: tuple[tuple[str, str], ...]
51
+ authors: tuple[str, ...]
52
+ group: str
53
+ stream: str
54
+ pages: int | None
55
+ checked: str
56
+
57
+ def to_json(self) -> str:
58
+ return json.dumps(asdict(self), indent=1, ensure_ascii=False) + "\n"
59
+
60
+ @staticmethod
61
+ def from_json(text: str) -> Status:
62
+ """Read a stored snapshot; a damaged one is a LibError that names the command that rewrites it."""
63
+ try:
64
+ raw: dict[str, Any] = json.loads(text)
65
+ return Status(
66
+ name=str(raw["name"]),
67
+ title=str(raw["title"]),
68
+ state=str(raw["state"]),
69
+ rev=str(raw["rev"]),
70
+ expires=str(raw["expires"]),
71
+ replaced_by=tuple(str(x) for x in raw["replaced_by"]),
72
+ became_rfc=None if raw["became_rfc"] is None else str(raw["became_rfc"]),
73
+ revisions=tuple((str(a), str(b)) for a, b in raw["revisions"]),
74
+ authors=tuple(str(x) for x in raw["authors"]),
75
+ group=str(raw["group"]),
76
+ stream=str(raw["stream"]),
77
+ pages=None if raw["pages"] is None else int(raw["pages"]),
78
+ checked=str(raw["checked"]),
79
+ )
80
+ except (ValueError, KeyError, TypeError) as exc:
81
+ raise LibError("a stored status snapshot is damaged", "", "primarysource draft refresh") from exc
82
+
83
+ def published(self, rev: str) -> str:
84
+ """The date revision `rev` was posted (YYYY-MM-DD), or ''."""
85
+ for known, when in self.revisions:
86
+ if known == rev:
87
+ return when[:10]
88
+ return ""
89
+
90
+
91
+ def _day(text: str) -> date | None:
92
+ try:
93
+ return datetime.fromisoformat(text.replace("Z", "+00:00")).date() if text else None
94
+ except ValueError:
95
+ return None
96
+
97
+
98
+ @dataclass(frozen=True, slots=True)
99
+ class Lifecycle:
100
+ """`level` is one of the module constants; `text` is one sentence for a person; `alive` is True only for a
101
+ draft that is active and not past its expiry date."""
102
+
103
+ level: str
104
+ text: str
105
+ alive: bool
106
+
107
+
108
+ def lifecycle(status: Status, today: date | None = None) -> Lifecycle:
109
+ """The verdict on a snapshot. A draft whose datatracker state is still Active but whose expiry date has
110
+ passed is reported as expired by date, because the snapshot may simply be old."""
111
+ today = today or datetime.now(UTC).date()
112
+ level = _LEVELS.get(status.state.strip().lower(), UNKNOWN)
113
+ when = status.expires[:10]
114
+ if level == RFC or (status.became_rfc and level != REPLACED):
115
+ rfc = status.became_rfc or "an RFC"
116
+ return Lifecycle(RFC, f"published as {rfc}: cite the RFC, not this draft", False)
117
+ if level == REPLACED:
118
+ by = ", ".join(status.replaced_by) or "another draft"
119
+ return Lifecycle(REPLACED, f"replaced by {by}: this draft is dead, read and cite its successor", False)
120
+ if level == WITHDRAWN:
121
+ return Lifecycle(WITHDRAWN, f"{status.state.lower()}: do not rely on it", False)
122
+ if level == EXPIRED:
123
+ return Lifecycle(
124
+ EXPIRED,
125
+ f"expired on {when or 'an unknown date'}: not an active draft, and may have been abandoned",
126
+ False,
127
+ )
128
+ if level == ACTIVE:
129
+ end = _day(status.expires)
130
+ if end is not None and end < today:
131
+ return Lifecycle(
132
+ EXPIRED,
133
+ f"past its expiry date {when} though the datatracker listed it Active at the last check",
134
+ False,
135
+ )
136
+ return Lifecycle(
137
+ ACTIVE, f"active, expires {when}: work in progress" if when else "active: work in progress", True
138
+ )
139
+ return Lifecycle(UNKNOWN, f"the datatracker reported the state {status.state!r}: check it before citing", False)
140
+
141
+
142
+ def staleness(status: Status, today: date | None = None) -> int:
143
+ """Days since the snapshot was taken (0 if its date cannot be read)."""
144
+ today = today or datetime.now(UTC).date()
145
+ taken = _day(status.checked)
146
+ return max(0, (today - taken).days) if taken else 0
@@ -0,0 +1,199 @@
1
+ """The on-disk library: `$PRIMARYSOURCE_HOME/draft/<name>/<rev>/` per revision, `<name>/status.json` per draft.
2
+
3
+ draft-x-y/status.json the datatracker's last word (status.py), replaced on every refresh
4
+ draft-x-y/03/draft.txt the archive's text, byte for byte as fetched
5
+ draft-x-y/03/draft.xml the author's source, when the archive has one
6
+ draft-x-y/03/meta.json what was fetched, from where, its sha256, and its licence legend
7
+ draft-x-y/03/sections.json a cache of the segmentation; rebuilt from draft.txt when missing or stale
8
+
9
+ A revision directory appears whole or not at all (it is built beside the others and renamed into place), and
10
+ `meta.json` is what a reader trusts. A stored revision is never overwritten: the archive's revisions do not
11
+ change, so a different digest for one is an error to report, not an update to apply.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import contextlib
17
+ import json
18
+ import os
19
+ import re
20
+ import secrets
21
+ import shutil
22
+ from dataclasses import asdict, dataclass, fields
23
+ from pathlib import Path
24
+ from typing import Any
25
+
26
+ from primarysource.draft import names, segment
27
+ from primarysource.draft.status import Status
28
+ from primarysource.errors import LibError
29
+ from primarysource.home import atomic_write
30
+ from primarysource.home import collection_home as core_collection_home
31
+
32
+ COLLECTION = "draft"
33
+ READONLY_VARIABLE = "PRIMARYSOURCE_READONLY"
34
+ _REV_DIR = re.compile(r"[0-9]{2}")
35
+
36
+
37
+ def readonly() -> bool:
38
+ """True when `primarysource mcp --read-only` (or the variable) asked for no writes and no fetching."""
39
+ return os.environ.get(READONLY_VARIABLE, "").strip().lower() in ("1", "true", "yes", "on")
40
+
41
+
42
+ @dataclass(frozen=True, slots=True)
43
+ class RevMeta:
44
+ """What one stored revision is. `xml_state` is `v2`, `v3`, `absent`, `refused`, `unparseable` or `skipped`."""
45
+
46
+ name: str
47
+ rev: str
48
+ title: str
49
+ published: str
50
+ authors: tuple[str, ...]
51
+ txt_sha256: str
52
+ txt_bytes: int
53
+ encoding: str
54
+ xml_state: str
55
+ xml_sha256: str
56
+ xml_bytes: int
57
+ restriction: str
58
+ copyright_year: int
59
+ ipr: str
60
+ imported: str
61
+
62
+ def to_json(self) -> str:
63
+ return json.dumps(asdict(self), indent=1, ensure_ascii=False) + "\n"
64
+
65
+ @staticmethod
66
+ def from_json(text: str) -> RevMeta:
67
+ try:
68
+ raw: dict[str, Any] = json.loads(text)
69
+ values = {f.name: raw[f.name] for f in fields(RevMeta)}
70
+ values["authors"] = tuple(str(a) for a in values["authors"])
71
+ for key in ("txt_bytes", "xml_bytes", "copyright_year"):
72
+ values[key] = int(values[key])
73
+ for key in set(values) - {"authors", "txt_bytes", "xml_bytes", "copyright_year"}:
74
+ values[key] = str(values[key])
75
+ return RevMeta(**values)
76
+ except (ValueError, KeyError, TypeError) as exc:
77
+ raise LibError(
78
+ "a stored revision's meta.json is damaged",
79
+ "",
80
+ "import that revision again: primarysource draft import",
81
+ ) from exc
82
+
83
+
84
+ class Store:
85
+ """One draft library. `root` is the collection's directory; by default `$PRIMARYSOURCE_HOME/draft`."""
86
+
87
+ def __init__(self, root: Path | None = None) -> None:
88
+ self.root = root if root is not None else core_collection_home(COLLECTION)
89
+
90
+ def _dir(self, name: str, rev: str | None = None) -> Path:
91
+ base = self.root / names.parse_name(name)
92
+ return base if rev is None else base / names.valid_revision(rev)
93
+
94
+ def path(self, name: str, rev: str) -> Path:
95
+ """Where one revision lives (it may not exist)."""
96
+ return self._dir(name, rev)
97
+
98
+ def names(self) -> list[str]:
99
+ """Drafts with at least one stored revision, sorted."""
100
+ if not self.root.is_dir():
101
+ return []
102
+ return sorted(p.name for p in self.root.iterdir() if p.is_dir() and self.revisions(p.name))
103
+
104
+ def revisions(self, name: str) -> list[str]:
105
+ """Stored revisions of `name`, oldest first. A directory without `meta.json` is a failed import: ignored."""
106
+ base = self._dir(name)
107
+ if not base.is_dir():
108
+ return []
109
+ return sorted(p.name for p in base.iterdir() if _REV_DIR.fullmatch(p.name) and (p / "meta.json").is_file())
110
+
111
+ def has(self, name: str, rev: str) -> bool:
112
+ return (self._dir(name, rev) / "meta.json").is_file()
113
+
114
+ def resolve(self, name: str, rev: str | None) -> tuple[str, bool]:
115
+ """The revision to read and whether it was chosen (True) because none was named: the latest stored."""
116
+ stored = self.revisions(name)
117
+ if rev is not None:
118
+ if rev not in stored:
119
+ have = ", ".join(stored) or "none"
120
+ raise LibError(
121
+ f"revision {rev} of {name} is not stored (stored: {have})",
122
+ f"draft_info('{name}') says which revisions exist",
123
+ f"primarysource draft import {name}-{rev}",
124
+ )
125
+ return rev, False
126
+ if not stored:
127
+ raise LibError(
128
+ f"{name} is not stored", "draft_list lists what is stored", f"primarysource draft import {name}"
129
+ )
130
+ return stored[-1], True
131
+
132
+ def meta(self, name: str, rev: str) -> RevMeta:
133
+ path = self._dir(name, rev) / "meta.json"
134
+ try:
135
+ return RevMeta.from_json(path.read_text(encoding="utf-8"))
136
+ except OSError as exc:
137
+ raise LibError(
138
+ f"{name}-{rev} is not stored",
139
+ "draft_info says which revisions are",
140
+ f"primarysource draft import {name}-{rev}",
141
+ ) from exc
142
+
143
+ def status(self, name: str) -> Status | None:
144
+ """The datatracker snapshot for `name`, or None if it was never fetched."""
145
+ path = self._dir(name) / "status.json"
146
+ try:
147
+ return Status.from_json(path.read_text(encoding="utf-8"))
148
+ except FileNotFoundError:
149
+ return None
150
+
151
+ def write_status(self, status: Status) -> None:
152
+ atomic_write(self._dir(status.name) / "status.json", status.to_json().encode("utf-8"))
153
+
154
+ def raw_text(self, name: str, rev: str) -> bytes:
155
+ try:
156
+ return (self._dir(name, rev) / "draft.txt").read_bytes()
157
+ except OSError as exc:
158
+ raise LibError(
159
+ f"{name}-{rev} has no stored text", "", f"primarysource draft import {name}-{rev}"
160
+ ) from exc
161
+
162
+ def segmented(self, name: str, rev: str) -> segment.Segmented:
163
+ """The sections of a stored revision, from the cache when it is current, else from the stored text (and the
164
+ cache is rewritten unless the library is read-only)."""
165
+ folder = self._dir(name, rev)
166
+ try:
167
+ cached = segment.Segmented.from_json((folder / "sections.json").read_text(encoding="utf-8"))
168
+ except OSError:
169
+ cached = None
170
+ if cached is not None:
171
+ return cached
172
+ text, _encoding = segment.decode(self.raw_text(name, rev))
173
+ found = segment.segment(text)
174
+ if not readonly():
175
+ with contextlib.suppress(OSError): # a read-only volume is a supported place to serve from
176
+ atomic_write(folder / "sections.json", found.to_json().encode("utf-8"))
177
+ return found
178
+
179
+ def install(self, meta: RevMeta, txt: bytes, xml: bytes | None, found: segment.Segmented) -> Path:
180
+ """Write one revision whole, or not at all. Refuses to replace a stored revision."""
181
+ target = self._dir(meta.name, meta.rev)
182
+ if (target / "meta.json").is_file():
183
+ raise LibError(f"{meta.name}-{meta.rev} is already stored", "")
184
+ base = target.parent
185
+ base.mkdir(parents=True, exist_ok=True)
186
+ staging = base / f".{meta.rev}.part-{secrets.token_hex(4)}"
187
+ staging.mkdir()
188
+ try:
189
+ atomic_write(staging / "draft.txt", txt)
190
+ if xml is not None:
191
+ atomic_write(staging / "draft.xml", xml)
192
+ atomic_write(staging / "sections.json", found.to_json().encode("utf-8"))
193
+ atomic_write(staging / "meta.json", meta.to_json().encode("utf-8"))
194
+ if target.exists(): # a failed earlier import left a directory without meta.json
195
+ shutil.rmtree(target)
196
+ os.replace(staging, target)
197
+ finally:
198
+ shutil.rmtree(staging, ignore_errors=True)
199
+ return target
@@ -0,0 +1,104 @@
1
+ {
2
+ "schema": 1,
3
+ "terms": {
4
+ "licence": "IETF Trust Legal Provisions 5.0 §3.c, with BCP 78 (RFC 5378)",
5
+ "placement": "volume",
6
+ "terms_url": "https://trustee.ietf.org/documents/trust-legal-provisions/tlp-5/",
7
+ "terms_read": "2026-11-13",
8
+ "summary": "Authors keep the copyright in their Internet-Drafts (RFC 5378 §3.6); the IETF Trust may sublicense. TLP 5.0 §3.c allows copying and distributing a Contribution in full and without modification, or unmodified portions with attribution, but only for Contributions submitted after 2015-03-25 and not where a draft's own legend forbids it. Drafts are fetched by the reader onto a volume; none is shipped in an image."
9
+ },
10
+ "drafts": [
11
+ {
12
+ "name": "draft-ietf-sidrops-8210bis",
13
+ "why": "RPKI-to-Router protocol version 2 and the ASPA PDU: the specification rtrkit implements beyond RFC 8210.",
14
+ "consumers": [
15
+ "rtrkit"
16
+ ],
17
+ "rev_read": "29",
18
+ "state": "Active",
19
+ "replaced_by": [],
20
+ "became_rfc": null,
21
+ "expires": "2027-05-16",
22
+ "checked": "2026-11-13",
23
+ "pins": {
24
+ "29": {
25
+ "txt": "9519b014550e43630ba484c2eda50c48a2a6bad4ec286690e695b9b6aa9681cf",
26
+ "xml": "1ce63b8f7338d9e2a073e47b8f82012f8c9f86ead6d0afe9d71b4986a0841292"
27
+ }
28
+ }
29
+ },
30
+ {
31
+ "name": "draft-ietf-sidrops-aspa-profile",
32
+ "why": "The ASPA signed-object profile that draft-ietf-sidrops-8210bis section 1.2 cites for the ASPA PDU; no portfolio row names it yet.",
33
+ "consumers": [],
34
+ "rev_read": "31",
35
+ "state": "Active",
36
+ "replaced_by": [],
37
+ "became_rfc": null,
38
+ "expires": "2027-05-14",
39
+ "checked": "2026-11-13",
40
+ "pins": {
41
+ "31": {
42
+ "txt": "6f4b8381e1ef85164dd855a04c7692c476ae454b7e269d56d023df8b94db5b56",
43
+ "xml": "762ebb9587b186fe29b9a9e133f6572daaa4286fd0854dc5ba6c8278b3d14199"
44
+ }
45
+ }
46
+ },
47
+ {
48
+ "name": "draft-ietf-sidrops-aspa-verification",
49
+ "why": "ASPA-based AS_PATH verification. RFC 9774 section 9.2 cites it as a Work in Progress.",
50
+ "consumers": [
51
+ "routevalid",
52
+ "asnkit"
53
+ ],
54
+ "rev_read": "29",
55
+ "state": "Active",
56
+ "replaced_by": [],
57
+ "became_rfc": null,
58
+ "expires": "2027-04-19",
59
+ "checked": "2026-11-13",
60
+ "pins": {
61
+ "29": {
62
+ "txt": "3203e57006b2700b67570394951b94f68b98967afc14d75f4b52b2e90220b699",
63
+ "xml": "bdf3e0200c22eefab17264bf2abd89adcad73d88dc6397aadea84aaa4874fccb"
64
+ }
65
+ }
66
+ },
67
+ {
68
+ "name": "draft-koch-librepgp",
69
+ "why": "LibrePGP message format, an extension of OpenPGP (RFC 4880, 5581, 6637, all since obsoleted by RFC 9580, which is a different line): what pgpenv needs for LibrePGP.",
70
+ "consumers": [
71
+ "pgpenv"
72
+ ],
73
+ "rev_read": "06",
74
+ "state": "Active",
75
+ "replaced_by": [],
76
+ "became_rfc": null,
77
+ "expires": "2027-03-11",
78
+ "checked": "2026-11-13",
79
+ "pins": {
80
+ "06": {
81
+ "txt": "c82a7412d728408ed442b2c940e225a58f3db96d288008290e31f45741b2fdc1"
82
+ }
83
+ }
84
+ },
85
+ {
86
+ "name": "draft-uberti-behave-turn-rest",
87
+ "why": "A REST API for obtaining ephemeral TURN credentials. The datatracker shows it expired in 2014, neither replaced nor published as an RFC.",
88
+ "consumers": [
89
+ "turnkit"
90
+ ],
91
+ "rev_read": "00",
92
+ "state": "Expired",
93
+ "replaced_by": [],
94
+ "became_rfc": null,
95
+ "expires": "2014-01-16",
96
+ "checked": "2026-11-13",
97
+ "pins": {
98
+ "00": {
99
+ "txt": "ebcd0450f37481dfb2817482af1bc09532fdf7691016542498b6e4a64bbb7636"
100
+ }
101
+ }
102
+ }
103
+ ]
104
+ }