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,283 @@
1
+ """A bounded parser for the `usb.ids` format, and the ref spelling of its entries.
2
+
3
+ The file is line-oriented. A `#` line is a comment (the first ones carry `# Version:` and `# Date:`), a blank line
4
+ is nothing, and every other line is `<id> <name>` (two spaces) indented by zero to two tabs. At column zero a
5
+ line is either a vendor (`1d6b Linux Foundation`) or a section record led by an upper-case tag (`C 09 Hub`). One
6
+ tab is a child of the last column-zero entry (a device of a vendor, a subclass of a class, a usage of a usage
7
+ page, a dialect of a language), two tabs a child of the last one-tab entry (an interface of a device, a protocol
8
+ of a subclass). The sections the file has today, from its own `# Syntax:` comments:
9
+
10
+ (none) vendor > device > interface C class > subclass > protocol
11
+ AT audio terminal type HID HID descriptor type
12
+ R HID item type BIAS physical descriptor bias
13
+ PHY physical descriptor item type HUT usage page > usage
14
+ L language > dialect HCC HID country code (decimal)
15
+ VT video terminal type
16
+
17
+ Nothing here reads a file or the network. `parse` takes bytes and `Limits`, never trusts a line, and reports a
18
+ line it cannot place as a `Problem` instead of guessing: the real file parses with none. Ids are lower-cased,
19
+ names are kept as written, less surrounding space (a line that is not valid UTF-8 is read as Latin-1
20
+ and counted), and a repeated id keeps its first entry and is reported.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import re
26
+ from dataclasses import dataclass, field
27
+
28
+ from primarysource.errors import LibError
29
+
30
+ SECTION_REF = {
31
+ "vendor": "vendor",
32
+ "class": "class",
33
+ "at": "at",
34
+ "hid": "hid",
35
+ "item": "item",
36
+ "bias": "bias",
37
+ "phy": "phy",
38
+ "hut": "hut",
39
+ "lang": "lang",
40
+ "hcc": "hcc",
41
+ "vt": "vt",
42
+ }
43
+ """The first path part of a non-vendor ref, by section name. A vendor ref has no section part: `usbids:1d6b`."""
44
+
45
+ TAGS = {
46
+ "C": "class",
47
+ "AT": "at",
48
+ "HID": "hid",
49
+ "R": "item",
50
+ "BIAS": "bias",
51
+ "PHY": "phy",
52
+ "HUT": "hut",
53
+ "L": "lang",
54
+ "HCC": "hcc",
55
+ "VT": "vt",
56
+ }
57
+ """The file's tag for each section."""
58
+
59
+ LEVELS = {
60
+ "vendor": ("vendor", "device", "interface"),
61
+ "class": ("class", "subclass", "protocol"),
62
+ "at": ("terminal type",),
63
+ "hid": ("descriptor type",),
64
+ "item": ("item type",),
65
+ "bias": ("bias",),
66
+ "phy": ("item type",),
67
+ "hut": ("usage page", "usage"),
68
+ "lang": ("language", "dialect"),
69
+ "hcc": ("country code",),
70
+ "vt": ("terminal type",),
71
+ }
72
+ """What each indentation level of a section is called, for `usbids_lookup` output."""
73
+
74
+ _HEX2 = r"[0-9a-f]{2}"
75
+ _HEX4 = r"[0-9a-f]{4}"
76
+ ID_PATTERNS: dict[str, tuple[str, ...]] = {
77
+ "vendor": (_HEX4, _HEX4, r"[0-9a-f]{2,4}"),
78
+ "class": (_HEX2, _HEX2, _HEX2),
79
+ "at": (_HEX4,),
80
+ "hid": (_HEX2,),
81
+ "item": (_HEX2,),
82
+ "bias": (r"[0-9a-f]{1,2}",),
83
+ "phy": (_HEX2,),
84
+ "hut": (_HEX2, r"[0-9a-f]{3,4}"),
85
+ "lang": (_HEX4, _HEX2),
86
+ "hcc": (r"[0-9]{2}",),
87
+ "vt": (_HEX4,),
88
+ }
89
+ """The shape of the id at each level. `hcc` is decimal (the file says HID country codes are 00 to 35)."""
90
+
91
+ _ID = {section: tuple(re.compile(p) for p in patterns) for section, patterns in ID_PATTERNS.items()}
92
+ _LINE = re.compile(r"(?P<tabs>\t{0,3})(?:(?P<tag>[A-Z]{1,4}) )?(?P<id>[0-9A-Za-z]{1,8}) +(?P<name>\S.*)")
93
+ _VERSION = re.compile(r"#\s*Version:\s*(\S+)")
94
+ _DATE = re.compile(r"#\s*Date:\s*(.+?)\s*$")
95
+ _CONTROL = re.compile(r"[\x00-\x08\x0a-\x1f\x7f]")
96
+
97
+
98
+ @dataclass(frozen=True, slots=True)
99
+ class Limits:
100
+ """Ceilings on what a parse accepts. The real file measures 730,605 bytes, 25,705 lines, the longest line
101
+ 160 characters."""
102
+
103
+ data_bytes: int = 8_000_000
104
+ lines: int = 200_000
105
+ line_chars: int = 512
106
+ entries: int = 200_000
107
+ problems: int = 1_000
108
+ """Problems are counted without limit but only this many are kept."""
109
+
110
+
111
+ @dataclass(frozen=True, slots=True)
112
+ class Entry:
113
+ """One record: `section`, the ids from the top down (`("1d6b", "0002")`), the name and its 1-based line."""
114
+
115
+ section: str
116
+ key: tuple[str, ...]
117
+ name: str
118
+ line: int
119
+
120
+ @property
121
+ def ref(self) -> str:
122
+ return format_ref(self.section, self.key)
123
+
124
+ @property
125
+ def level_name(self) -> str:
126
+ return LEVELS[self.section][len(self.key) - 1]
127
+
128
+
129
+ @dataclass(frozen=True, slots=True)
130
+ class Problem:
131
+ line: int
132
+ reason: str
133
+
134
+
135
+ @dataclass(slots=True)
136
+ class UsbIds:
137
+ """A parsed file: entries in file order, the children of each, and what was wrong."""
138
+
139
+ entries: dict[tuple[str, tuple[str, ...]], Entry] = field(default_factory=dict)
140
+ children: dict[tuple[str, tuple[str, ...]], list[Entry]] = field(default_factory=dict)
141
+ version: str = ""
142
+ date: str = ""
143
+ problems: list[Problem] = field(default_factory=list)
144
+ problem_count: int = 0
145
+ latin1_lines: int = 0
146
+ """Lines that were not valid UTF-8 and were read as Latin-1."""
147
+
148
+ def get(self, section: str, key: tuple[str, ...]) -> Entry | None:
149
+ return self.entries.get((section, key))
150
+
151
+ def kids(self, entry: Entry) -> list[Entry]:
152
+ return self.children.get((entry.section, entry.key), [])
153
+
154
+ def counts(self) -> dict[str, int]:
155
+ """Entries per kind in first-seen order: `vendor`, `device`, `class`, `subclass`, `protocol`, `usage page`,
156
+ `usage`, `language`, `dialect`, and one count under the section's own name (`at`, `hid`, `item`, `bias`,
157
+ `phy`, `hcc`, `vt`) for the single-level tables."""
158
+ out: dict[str, int] = {}
159
+ for entry in self.entries.values():
160
+ label = entry.level_name if entry.section in ("vendor", "class", "hut", "lang") else entry.section
161
+ out[label] = out.get(label, 0) + 1
162
+ return out
163
+
164
+
165
+ def parse(data: bytes, limits: Limits | None = None) -> UsbIds:
166
+ """Parse `usb.ids` bytes. Raises `LibError` for input that is not this format at all (too large,
167
+ NUL bytes, too many lines or entries); a bad line inside an otherwise sound file is a `Problem`."""
168
+ limits = limits or Limits()
169
+ if len(data) > limits.data_bytes:
170
+ raise LibError(f"usb.ids input is {len(data):,} bytes, over the {limits.data_bytes:,} limit", "")
171
+ if b"\x00" in data:
172
+ raise LibError("usb.ids input contains NUL bytes: this is not the text file", "")
173
+ raw_lines = data.removeprefix(b"\xef\xbb\xbf").split(b"\n")
174
+ if len(raw_lines) > limits.lines:
175
+ raise LibError(f"usb.ids input has over {limits.lines:,} lines", "")
176
+ ids = UsbIds()
177
+
178
+ def problem(number: int, reason: str) -> None:
179
+ ids.problem_count += 1
180
+ if len(ids.problems) < limits.problems:
181
+ ids.problems.append(Problem(number, reason))
182
+
183
+ parents: list[Entry | None] = [None, None, None]
184
+ for number, raw_bytes in enumerate(raw_lines, 1):
185
+ try:
186
+ raw = raw_bytes.decode("utf-8")
187
+ except UnicodeDecodeError:
188
+ # The published file is UTF-8 with a few Latin-1 bytes (an acute accent written as 0xB4)
189
+ raw = raw_bytes.decode("latin-1")
190
+ ids.latin1_lines += 1
191
+ line = raw.rstrip(" \t\r")
192
+ if not line:
193
+ continue
194
+ if line.startswith("#"):
195
+ if not ids.version and (m := _VERSION.match(line)):
196
+ ids.version = m[1]
197
+ elif not ids.date and (m := _DATE.match(line)):
198
+ ids.date = m[1]
199
+ continue
200
+ if len(line) > limits.line_chars:
201
+ problem(number, f"line over {limits.line_chars} characters")
202
+ continue
203
+ if _CONTROL.search(line):
204
+ problem(number, "control character")
205
+ continue
206
+ match = _LINE.fullmatch(line)
207
+ if match is None:
208
+ problem(number, "not '<id> <name>'")
209
+ continue
210
+ level = len(match["tabs"])
211
+ tag = match["tag"]
212
+ if level > 2 or (level > 0 and tag):
213
+ problem(number, "indentation or tag not allowed here")
214
+ continue
215
+ if level == 0:
216
+ if tag is None:
217
+ section = "vendor"
218
+ elif tag in TAGS:
219
+ section = TAGS[tag]
220
+ else:
221
+ problem(number, f"unknown section tag {tag!r}")
222
+ parents[:] = [None, None, None]
223
+ continue
224
+ parent_key: tuple[str, ...] = ()
225
+ else:
226
+ parent = parents[level - 1]
227
+ if parent is None:
228
+ problem(number, "indented line with no parent")
229
+ continue
230
+ section, parent_key = parent.section, parent.key
231
+ patterns = _ID[section]
232
+ if level >= len(patterns):
233
+ problem(number, f"a {section} section has no level {level}")
234
+ continue
235
+ entry_id = match["id"].lower()
236
+ if patterns[level].fullmatch(entry_id) is None:
237
+ problem(number, f"{entry_id!r} is not a valid {LEVELS[section][level]} id")
238
+ continue
239
+ entry = Entry(section, (*parent_key, entry_id), match["name"], number)
240
+ index = (entry.section, entry.key)
241
+ if index in ids.entries:
242
+ problem(number, f"duplicate {entry.ref}")
243
+ parents[level:] = [None] * (3 - level)
244
+ continue
245
+ if len(ids.entries) >= limits.entries:
246
+ raise LibError(f"usb.ids input has over {limits.entries:,} entries", "")
247
+ ids.entries[index] = entry
248
+ if level > 0:
249
+ ids.children.setdefault((section, parent_key), []).append(entry)
250
+ parents[level:] = [entry] + [None] * (2 - level)
251
+ return ids
252
+
253
+
254
+ _VENDOR_REF = re.compile(r"(?P<v>[0-9a-f]{4})(?::(?P<d>[0-9a-f]{4})(?::(?P<i>[0-9a-f]{2,4}))?)?")
255
+ _SECTION_REF = re.compile(r"(?P<s>[a-z]+)(?P<path>(?:/[0-9a-f]{1,4}){1,3})")
256
+
257
+
258
+ def format_ref(section: str, key: tuple[str, ...]) -> str:
259
+ """`usbids:1d6b:0002` for the vendor section, `usbids:class/09/00` for the others."""
260
+ if section == "vendor":
261
+ return "usbids:" + ":".join(key)
262
+ return f"usbids:{section}/" + "/".join(key)
263
+
264
+
265
+ def parse_ref(text: str) -> tuple[str, tuple[str, ...]]:
266
+ """`usbids:1d6b:0002`, `1d6b:0002`, `usbids:class/09/00/02` -> (section, ids). Raises `LibError` on anything
267
+ else: a string that names nothing is refused, never guessed at."""
268
+ cleaned = text.strip().lower()
269
+ if len(cleaned) > 64:
270
+ raise LibError("not a usbids ref: too long", "")
271
+ cleaned = cleaned.removeprefix("usbids:")
272
+ if (m := _VENDOR_REF.fullmatch(cleaned)) is not None:
273
+ return "vendor", tuple(g for g in (m["v"], m["d"], m["i"]) if g)
274
+ if (m := _SECTION_REF.fullmatch(cleaned)) is not None and m["s"] in SECTION_REF and m["s"] != "vendor":
275
+ key = tuple(m["path"].split("/")[1:])
276
+ patterns = _ID[m["s"]]
277
+ if len(key) <= len(patterns) and all(p.fullmatch(k) for p, k in zip(patterns, key, strict=False)):
278
+ return m["s"], key
279
+ names = ", ".join(sorted(set(SECTION_REF) - {"vendor"}))
280
+ raise LibError(
281
+ f"not a usbids ref: {text!r}",
282
+ f"expected usbids:<vid>[:<pid>] (hex) or usbids:<section>/<id>[/<id>...] with section one of {names}",
283
+ )
@@ -0,0 +1,55 @@
1
+ """MCP server for the usbids collection: `primarysource mcp --collection usbids`.
2
+
3
+ Imported only when served, so `mcp` (the `[mcp]` extra) is needed only then. Tools carry the `usbids_` prefix,
4
+ answer in text with the fixed footer, read the local database only (none writes or fetches), and turn a
5
+ `LibError` into an MCP tool error whose text names the remedy.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from mcp.server.mcpserver import MCPServer
11
+ from mcp.types import ToolAnnotations
12
+
13
+ from primarysource import __version__
14
+ from primarysource.scaffold import add_tools
15
+ from primarysource.usbids import tools
16
+
17
+ READ_ONLY = ToolAnnotations(read_only_hint=True, idempotent_hint=True, destructive_hint=False, open_world_hint=False)
18
+
19
+ INSTRUCTIONS = (
20
+ "The USB ID Database (usb.ids from the linux-usb.org USB ID Repository), imported by the user: vendor and "
21
+ "product ids, device classes, HID usages, languages and other lookup tables. It names things; it is not a USB "
22
+ "specification. Loop: usbids_search('<name>') -> usbids_lookup('<ref>'). Refs: 'usbids:1d6b' (vendor), "
23
+ "'usbids:1d6b:0002' (device), 'usbids:class/09/00/02' (class/subclass/protocol), 'usbids:hut/01/002' (HID "
24
+ "usage). usbids_classify('09', '00', '02') decodes a class code triple. The cite: footer line names the "
25
+ "snapshot. If the database is not imported, the error says how."
26
+ )
27
+
28
+
29
+ def usbids_lookup(ref: str, max_tokens: int = 2000) -> str:
30
+ """Read one entry by ref with its parents and children. ref: 'usbids:1d6b' (a vendor, lists its devices),
31
+ 'usbids:1d6b:0002' (a device), 'usbids:class/09' (a class, lists subclasses), 'usbids:hut/01' (a HID usage
32
+ page), 'usbids:1d6b¶40' (that listing from its 40th line). A 'next: <ref>' footer line is the ref that reads
33
+ the next page: pass it back as ref."""
34
+ return tools.lookup(ref, max_tokens=max_tokens)
35
+
36
+
37
+ def usbids_search(text: str, section: str | None = None, limit: int = 20, start: int = 0) -> str:
38
+ """Find entries whose name contains every word of text (a vendor's name counts for its devices). One line per
39
+ hit: '<ref> <name>'. section: vendor (vendors, devices, interfaces) | class | hut | lang | at | hid | item |
40
+ bias | phy | hcc | vt. A 'next: start=N' footer line means call again with start=N (a 0-based offset)."""
41
+ return tools.search(text, section=section, limit=limit, start=start)
42
+
43
+
44
+ def usbids_classify(base_class: str, subclass: str | None = None, protocol: str | None = None) -> str:
45
+ """Name a USB class code triple (hex, with or without 0x): usbids_classify('09', '00', '02')."""
46
+ return tools.classify(base_class, subclass=subclass, protocol=protocol)
47
+
48
+
49
+ def usbids_sections() -> str:
50
+ """The imported snapshot: version, date, sha256, source, licence and entry counts per section."""
51
+ return tools.sections()
52
+
53
+
54
+ server = MCPServer("primarysource-usbids", version=__version__, instructions=INSTRUCTIONS)
55
+ add_tools(server, (usbids_lookup, usbids_search, usbids_classify, usbids_sections), annotations=READ_ONLY)
@@ -0,0 +1,80 @@
1
+ """Where the imported `usb.ids` lives and how it is read back.
2
+
3
+ `$PRIMARYSOURCE_HOME/usbids/usb.ids` is the file exactly as served; `manifest.json` beside it records its URL,
4
+ sha256, size, the file's own `# Version:` and `# Date:`, when it was fetched, the server's validators and whether
5
+ the digest was the pinned one. Reading parses once per (path, mtime, size) and refuses a file whose sha256 no
6
+ longer matches the manifest.
7
+
8
+ Placement: the database is distributable under GPL-2.0-or-later or BSD-3-Clause (docs/usbids.md), so a built image
9
+ may carry it as a layer; the importer writes the same layout either way.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import hashlib
15
+ import json
16
+ from dataclasses import dataclass
17
+ from functools import lru_cache
18
+ from pathlib import Path
19
+ from typing import Any
20
+
21
+ from primarysource.errors import LibError
22
+ from primarysource.home import collection_home
23
+ from primarysource.usbids.parse import Limits, UsbIds, parse
24
+
25
+ FILE = "usb.ids"
26
+ MANIFEST = "manifest.json"
27
+ IMPORT = "primarysource usbids import"
28
+ LICENCE = "GPL-2.0-or-later OR BSD-3-Clause"
29
+
30
+
31
+ def home() -> Path:
32
+ return collection_home("usbids")
33
+
34
+
35
+ def read_manifest(base: Path) -> dict[str, Any]:
36
+ path = base / MANIFEST
37
+ if not path.is_file():
38
+ return {}
39
+ try:
40
+ data = json.loads(path.read_text(encoding="utf-8"))
41
+ except (ValueError, OSError) as exc:
42
+ raise LibError(f"{path} is not a readable manifest", "", f"delete {path}, then {IMPORT} --force") from exc
43
+ if not isinstance(data, dict) or not isinstance(data.get("file"), dict):
44
+ raise LibError(f"{path} is not a readable manifest", "", f"delete {path}, then {IMPORT} --force")
45
+ return dict(data["file"])
46
+
47
+
48
+ @dataclass(frozen=True, slots=True)
49
+ class Loaded:
50
+ ids: UsbIds
51
+ manifest: dict[str, Any]
52
+ sha256: str
53
+ path: Path
54
+
55
+
56
+ @lru_cache(maxsize=4)
57
+ def _load(path: str, mtime_ns: int, size: int) -> tuple[UsbIds, str]:
58
+ data = Path(path).read_bytes()
59
+ return parse(data, Limits()), hashlib.sha256(data).hexdigest()
60
+
61
+
62
+ def load(base: Path | None = None) -> Loaded:
63
+ """The imported database, or a `LibError` that names the import command."""
64
+ root = base or home()
65
+ path = root / FILE
66
+ if not path.is_file():
67
+ raise LibError(f"usb.ids is not imported (looked in {root})", "", IMPORT)
68
+ stat = path.stat()
69
+ ids, digest = _load(str(path), stat.st_mtime_ns, stat.st_size)
70
+ manifest = read_manifest(root)
71
+ recorded = manifest.get("sha256")
72
+ if recorded and recorded != digest:
73
+ raise LibError(
74
+ f"{path} has sha256 {digest}, but the manifest recorded {recorded}: the file changed after import",
75
+ "",
76
+ IMPORT + " --force",
77
+ )
78
+ if not ids.entries:
79
+ raise LibError(f"{path} holds no usb.ids entries", "", IMPORT + " --force")
80
+ return Loaded(ids, manifest, digest, path)
@@ -0,0 +1,174 @@
1
+ """The usbids readers: lookup, search, classify and a summary. Plain text with the core's fixed footer.
2
+
3
+ Each takes the imported database from `store.load()` and never writes or fetches. A missing database is an
4
+ error whose `action` is the import command; a ref that names nothing is a `LibError` too, with a hint, so an empty
5
+ result always means a search matched nothing.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import re
11
+ from pathlib import Path
12
+
13
+ from primarysource import budget, cursor
14
+ from primarysource.errors import LibError
15
+ from primarysource.render import next_start, render
16
+ from primarysource.usbids import store
17
+ from primarysource.usbids.parse import SECTION_REF, Entry, UsbIds, format_ref, parse_ref
18
+
19
+ MAX_QUERY = 200
20
+ MAX_TERMS = 8
21
+ MAX_LIMIT = 200
22
+ _WORD = re.compile(r"[a-z0-9]+")
23
+ _HEX2 = re.compile(r"[0-9a-fA-F]{1,2}")
24
+
25
+
26
+ def _cite(loaded: store.Loaded, entry: Entry | None = None) -> str:
27
+ ids = loaded.ids
28
+ head = f"USB ID Database usb.ids {ids.version or 'unversioned'}" + (f" ({ids.date})" if ids.date else "")
29
+ return f"{head}, {entry.ref}" if entry else head
30
+
31
+
32
+ def _line(entry: Entry) -> str:
33
+ return f"{entry.ref} {entry.name}"
34
+
35
+
36
+ def _ancestors(ids: UsbIds, entry: Entry) -> list[Entry]:
37
+ found: list[Entry] = []
38
+ for depth in range(1, len(entry.key)):
39
+ parent = ids.get(entry.section, entry.key[:depth])
40
+ if parent is not None:
41
+ found.append(parent)
42
+ return found
43
+
44
+
45
+ def lookup(ref: str, max_tokens: int = 2000, base: Path | None = None) -> str:
46
+ """One entry by ref, its ancestors and its children, paged by token budget. `<ref>¶n` starts at line n of that
47
+ listing; the `next:` footer is the ref that reads the next page."""
48
+ text, line = cursor.split_para(ref)
49
+ section, key = parse_ref(text)
50
+ loaded = store.load(base)
51
+ ids = loaded.ids
52
+ entry = ids.get(section, key)
53
+ if entry is None:
54
+ parent = ids.get(section, key[:-1]) if len(key) > 1 else None
55
+ if parent is not None:
56
+ raise LibError(
57
+ f"{format_ref(section, key)} is not listed; its {parent.level_name} is {parent.name!r}",
58
+ f"usbids_lookup('{parent.ref}') lists what is under it",
59
+ )
60
+ raise LibError(
61
+ f"{format_ref(section, key)} is not in usb.ids {ids.version}", "usbids_search finds an entry by name"
62
+ )
63
+ units = [f"{_line(entry)} [{entry.level_name}]"]
64
+ units += [f"within: {_line(a)} [{a.level_name}]" for a in _ancestors(ids, entry)]
65
+ units += [f" {_line(kid)}" for kid in ids.kids(entry)]
66
+ if max_tokens < 1:
67
+ raise LibError("max_tokens must be positive", "")
68
+ page = budget.page(units, cursor.first_unit(line, len(units), "lines", ref), max_tokens)
69
+ footer = {
70
+ "cite": _cite(loaded, entry),
71
+ "unit": "line",
72
+ "span": f"{page.first}-{page.last} of {page.total}",
73
+ "next": cursor.read_next(entry.ref, page),
74
+ }
75
+ return render("\n".join(page.units), footer)
76
+
77
+
78
+ def search(
79
+ text: str,
80
+ section: str | None = None,
81
+ limit: int = 20,
82
+ start: int = 0,
83
+ base: Path | None = None,
84
+ ) -> str:
85
+ """Entries whose name contains every word of `text` (a vendor's name counts for its devices), best first."""
86
+ if section is not None and section not in SECTION_REF:
87
+ raise LibError(f"unknown section {section!r}", "one of " + ", ".join(sorted(SECTION_REF)))
88
+ if len(text) > MAX_QUERY:
89
+ raise LibError(f"query is over {MAX_QUERY} characters", "")
90
+ terms = _WORD.findall(text.lower())[:MAX_TERMS]
91
+ if not terms:
92
+ raise LibError("query has no letters or digits", "give a name or part of one, e.g. 'hub' or 'logitech'")
93
+ if not 1 <= limit <= MAX_LIMIT or start < 0:
94
+ raise LibError(f"limit must be 1 to {MAX_LIMIT} and start (0-based) 0 or more", "")
95
+ loaded = store.load(base)
96
+ ids = loaded.ids
97
+ hits: list[tuple[int, int, Entry, str]] = []
98
+ for order, entry in enumerate(ids.entries.values()):
99
+ if section is not None and entry.section != section:
100
+ continue
101
+ shown = entry.name
102
+ hay = entry.name.lower()
103
+ if entry.section == "vendor" and len(entry.key) > 1:
104
+ owner = ids.get("vendor", entry.key[:1])
105
+ if owner is not None:
106
+ hay = f"{owner.name.lower()} {hay}"
107
+ shown = f"{owner.name} / {entry.name}"
108
+ if not all(t in hay for t in terms):
109
+ continue
110
+ words = _WORD.findall(hay)
111
+ score = sum(3 if t in words else 2 if any(w.startswith(t) for w in words) else 1 for t in terms)
112
+ hits.append((-score, order, entry, shown))
113
+ hits.sort(key=lambda h: (h[0], h[1]))
114
+ window = hits[start : start + limit]
115
+ body = "\n".join(f"{e.ref} {shown}" for _, _, e, shown in window) if window else f"no entry matches {text!r}"
116
+ footer = {"cite": _cite(loaded), "unit": "line"}
117
+ if window:
118
+ footer["span"] = f"{start + 1}-{start + len(window)} of {len(hits)}"
119
+ footer["next"] = next_start(start + limit if start + limit < len(hits) else None)
120
+ return render(body, footer)
121
+
122
+
123
+ def _two_hex(value: str, what: str) -> str:
124
+ if not _HEX2.fullmatch(value.strip()):
125
+ raise LibError(f"{what} {value!r} is not one or two hex digits", "e.g. 09 or 0xe0")
126
+ return value.strip().lower().zfill(2)
127
+
128
+
129
+ def classify(
130
+ base_class: str, subclass: str | None = None, protocol: str | None = None, base: Path | None = None
131
+ ) -> str:
132
+ """Name a class code triple as a device or interface descriptor carries it: bDeviceClass, bDeviceSubClass,
133
+ bDeviceProtocol. Digits may be written with or without `0x`."""
134
+ clean = [v.lower().removeprefix("0x") if v is not None else None for v in (base_class, subclass, protocol)]
135
+ if clean[1] is None and clean[2] is not None:
136
+ raise LibError("a protocol needs a subclass", "")
137
+ key: list[str] = []
138
+ for value, what in zip(clean, ("class", "subclass", "protocol"), strict=True):
139
+ if value is not None:
140
+ key.append(_two_hex(value, what))
141
+ loaded = store.load(base)
142
+ ids = loaded.ids
143
+ lines: list[str] = []
144
+ for depth in range(1, len(key) + 1):
145
+ entry = ids.get("class", tuple(key[:depth]))
146
+ if entry is None:
147
+ if depth == 1:
148
+ raise LibError(
149
+ f"class {key[0]} is not in usb.ids {ids.version}", "usbids_search with section='class'"
150
+ )
151
+ lines.append(f"{format_ref('class', tuple(key[:depth]))} (not listed in usb.ids)")
152
+ break
153
+ lines.append(f"{_line(entry)} [{entry.level_name}]")
154
+ return render("\n".join(lines), {"cite": _cite(loaded), "unit": "line"})
155
+
156
+
157
+ def sections(base: Path | None = None) -> str:
158
+ """The imported snapshot: version, date, digest, source, licence and entry counts per section."""
159
+ loaded = store.load(base)
160
+ ids = loaded.ids
161
+ per: dict[str, int] = {}
162
+ for entry in ids.entries.values():
163
+ per[entry.section] = per.get(entry.section, 0) + 1
164
+ man = loaded.manifest
165
+ lines = [
166
+ f"usb.ids {ids.version or 'unversioned'} date {ids.date or 'unknown'}",
167
+ f"sha256 {loaded.sha256} ({'pinned snapshot' if man.get('pinned') else 'not the pinned snapshot'})",
168
+ f"source {man.get('url', 'unknown')} fetched {man.get('fetched', 'unknown')}",
169
+ f"licence {man.get('licence', store.LICENCE)}",
170
+ "entries per section (ref prefix):",
171
+ ]
172
+ lines += [f" {name}: {count:,}" for name, count in per.items()]
173
+ lines += [f"unparsed lines at import: {ids.problem_count}"]
174
+ return render("\n".join(lines), {"cite": _cite(loaded), "unit": "line"})
@@ -0,0 +1,5 @@
1
+ """zim: a Kiwix library (ZIM files) as token-budgeted, citable documentation for AI agents."""
2
+
3
+ from __future__ import annotations
4
+
5
+ __version__ = "0.1.0"
@@ -0,0 +1,8 @@
1
+ """`python -m primarysource.zim` is the same as `primarysource zim`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .cli import main
6
+
7
+ if __name__ == "__main__":
8
+ raise SystemExit(main())
@@ -0,0 +1,37 @@
1
+ """The only place that imports the native binding.
2
+
3
+ `libzim` is a compiled extension (it carries libzim and Xapian). It ships wheels for CPython 3.11+ on
4
+ Windows x64, macOS (x86-64, arm64) and Linux (glibc and musl, x86-64 and aarch64), and nothing else, so on any
5
+ other platform `import libzim` fails. Everything that does not read a ZIM (the CLI's `info`, `catalog`, `get`,
6
+ the reference grammar, the converter) must keep working there, which is why the import is lazy and why its
7
+ failure is a `ZimloreError` naming the remedy instead of a traceback from deep inside a tool.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import importlib
13
+ from types import ModuleType
14
+
15
+ from .errors import ZimloreError
16
+
17
+ REMEDY = (
18
+ 'pip install "primarysource[zim]" (libzim wheels: Windows x64, macOS, Linux glibc/musl; CPython 3.11+). On a '
19
+ "platform without a wheel, build libzim from source (https://github.com/openzim/python-libzim) or serve the "
20
+ "same files with kiwix-serve and read them in a browser"
21
+ )
22
+
23
+
24
+ def load(name: str) -> ModuleType:
25
+ """Import `libzim.<name>` (reader, search, suggestion, writer)."""
26
+ try:
27
+ return importlib.import_module(f"libzim.{name}")
28
+ except ImportError as error:
29
+ raise ZimloreError(f"the libzim binding is not available ({error})", REMEDY) from error
30
+
31
+
32
+ def available() -> bool:
33
+ try:
34
+ load("reader")
35
+ except ZimloreError:
36
+ return False
37
+ return True