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,325 @@
1
+ """IANA registry XML as rows with their citations.
2
+
3
+ The format (checked 2026-11-13 against the files themselves and their RELAX NG, `_support/iana-registry.rng`):
4
+ a root `<registry id="<file name>">` in the namespace `http://www.iana.org/assignments` holds a `title`, an
5
+ `updated` date, sub-`<registry id=...>` elements (which may nest) and a `<people>` table. A registry holds
6
+ `registration_rule`, `expert`, `note`, `range` and `xref` metadata and `<record>` rows. A record's columns are
7
+ ordinary child elements whose names differ per registry (`value`, `name`, `description`, `type`, `code`,
8
+ `length`, ...), plus `xref` citations; `date` and `updated` attributes mark when it was added or changed.
9
+
10
+ This module takes a parsed tree and makes it into frozen dataclasses. It never reads a file and never fetches.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import re
16
+ import xml.etree.ElementTree as ET # noqa: S405 - elements come from xmlsafe.parse; nothing is parsed here
17
+ from dataclasses import dataclass, field
18
+
19
+ from primarysource.errors import LibError
20
+
21
+ NAMESPACE = "http://www.iana.org/assignments"
22
+ _NS = "{" + NAMESPACE + "}"
23
+ _ID = re.compile(r"[A-Za-z_][A-Za-z0-9_.\-]*")
24
+ _RFC = re.compile(r"(rfc|bcp|std)(\d+)")
25
+ _NUMBER = r"(?:0[xX][0-9A-Fa-f]+|\d+)"
26
+ _RANGE = re.compile(rf"\s*({_NUMBER})(?:\s*-\s*({_NUMBER}))?\s*")
27
+ _SPACE = re.compile(r"\s+")
28
+
29
+
30
+ def _local(element: ET.Element) -> str:
31
+ tag = element.tag
32
+ return tag[len(_NS) :] if tag.startswith(_NS) else tag
33
+
34
+
35
+ def _squash(text: str) -> str:
36
+ return _SPACE.sub(" ", text).strip()
37
+
38
+
39
+ @dataclass(frozen=True, slots=True)
40
+ class Xref:
41
+ """One `<xref>`: what a registry row or note cites. `kind` is the schema's `type`: rfc, rfc-errata, draft,
42
+ uri, registry, person, note or text. `fragment` is the part cited (`Section 3`, `Appendix A`), or empty;
43
+ `section` is the section number alone, for a ref the `rfc` collection can read."""
44
+
45
+ kind: str
46
+ data: str
47
+ fragment: str = ""
48
+ section: str = ""
49
+ text: str = ""
50
+
51
+ @property
52
+ def rfc(self) -> str:
53
+ """`RFC4271`, `BCP14` or `STD5` for an rfc xref, else empty."""
54
+ match = _RFC.fullmatch(self.data) if self.kind == "rfc" else None
55
+ return f"{match[1].upper()}{match[2]}" if match else ""
56
+
57
+ @property
58
+ def ref(self) -> str:
59
+ """A ref the `rfc` collection reads (`RFC4271§3.1`), or `RFC4271` without a section; empty if not an RFC."""
60
+ if not self.rfc:
61
+ return ""
62
+ return f"{self.rfc}§{self.section}" if self.section else self.rfc
63
+
64
+ @property
65
+ def label(self) -> str:
66
+ """How the citation reads in running text: `RFC4271, Section 3`, `draft-ietf-x-00`, `RFC errata 123`."""
67
+ if self.kind == "rfc" and self.rfc:
68
+ base = self.rfc
69
+ elif self.kind == "rfc-errata":
70
+ base = f"RFC errata {self.data}"
71
+ elif self.kind == "note":
72
+ base = f"note {self.data}"
73
+ elif self.kind == "registry":
74
+ base = f"registry {self.data}"
75
+ elif self.kind in ("person", "text"):
76
+ base = self.text or self.data
77
+ else:
78
+ base = self.data
79
+ return f"{base}, {self.fragment}" if self.fragment else base
80
+
81
+
82
+ @dataclass(frozen=True, slots=True)
83
+ class Record:
84
+ """A row. `key` is the `<value>` column, else the first column's text. `fields` are the columns in file
85
+ order as (element name, text) with empty ones dropped; `xrefs` every citation under the row."""
86
+
87
+ ordinal: int
88
+ key: str
89
+ fields: tuple[tuple[str, str], ...]
90
+ xrefs: tuple[Xref, ...] = ()
91
+ date: str = ""
92
+ updated: str = ""
93
+ id: str = ""
94
+
95
+ def field(self, name: str) -> str:
96
+ return next((text for key, text in self.fields if key == name), "")
97
+
98
+ def text(self) -> str:
99
+ """One line: `value: 1 | name: OPEN | refs: RFC4271`."""
100
+ parts = [f"{name}: {text}" for name, text in self.fields]
101
+ if self.xrefs:
102
+ parts.append("refs: " + ", ".join(dict.fromkeys(x.label for x in self.xrefs)))
103
+ return " | ".join(parts) if parts else self.key
104
+
105
+
106
+ @dataclass(frozen=True, slots=True)
107
+ class Range:
108
+ """A `<range>`: which part of the number space follows which registration rule."""
109
+
110
+ value: str
111
+ rule: str
112
+ hex: str = ""
113
+ note: str = ""
114
+
115
+
116
+ @dataclass(frozen=True, slots=True)
117
+ class Registry:
118
+ """One `<registry>`: the root's file-level registry and every sub-registry, flattened in file order. `parent`
119
+ is the enclosing registry's id, or empty for the root."""
120
+
121
+ id: str
122
+ title: str
123
+ parent: str = ""
124
+ created: str = ""
125
+ updated: str = ""
126
+ rules: tuple[str, ...] = ()
127
+ experts: tuple[str, ...] = ()
128
+ notes: tuple[str, ...] = ()
129
+ ranges: tuple[Range, ...] = ()
130
+ xrefs: tuple[Xref, ...] = ()
131
+ records: tuple[Record, ...] = ()
132
+ footnotes: tuple[tuple[str, str], ...] = ()
133
+
134
+
135
+ @dataclass(frozen=True, slots=True)
136
+ class RegistryFile:
137
+ """One registry file (`dns-parameters.xml`): its registries in file order and the people it names."""
138
+
139
+ name: str
140
+ title: str
141
+ updated: str
142
+ registries: tuple[Registry, ...]
143
+ people: dict[str, str] = field(default_factory=dict)
144
+
145
+ def registry(self, registry_id: str) -> Registry | None:
146
+ return next((r for r in self.registries if r.id == registry_id), None)
147
+
148
+ def children(self, registry_id: str) -> tuple[Registry, ...]:
149
+ return tuple(r for r in self.registries if r.parent == registry_id)
150
+
151
+
152
+ def _xref(element: ET.Element, people: dict[str, str]) -> Xref:
153
+ kind = element.get("type", "")
154
+ data = element.get("data", "")
155
+ fragment = ""
156
+ section = ""
157
+ for attribute in ("section", "appendix", "page", "table", "figure", "anchor"):
158
+ value = element.get(attribute)
159
+ if value:
160
+ fragment = f"anchor {value}" if attribute == "anchor" else f"{attribute.capitalize()} {value}"
161
+ if attribute == "section":
162
+ section = value
163
+ break
164
+ text = _squash("".join(element.itertext()))
165
+ if kind == "person":
166
+ text = people.get(data, "") or data.replace("_", " ")
167
+ return Xref(kind, data, fragment, section, text)
168
+
169
+
170
+ def _inline(element: ET.Element, people: dict[str, str]) -> str:
171
+ """Text with inline xrefs spelled out (`[RFC6762]`), whitespace collapsed."""
172
+ parts = [element.text or ""]
173
+ for child in element:
174
+ if _local(child) == "xref":
175
+ parts.append(f"[{_xref(child, people).label}]")
176
+ else:
177
+ parts.append(_inline(child, people))
178
+ parts.append(child.tail or "")
179
+ return _squash("".join(parts))
180
+
181
+
182
+ def _all_xrefs(element: ET.Element, people: dict[str, str]) -> tuple[Xref, ...]:
183
+ found = [_xref(x, people) for x in element.iter() if _local(x) == "xref"]
184
+ return tuple(dict.fromkeys(found))
185
+
186
+
187
+ def _record(element: ET.Element, ordinal: int, people: dict[str, str]) -> Record:
188
+ columns: list[tuple[str, str]] = []
189
+ for child in element:
190
+ name = _local(child)
191
+ if name == "xref":
192
+ continue
193
+ text = _inline(child, people)
194
+ if text:
195
+ columns.append((name, text))
196
+ key = next((text for name, text in columns if name == "value"), columns[0][1] if columns else "")
197
+ return Record(
198
+ ordinal=ordinal,
199
+ key=key,
200
+ fields=tuple(columns),
201
+ xrefs=_all_xrefs(element, people),
202
+ date=element.get("date", ""),
203
+ updated=element.get("updated", ""),
204
+ id=element.get("id", ""),
205
+ )
206
+
207
+
208
+ def _range(element: ET.Element, people: dict[str, str]) -> Range:
209
+ def column(name: str) -> str:
210
+ child = element.find(_NS + name)
211
+ return _inline(child, people) if child is not None else ""
212
+
213
+ return Range(column("value"), column("registration_rule"), column("hex"), column("note"))
214
+
215
+
216
+ def _registry(element: ET.Element, parent: str, people: dict[str, str], out: list[Registry]) -> None:
217
+ registry_id = element.get("id", "")
218
+ if not _ID.fullmatch(registry_id):
219
+ raise LibError(f"a registry has the id {registry_id!r}, which is not an IANA registry id", "")
220
+ slot = len(out)
221
+ out.append(Registry(registry_id, "")) # keeps file order; replaced below once the children are done
222
+ title = created = updated = ""
223
+ rules: list[str] = []
224
+ experts: list[str] = []
225
+ notes: list[str] = []
226
+ ranges: list[Range] = []
227
+ xrefs: list[Xref] = []
228
+ records: list[Record] = []
229
+ footnotes: list[tuple[str, str]] = []
230
+ for child in element:
231
+ name = _local(child)
232
+ if name == "title":
233
+ title = _inline(child, people)
234
+ elif name == "created":
235
+ created = _squash(child.text or "")
236
+ elif name == "updated":
237
+ updated = _squash(child.text or "")
238
+ elif name == "registration_rule":
239
+ rules.append(_inline(child, people))
240
+ elif name == "expert":
241
+ experts.append(_inline(child, people))
242
+ elif name == "note":
243
+ notes.append(_inline(child, people))
244
+ elif name == "range":
245
+ ranges.append(_range(child, people))
246
+ elif name == "xref":
247
+ xrefs.append(_xref(child, people))
248
+ elif name == "record":
249
+ records.append(_record(child, len(records) + 1, people))
250
+ elif name == "footnote":
251
+ footnotes.append((child.get("anchor", ""), _inline(child, people)))
252
+ elif name == "registry":
253
+ _registry(child, registry_id, people, out)
254
+ out[slot] = Registry(
255
+ registry_id,
256
+ title,
257
+ parent,
258
+ created,
259
+ updated,
260
+ tuple(rules),
261
+ tuple(experts),
262
+ tuple(notes),
263
+ tuple(ranges),
264
+ tuple(dict.fromkeys(xrefs)),
265
+ tuple(records),
266
+ tuple(footnotes),
267
+ )
268
+
269
+
270
+ def from_tree(root: ET.Element, name: str | None = None) -> RegistryFile:
271
+ """Build a `RegistryFile` from a tree parsed by `xmlsafe.parse`. A tree that is not an IANA registry is
272
+ refused; so is a root whose `id` is not `name`, when `name` is given."""
273
+ if root.tag != _NS + "registry":
274
+ raise LibError(
275
+ f"the root element is {root.tag!r}, not an IANA <registry> in {NAMESPACE}",
276
+ "this is not a registry file; check the URL",
277
+ )
278
+ root_id = root.get("id", "")
279
+ if not _ID.fullmatch(root_id):
280
+ raise LibError(f"the root registry has the id {root_id!r}", "this is not a registry file")
281
+ if name is not None and root_id != name:
282
+ raise LibError(f"the file is registry {root_id!r}, not {name!r}", "check the URL against the catalogue")
283
+ people: dict[str, str] = {}
284
+ for person in root.iter(_NS + "person"):
285
+ label = person.find(_NS + "name")
286
+ if person.get("id") and label is not None:
287
+ people[person.get("id", "")] = _squash(label.text or "")
288
+ registries: list[Registry] = []
289
+ _registry(root, "", people, registries)
290
+ top = registries[0]
291
+ return RegistryFile(root_id, top.title, top.updated, tuple(registries), people)
292
+
293
+
294
+ def _int(text: str) -> int:
295
+ """Decimal, or hex with a 0x prefix. Leading zeros are decimal (`010` is ten), unlike `int(text, 0)`."""
296
+ return int(text, 16) if text[:2].lower() == "0x" else int(text, 10)
297
+
298
+
299
+ def numeric_range(text: str) -> tuple[int, int] | None:
300
+ """`6-255` -> (6, 255); `0x0010` -> (16, 16); anything else (a name, `*`, `1.2`) -> None."""
301
+ match = _RANGE.fullmatch(text)
302
+ if match is None:
303
+ return None
304
+ low = _int(match[1])
305
+ high = _int(match[2] or match[1])
306
+ return (low, high) if low <= high else None
307
+
308
+
309
+ def number_of(text: str) -> int | None:
310
+ """A single number (decimal or 0x hex) as an int, else None."""
311
+ span = numeric_range(text)
312
+ return span[0] if span is not None and span[0] == span[1] else None
313
+
314
+
315
+ def matches(record: Record, query: str) -> bool:
316
+ """Does `query` name this row by value? A single number matches a row whose `value` (or `hex`) is that
317
+ number or a range containing it; anything else matches a row whose `value` equals it, ignoring case."""
318
+ wanted = number_of(query)
319
+ if wanted is None:
320
+ return query.strip().casefold() == record.key.casefold()
321
+ for candidate in (record.key, record.field("hex")):
322
+ span = numeric_range(candidate)
323
+ if span is not None and span[0] <= wanted <= span[1]:
324
+ return True
325
+ return False
@@ -0,0 +1,315 @@
1
+ """Read, look up and search imported IANA registries. Every function returns text with the fixed footer and raises
2
+ a `LibError` that names its remedy; nothing here writes or fetches.
3
+
4
+ Refs (grammar in `docs/iana.md`):
5
+
6
+ dns-parameters the file: its registries, each with the ref that reads it
7
+ dns-parameters§dns-parameters-4 one registry: header, then its records
8
+ dns-parameters#dns-parameters-4 the same, in IANA's own URL-fragment spelling
9
+ bgp-parameters§bgp-parameters-2#3 the record(s) whose value is 3 (a range containing 3 counts)
10
+ bgp-parameters§bgp-parameters-2¶4 from the 4th record of the registry, in file order
11
+ bgp-parameters§bgp-parameters-2#3¶2 from the 2nd record whose value is 3
12
+ dns-parameters¶5 the file's registry listing, from its 5th registry
13
+
14
+ A read's `next:` footer is the ref that reads the next page; a `¶n` read starts at record n and runs on.
15
+ iana:bgp-parameters§bgp-parameters-2¶4 an `iana:` prefix is accepted and dropped
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from dataclasses import dataclass
21
+ from pathlib import Path
22
+
23
+ from primarysource import cursor
24
+ from primarysource.budget import page as budget_page
25
+ from primarysource.errors import LibError
26
+ from primarysource.iana import catalogue, store
27
+ from primarysource.iana.catalogue import BASE, NAME
28
+ from primarysource.iana.model import Record, Registry, RegistryFile, matches
29
+ from primarysource.model import span_hash
30
+ from primarysource.refs import Ref, RefGrammar
31
+ from primarysource.render import render
32
+
33
+ DOC = r"[a-z0-9][a-z0-9._-]*"
34
+ REGISTRY = r"[A-Za-z_][A-Za-z0-9_.\-]*"
35
+ _GRAMMAR = RefGrammar(DOC, REGISTRY)
36
+ UNIT = "record (¶n is the nth record of the registry, in file order)"
37
+ MAX_QUERY = 200
38
+ _SNIPPET = 200
39
+
40
+
41
+ @dataclass(frozen=True, slots=True)
42
+ class Target:
43
+ """A parsed ref: the file, the registry (None for the file itself), a value to look up and a record ordinal."""
44
+
45
+ file: str
46
+ registry: str | None = None
47
+ value: str | None = None
48
+ para: int | None = None
49
+
50
+
51
+ def parse_ref(text: str) -> Target:
52
+ """Parse a ref or raise LibError. With a `§`, the anchor is a value; with no `§`, it names a registry."""
53
+ body = text.strip()
54
+ if body[:5].lower() == "iana:":
55
+ body = body[5:]
56
+ body, listing_para = cursor.split_doc_para(body)
57
+ parsed = _GRAMMAR.parse(body)
58
+ if parsed.lines is not None:
59
+ raise LibError(f"not an iana ref: {text!r}", "registries have no line ranges; use ¶n for the nth record")
60
+ if parsed.doc is None:
61
+ raise LibError(
62
+ f"not an iana ref: {text!r}", "start with the registry file's name, e.g. dns-parameters§dns-parameters-4"
63
+ )
64
+ if parsed.section is not None:
65
+ return Target(parsed.doc, parsed.section, parsed.anchor, parsed.para)
66
+ if parsed.anchor is not None:
67
+ return Target(parsed.doc, parsed.anchor, None, parsed.para)
68
+ if parsed.para is not None:
69
+ raise LibError(f"not an iana ref: {text!r}", "¶n needs a registry: dns-parameters§dns-parameters-4¶2")
70
+ return Target(parsed.doc, None, None, listing_para)
71
+
72
+
73
+ def format_ref(file: str, registry: str | None = None, value: str | None = None, para: int | None = None) -> str:
74
+ out = file
75
+ if registry is not None:
76
+ out += f"§{registry}"
77
+ if value is not None:
78
+ out += f"#{value}"
79
+ if para is not None:
80
+ out += f"¶{para}"
81
+ return out
82
+
83
+
84
+ class IanaRefs:
85
+ """The collection's `RefParser`: `parse_ref` and `format_ref`, with the core's `Ref` carrying the file as `doc`,
86
+ the registry as `section`, a value as `anchor` and the record ordinal as `para`."""
87
+
88
+ def parse(self, text: str) -> Ref:
89
+ target = parse_ref(text)
90
+ return Ref(target.file, target.registry, target.para, target.value)
91
+
92
+ def format(self, ref: Ref) -> str:
93
+ if ref.doc is None or ref.lines is not None:
94
+ raise LibError(f"not an iana ref: {ref!r}", "an iana ref starts with the registry file's name")
95
+ return format_ref(ref.doc, ref.section, ref.anchor, ref.para)
96
+
97
+
98
+ GRAMMAR = IanaRefs()
99
+
100
+
101
+ def _url(file: str, registry: str | None = None) -> str:
102
+ return f"{BASE}{file}" + (f"#{registry}" if registry and registry != file else "")
103
+
104
+
105
+ def _registry(loaded: store.Loaded, target: Target) -> Registry:
106
+ found = loaded.file.registry(target.registry or "")
107
+ if found is None:
108
+ ids = ", ".join(r.id for r in loaded.file.registries[:6])
109
+ raise LibError(
110
+ f"{target.file} has no registry {target.registry!r} (its registries start: {ids}, ...)",
111
+ f"read {target.file} to list them with their refs",
112
+ )
113
+ return found
114
+
115
+
116
+ def _warning(loaded: store.Loaded) -> str:
117
+ if loaded.digest_ok is False:
118
+ return "the file differs from the sha256 recorded when it was imported; import it again to trust it"
119
+ if loaded.digest_ok is None:
120
+ return "the file has no manifest entry, so its provenance is not recorded"
121
+ return ""
122
+
123
+
124
+ def _header(file: RegistryFile, registry: Registry) -> str:
125
+ lines = [f"{registry.title} [{registry.id}] (file updated {file.updated or 'unknown'}"]
126
+ if registry.updated and registry.updated != file.updated:
127
+ lines[0] += f", registry updated {registry.updated}"
128
+ lines[0] += ")"
129
+ lines.append(f"source: {_url(file.name, registry.id)}")
130
+ for rule in registry.rules:
131
+ lines.append(f"registration procedure: {rule}")
132
+ for span in registry.ranges:
133
+ extra = f" (hex {span.hex})" if span.hex else ""
134
+ note = f"; {span.note}" if span.note else ""
135
+ lines.append(f"range {span.value}{extra}: {span.rule}{note}")
136
+ if registry.experts:
137
+ lines.append("expert: " + "; ".join(registry.experts))
138
+ for note in registry.notes:
139
+ lines.append(f"note: {note}")
140
+ for anchor, text in registry.footnotes:
141
+ lines.append(f"footnote {anchor}: {text}")
142
+ if registry.xrefs:
143
+ lines.append("references: " + ", ".join(dict.fromkeys(x.label for x in registry.xrefs)))
144
+ children = [r for r in file.registries if r.parent == registry.id]
145
+ for child in children:
146
+ lines.append(f"sub-registry: {format_ref(file.name, child.id)} {child.title}")
147
+ return "\n".join(lines)
148
+
149
+
150
+ def _row(record: Record) -> str:
151
+ return f"¶{record.ordinal} {record.text()}"
152
+
153
+
154
+ def _cite(file: RegistryFile, registry: Registry | None = None, record: Record | None = None) -> str:
155
+ title = registry.title if registry else file.title
156
+ out = f'IANA, "{title}"'
157
+ if record is not None:
158
+ out += f", value {record.key}"
159
+ out += f", {_url(file.name, registry.id if registry else None)}, file updated {file.updated or 'unknown'}"
160
+ return out
161
+
162
+
163
+ def read(ref: str, max_tokens: int = 2000, base: Path | None = None) -> str:
164
+ """Read by ref (see the module docstring). Pages are whole records; the `next:` footer is the ref that reads the
165
+ next page."""
166
+ target = parse_ref(ref)
167
+ loaded = store.load(target.file, base)
168
+ file = loaded.file
169
+ if target.registry is None:
170
+ units = tuple(
171
+ f"{' ' * _depth(file, r)}{format_ref(file.name, r.id)} {r.title} ({len(r.records)} records)"
172
+ for r in file.registries
173
+ )
174
+ result = budget_page(units, cursor.first_unit(target.para, len(units), "registries", ref), max_tokens)
175
+ body = f"{file.title} (file updated {file.updated or 'unknown'})\nsource: {_url(file.name)}\n\n"
176
+ body += "\n".join(result.units)
177
+ footer = {
178
+ "cite": _cite(file),
179
+ "unit": "registry (the listing is in file order)",
180
+ "next": cursor.read_next(file.name, result),
181
+ "warning": _warning(loaded),
182
+ }
183
+ return render(body, footer)
184
+ registry = _registry(loaded, target)
185
+ records: tuple[Record, ...] = registry.records
186
+ if target.value is not None:
187
+ records = tuple(r for r in records if matches(r, target.value or ""))
188
+ if not records:
189
+ return render(
190
+ f"no record in {registry.id} has value {target.value!r}",
191
+ {"cite": _cite(file, registry), "unit": UNIT, "warning": _warning(loaded)},
192
+ )
193
+ if target.para is not None and target.para > len(records):
194
+ where = f"{len(records)} records" + (f" with value {target.value!r}" if target.value is not None else "")
195
+ raise LibError(f"{ref!r}: {registry.id} has {where}", f"read {format_ref(file.name, registry.id)}")
196
+ units = tuple(_row(r) for r in records)
197
+ result = budget_page(units, target.para or 1, max_tokens)
198
+ body = "\n".join(result.units)
199
+ if target.para is None and target.value is None:
200
+ body = _header(file, registry) + "\n\n" + body
201
+ elif target.value is not None:
202
+ body = f"{registry.title} [{registry.id}]\n\n" + body
203
+ one = records[result.first - 1] if len(result.units) == 1 and (target.para or target.value) else None
204
+ footer = {
205
+ "cite": _cite(file, registry, one),
206
+ "unit": UNIT,
207
+ "span": span_hash(body),
208
+ "next": cursor.read_next(format_ref(file.name, registry.id, target.value), result),
209
+ "warning": _warning(loaded),
210
+ }
211
+ return render(body, footer)
212
+
213
+
214
+ def _depth(file: RegistryFile, registry: Registry) -> int:
215
+ depth = 0
216
+ parent = registry.parent
217
+ while parent:
218
+ depth += 1
219
+ found = file.registry(parent)
220
+ parent = found.parent if found else ""
221
+ return depth
222
+
223
+
224
+ def lookup(where: str, value: str, base: Path | None = None) -> str:
225
+ """The record(s) with this value in a registry, or in every registry of a file if `where` is a file name.
226
+
227
+ A single number matches a record whose value is that number or a range containing it (decimal or 0x hex). Any
228
+ other text matches a value equal to it, ignoring case; failing that, a column equal to it (`A` is an RR type)."""
229
+ if not value.strip() or len(value) > MAX_QUERY:
230
+ raise LibError(f"not a value to look up: {value!r}", f"give 1 to {MAX_QUERY} characters")
231
+ target = parse_ref(where)
232
+ loaded = store.load(target.file, base)
233
+ file = loaded.file
234
+ scope = [_registry(loaded, target)] if target.registry else list(file.registries)
235
+ lines: list[str] = []
236
+ pool: list[tuple[Registry, Record]] = []
237
+ for registry in scope:
238
+ hits = [r for r in registry.records if matches(r, value)]
239
+ if not hits:
240
+ wanted = value.strip().casefold()
241
+ hits = [r for r in registry.records if any(text.casefold() == wanted for _, text in r.fields)]
242
+ for record in hits:
243
+ pool.append((registry, record))
244
+ for registry, record in pool:
245
+ lines.append(f"{format_ref(file.name, registry.id, para=record.ordinal)} {registry.title}: {record.text()}")
246
+ if not lines:
247
+ return render(
248
+ f"no record has value {value!r} in {where}",
249
+ {"cite": _cite(file, scope[0] if len(scope) == 1 else None), "unit": UNIT, "warning": _warning(loaded)},
250
+ )
251
+ only = pool[0][0] if len({r.id for r, _ in pool}) == 1 else None
252
+ footer = {
253
+ "cite": _cite(file, only, pool[0][1] if len(pool) == 1 else None),
254
+ "unit": UNIT,
255
+ "span": span_hash("\n".join(lines)),
256
+ "warning": _warning(loaded),
257
+ }
258
+ return render("\n".join(lines), footer)
259
+
260
+
261
+ def search(text: str, file: str | None = None, limit: int = 20, start: int = 0, base: Path | None = None) -> str:
262
+ """Records whose registry title and columns contain every word of `text` (case-insensitive, literal, at most
263
+ 200 characters), across every imported file or one. One line per hit: `<ref> <registry>: <record>`."""
264
+ words = text.casefold().split()
265
+ if not words or len(text) > MAX_QUERY:
266
+ raise LibError(f"not a search: {text!r}", f"give 1 to {MAX_QUERY} characters")
267
+ names = (file,) if file else store.imported(base)
268
+ if not names:
269
+ raise LibError(
270
+ "no registry is imported",
271
+ "iana_list shows the catalogue",
272
+ "primarysource iana import --all (or name one, e.g. dns-parameters)",
273
+ )
274
+ hits: list[str] = []
275
+ warnings: list[str] = []
276
+ for name in names:
277
+ if not NAME.fullmatch(name):
278
+ raise LibError(f"{name!r} is not a registry file name", "")
279
+ loaded = store.load(name, base)
280
+ if loaded.digest_ok is False:
281
+ warnings.append(name)
282
+ for registry in loaded.file.registries:
283
+ title = registry.title.casefold()
284
+ for record in registry.records:
285
+ row = record.text()
286
+ if all(w in title or w in row.casefold() for w in words):
287
+ snippet = row if len(row) <= _SNIPPET else row[: _SNIPPET - 1] + "…"
288
+ hits.append(f"{format_ref(name, registry.id, para=record.ordinal)} {registry.title}: {snippet}")
289
+ shown, nxt = cursor.window(hits, start, limit)
290
+ body = "\n".join(shown) if shown else f"no record matches {text!r}"
291
+ footer = {
292
+ "unit": UNIT,
293
+ "next": nxt,
294
+ "warning": ("files differ from their recorded sha256: " + ", ".join(warnings)) if warnings else "",
295
+ }
296
+ return render(body, footer)
297
+
298
+
299
+ def listing(base: Path | None = None) -> str:
300
+ """The catalogue, each registry marked with whether it is imported, its `updated` date and sha256 prefix."""
301
+ root = base or store.home()
302
+ manifest = store.read_manifest(root)
303
+ present = set(store.imported(root))
304
+ lines = []
305
+ for entry in catalogue.ENTRIES:
306
+ recorded = manifest.get(entry.name, {})
307
+ state = (
308
+ f"imported, updated {recorded.get('updated') or '?'}, sha256 {str(recorded.get('sha256', ''))[:12]}"
309
+ if entry.name in present
310
+ else "not imported"
311
+ )
312
+ lines.append(f"{entry.name} {entry.title} [{state}]")
313
+ for name in sorted(present - {e.name for e in catalogue.ENTRIES}):
314
+ lines.append(f"{name} (not in the catalogue) [imported]")
315
+ return render("\n".join(lines), {})