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 @@
1
+ """Ingest side of usblib: fetch the publications listed in data/sources.toml and index them."""
@@ -0,0 +1,130 @@
1
+ """Download the files named in the USB-IF catalogue, unpack ZIP packages, and record what was fetched.
2
+
3
+ Input: data/usb-catalog.json (from discover_usb). Output, under the data directory:
4
+ raw/<slug>/<file> each downloaded file as published
5
+ raw/<slug>/pdf/<name> PDFs taken from ZIP packages, with the ZIP kept beside them
6
+ acquired.json per file: url, path, size, sha256, and for ZIPs the member list
7
+
8
+ Only PDFs and ZIPs are kept. Each download goes to a .part file and is renamed only when complete.
9
+ Extraction is safe: members with absolute paths or ".." are refused, and only PDF members are written.
10
+ Requests are throttled to one per second. Pass --dry-run to print the plan and the total size first.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from primarysource.usb.paths import PACKAGE_DATA
16
+
17
+ import argparse
18
+ import hashlib
19
+ import json
20
+ import sys
21
+ import time
22
+ import urllib.error
23
+ import urllib.request
24
+ import zipfile
25
+ from collections.abc import Sequence
26
+ from pathlib import Path, PurePosixPath
27
+
28
+ USER_AGENT = "usblib-acquire/0.1 (documentation corpus)"
29
+ DELAY = 1.0
30
+ CHUNK = 1 << 20
31
+
32
+
33
+ def _size(url: str) -> int | None:
34
+ req = urllib.request.Request(url, method="HEAD", headers={"User-Agent": USER_AGENT})
35
+ try:
36
+ with urllib.request.urlopen(req, timeout=60) as resp:
37
+ length = resp.headers.get("Content-Length")
38
+ return int(length) if length else None
39
+ except urllib.error.URLError:
40
+ return None
41
+ finally:
42
+ time.sleep(DELAY)
43
+
44
+
45
+ def _download(url: str, dest: Path) -> tuple[int, str]:
46
+ dest.parent.mkdir(parents=True, exist_ok=True)
47
+ part = dest.with_suffix(dest.suffix + ".part")
48
+ digest = hashlib.sha256()
49
+ size = 0
50
+ req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
51
+ with urllib.request.urlopen(req, timeout=300) as resp, part.open("wb") as out:
52
+ while chunk := resp.read(CHUNK):
53
+ out.write(chunk)
54
+ digest.update(chunk)
55
+ size += len(chunk)
56
+ part.replace(dest)
57
+ time.sleep(DELAY)
58
+ return size, digest.hexdigest()
59
+
60
+
61
+ def _safe_member(name: str) -> bool:
62
+ p = PurePosixPath(name)
63
+ return not p.is_absolute() and ".." not in p.parts and name.lower().endswith(".pdf")
64
+
65
+
66
+ def _extract_pdfs(archive: Path, out_dir: Path) -> list[str]:
67
+ written: list[str] = []
68
+ out_dir.mkdir(parents=True, exist_ok=True)
69
+ with zipfile.ZipFile(archive) as zf:
70
+ for info in zf.infolist():
71
+ if info.is_dir() or not _safe_member(info.filename):
72
+ continue
73
+ target = out_dir / PurePosixPath(info.filename).name
74
+ with zf.open(info) as src, target.open("wb") as dst:
75
+ dst.write(src.read())
76
+ written.append(target.name)
77
+ return written
78
+
79
+
80
+ def plan(catalog: dict, only: set[str] | None) -> list[tuple[str, str, str]]:
81
+ """(slug, url, filename) for every PDF or ZIP the catalogue names."""
82
+ items = []
83
+ for entry in catalog["entries"]:
84
+ if only and entry["slug"] not in only:
85
+ continue
86
+ for url in entry["files"]:
87
+ name = PurePosixPath(url).name
88
+ items.append((entry["slug"], url, name))
89
+ return items
90
+
91
+
92
+ def main(argv: Sequence[str] | None = None) -> int:
93
+ parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
94
+ parser.add_argument("--catalog", type=Path, default=PACKAGE_DATA / "usb-catalog.json")
95
+ parser.add_argument("--data-dir", type=Path, default=Path("data"))
96
+ parser.add_argument("--only", nargs="*", default=None, help="catalogue slugs to fetch (default: all)")
97
+ parser.add_argument("--dry-run", action="store_true")
98
+ args = parser.parse_args(argv)
99
+ catalog = json.loads(args.catalog.read_text(encoding="utf-8"))
100
+ items = plan(catalog, set(args.only) if args.only else None)
101
+ sizes = {url: _size(url) for _, url, _ in items} if args.dry_run else {}
102
+ if args.dry_run:
103
+ total = sum(s or 0 for s in sizes.values())
104
+ for slug, url, name in items:
105
+ print(f"{slug:<40} {sizes.get(url) or '?':>12} {name}")
106
+ print(f"{len(items)} files, {total / 1e9:.2f} GB known", file=sys.stderr)
107
+ return 0
108
+ manifest_path = args.data_dir / "acquired.json"
109
+ manifest = json.loads(manifest_path.read_text(encoding="utf-8")) if manifest_path.exists() else {"schema": 1, "files": []}
110
+ done = {f["url"] for f in manifest["files"]}
111
+ for slug, url, name in items:
112
+ if url in done:
113
+ continue
114
+ dest = args.data_dir / "raw" / slug / name
115
+ try:
116
+ size, sha = _download(url, dest)
117
+ except (urllib.error.URLError, OSError) as e:
118
+ print(f"FAILED {url}: {e}", file=sys.stderr)
119
+ continue
120
+ record = {"slug": slug, "url": url, "path": str(dest.relative_to(args.data_dir)), "size": size, "sha256": sha}
121
+ if dest.suffix.lower() == ".zip":
122
+ record["pdfs"] = _extract_pdfs(dest, dest.parent / "pdf")
123
+ manifest["files"].append(record)
124
+ manifest_path.write_text(json.dumps(manifest, indent=2), encoding="utf-8")
125
+ print(f"ok {slug} {name} {size}", file=sys.stderr)
126
+ return 0
127
+
128
+
129
+ if __name__ == "__main__":
130
+ raise SystemExit(main())
@@ -0,0 +1,88 @@
1
+ """Build the catalogue of USB-IF document-library entries and the files each one links to.
2
+
3
+ Walks the paged listing at https://www.usb.org/documents, opens each document page, and records the
4
+ PDF and ZIP links it carries. It writes data/usb-catalog.json. It downloads no specifications.
5
+ Requests are throttled to one per second with a generic User-Agent.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from primarysource.usb.paths import PACKAGE_DATA
11
+
12
+ import argparse
13
+ import json
14
+ import re
15
+ import sys
16
+ import time
17
+ import urllib.error
18
+ import urllib.request
19
+ from collections.abc import Sequence
20
+ from pathlib import Path
21
+
22
+ BASE = "https://www.usb.org"
23
+ LISTING = BASE + "/documents?page={page}"
24
+ SLUG = re.compile(r"/document-library/([a-z0-9-]+)")
25
+ FILE = re.compile(r'href="(https?://[^"]+?\.(?:pdf|zip))"|href="(/sites/default/files/[^"]+?\.(?:pdf|zip))"', re.I)
26
+ TITLE = re.compile(r"<h1[^>]*>(.*?)</h1>", re.S)
27
+ USER_AGENT = "usblib-discover/0.1 (documentation catalogue)"
28
+ DELAY = 1.0
29
+
30
+
31
+ def fetch_text(url: str) -> str | None:
32
+ req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
33
+ try:
34
+ with urllib.request.urlopen(req, timeout=60) as resp:
35
+ return resp.read().decode("utf-8", errors="replace")
36
+ except urllib.error.HTTPError as e:
37
+ print(f" {url}: HTTP {e.code}", file=sys.stderr)
38
+ return None
39
+ finally:
40
+ time.sleep(DELAY)
41
+
42
+
43
+ def listing_slugs(max_pages: int) -> list[str]:
44
+ seen: list[str] = []
45
+ for page in range(max_pages):
46
+ html = fetch_text(LISTING.format(page=page))
47
+ if html is None:
48
+ break
49
+ slugs = [s for s in dict.fromkeys(SLUG.findall(html)) if s not in seen]
50
+ if not slugs:
51
+ break
52
+ seen.extend(slugs)
53
+ print(f"listing page {page}: {len(slugs)} new, {len(seen)} total", file=sys.stderr)
54
+ return seen
55
+
56
+
57
+ def entry_files(slug: str) -> tuple[str, list[str]]:
58
+ html = fetch_text(f"{BASE}/document-library/{slug}") or ""
59
+ title_m = TITLE.search(html)
60
+ title = re.sub(r"<[^>]+>", "", title_m.group(1)).strip() if title_m else slug
61
+ urls: list[str] = []
62
+ for a, b in FILE.findall(html):
63
+ url = a or (BASE + b)
64
+ if url not in urls:
65
+ urls.append(url)
66
+ return title, urls
67
+
68
+
69
+ def main(argv: Sequence[str] | None = None) -> int:
70
+ parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
71
+ parser.add_argument("--out", type=Path, default=PACKAGE_DATA / "usb-catalog.json")
72
+ parser.add_argument("--max-pages", type=int, default=40)
73
+ args = parser.parse_args(argv)
74
+ slugs = listing_slugs(args.max_pages)
75
+ entries = []
76
+ for slug in slugs:
77
+ title, files = entry_files(slug)
78
+ entries.append({"slug": slug, "title": title, "files": files})
79
+ print(f"{slug}: {len(files)} file(s)", file=sys.stderr)
80
+ args.out.parent.mkdir(parents=True, exist_ok=True)
81
+ args.out.write_text(json.dumps({"schema": 1, "source": LISTING.format(page="N"), "entries": entries}, indent=2), encoding="utf-8")
82
+ with_files = sum(1 for e in entries if e["files"])
83
+ print(f"wrote {args.out}: {len(entries)} entries, {with_files} with files", file=sys.stderr)
84
+ return 0
85
+
86
+
87
+ if __name__ == "__main__":
88
+ raise SystemExit(main())
@@ -0,0 +1,48 @@
1
+ """Text extraction with ``pdftotext -layout``. The section splitter is primarysource.pdfcorpus.split."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import shutil
6
+ import subprocess
7
+ from pathlib import Path
8
+
9
+
10
+ MISSING_MESSAGE = (
11
+ "pdftotext was not found on PATH. Install poppler-utils (on MSYS2: pacman -S mingw-w64-x86_64-poppler) "
12
+ "and make sure pdftotext is on PATH."
13
+ )
14
+
15
+
16
+ class ExtractError(RuntimeError):
17
+ """Raised when text extraction cannot run or fails."""
18
+
19
+
20
+ def find_pdftotext() -> str:
21
+ """Return the path of pdftotext, or raise ExtractError saying how to get it."""
22
+ found = shutil.which("pdftotext")
23
+ if found is None:
24
+ raise ExtractError(MISSING_MESSAGE)
25
+ return found
26
+
27
+
28
+ def extract_text(pdf: Path, *, timeout: float = 300.0) -> str:
29
+ """Run ``pdftotext -layout`` on ``pdf`` and return the text (pages separated by form feeds)."""
30
+ exe = find_pdftotext()
31
+ try:
32
+ result = subprocess.run( # noqa: S603
33
+ [exe, "-layout", "-enc", "UTF-8", str(pdf), "-"],
34
+ capture_output=True,
35
+ timeout=timeout,
36
+ check=False,
37
+ )
38
+ except subprocess.TimeoutExpired as error:
39
+ raise ExtractError(f"pdftotext timed out after {timeout:.0f}s on {pdf}") from error
40
+ if result.returncode != 0:
41
+ detail = result.stderr.decode("utf-8", "replace").strip()
42
+ raise ExtractError(f"pdftotext failed on {pdf} (exit {result.returncode}): {detail}")
43
+ return result.stdout.decode("utf-8", "replace")
44
+
45
+
46
+ # The splitter is shared (primarysource.pdfcorpus.split); it is re-exported here for the importers that have
47
+ # always found it in this module.
48
+ from primarysource.pdfcorpus.split import split_sections as split_sections # noqa: E402
@@ -0,0 +1,197 @@
1
+ """``usblib-fetch``: download the publications in data/sources.toml, extract their text and write the index.
2
+
3
+ This is the only module that touches the network, and only for URLs listed in sources.toml.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from primarysource.usb.paths import data_dir as default_data_dir, sources_file
9
+
10
+ import argparse
11
+ import os
12
+ import sys
13
+ import urllib.error
14
+ import urllib.request
15
+ from collections.abc import Sequence
16
+ from pathlib import Path
17
+
18
+ from primarysource.usb.ingest.extract import (
19
+ ExtractError,
20
+ extract_text,
21
+ find_pdftotext,
22
+ split_sections,
23
+ )
24
+ from primarysource.usb.ingest.index import read_index, sha256_bytes, sha256_file, write_index
25
+ from primarysource.usb.ingest.sources import Source, SourcesError, load_sources
26
+ from primarysource.usb.models import Document
27
+ from primarysource.usb.spec import SPEC
28
+ from primarysource.pdfcorpus.fts import build_after_fetch
29
+
30
+ USER_AGENT = "usblib-fetch/0.1 (corpus fetcher; one request per listed URL)"
31
+ TIMEOUT_SECONDS = 120
32
+ MAX_BYTES = 200 * 1024 * 1024
33
+
34
+
35
+ class FetchError(RuntimeError):
36
+ """Raised when one document cannot be fetched or verified."""
37
+
38
+
39
+ def download(url: str, *, timeout: float = TIMEOUT_SECONDS) -> bytes:
40
+ """GET ``url`` and return the body, which must look like a PDF."""
41
+ request = urllib.request.Request(url, headers={"User-Agent": USER_AGENT}) # noqa: S310
42
+ try:
43
+ with urllib.request.urlopen(request, timeout=timeout) as response: # noqa: S310
44
+ body: bytes = response.read(MAX_BYTES + 1)
45
+ except (urllib.error.URLError, TimeoutError, OSError) as error:
46
+ raise FetchError(f"download failed for {url}: {error}") from error
47
+ if len(body) > MAX_BYTES:
48
+ raise FetchError(f"{url}: larger than {MAX_BYTES} bytes, refusing")
49
+ if not body.startswith(b"%PDF-"):
50
+ raise FetchError(f"{url}: response is not a PDF")
51
+ return body
52
+
53
+
54
+ def check_digest(doc_id: str, actual: str, expected: str) -> None:
55
+ """Raise FetchError when a recorded digest exists and differs from ``actual``."""
56
+ if expected and actual != expected:
57
+ raise FetchError(
58
+ f"{doc_id}: sha256 {actual} does not match the recorded {expected}"
59
+ )
60
+
61
+
62
+ def _store(pdf: Path, data: bytes) -> None:
63
+ temp = pdf.with_name(pdf.name + ".part")
64
+ temp.write_bytes(data)
65
+ os.replace(temp, pdf)
66
+
67
+
68
+ def obtain_pdf(
69
+ source: Source, corpus: Path, recorded: str, from_file: Path | None
70
+ ) -> str:
71
+ """Make ``corpus/<doc_id>.pdf`` exist with a matching digest and return that digest.
72
+
73
+ ``recorded`` is the digest from sources.toml, or failing that the one recorded by an earlier run.
74
+ A file already in place whose digest matches (or for which none is recorded) is kept. A mismatching
75
+ file is replaced by a fresh copy, which is itself verified before it is kept.
76
+ """
77
+ pdf = corpus / f"{source.doc_id}.pdf"
78
+ if from_file is not None:
79
+ data = from_file.read_bytes()
80
+ if not data.startswith(b"%PDF-"):
81
+ raise FetchError(f"{source.doc_id}: {from_file} is not a PDF")
82
+ digest = sha256_bytes(data)
83
+ check_digest(source.doc_id, digest, source.sha256)
84
+ if not (pdf.exists() and sha256_file(pdf) == digest):
85
+ _store(pdf, data)
86
+ return digest
87
+ if pdf.exists():
88
+ digest = sha256_file(pdf)
89
+ if not recorded or digest == recorded:
90
+ return digest
91
+ data = download(source.url)
92
+ digest = sha256_bytes(data)
93
+ check_digest(source.doc_id, digest, recorded)
94
+ _store(pdf, data)
95
+ return digest
96
+
97
+
98
+ def build_document(source: Source, corpus: Path, digest: str) -> Document:
99
+ """Extract text for ``source`` into the corpus and return its index entry."""
100
+ text = extract_text(corpus / f"{source.doc_id}.pdf")
101
+ text_name = f"{source.doc_id}.txt"
102
+ (corpus / text_name).write_text(text, encoding="utf-8", newline="")
103
+ return Document(
104
+ doc_id=source.doc_id,
105
+ number=source.number,
106
+ title=source.title,
107
+ date=source.date,
108
+ status=source.status,
109
+ url=source.url,
110
+ sha256=digest,
111
+ text_file=text_name,
112
+ sections=split_sections(text),
113
+ )
114
+
115
+
116
+ def parse_from_files(values: Sequence[str]) -> dict[str, Path]:
117
+ """Parse ``DOC_ID=PATH`` arguments."""
118
+ result: dict[str, Path] = {}
119
+ for value in values:
120
+ doc_id, sep, path = value.partition("=")
121
+ if not sep or not doc_id or not path:
122
+ raise SourcesError(f"--from-file expects DOC_ID=PATH, got {value!r}")
123
+ result[doc_id] = Path(path)
124
+ return result
125
+
126
+
127
+ def run(data_dir: Path, from_files: dict[str, Path], only: Sequence[str]) -> int:
128
+ """Fetch, verify, extract and index. Returns the process exit code."""
129
+ sources = load_sources(sources_file(data_dir))
130
+ known = {s.doc_id for s in sources}
131
+ unknown = sorted((set(from_files) | set(only)) - known)
132
+ if unknown:
133
+ raise SourcesError(f"not in sources.toml: {', '.join(unknown)}")
134
+ find_pdftotext()
135
+ corpus = data_dir / "corpus"
136
+ corpus.mkdir(parents=True, exist_ok=True)
137
+ index_path = corpus / "index.json"
138
+ previous = {d.doc_id: d for d in read_index(index_path)}
139
+ wanted = set(only) | set(from_files) if (only or from_files) else known
140
+ documents: list[Document] = []
141
+ failures = 0
142
+ for source in sources:
143
+ old = previous.get(source.doc_id)
144
+ if source.doc_id not in wanted:
145
+ if old is not None and (corpus / old.text_file).exists():
146
+ documents.append(old)
147
+ continue
148
+ recorded = source.sha256 or (old.sha256 if old else "")
149
+ try:
150
+ digest = obtain_pdf(source, corpus, recorded, from_files.get(source.doc_id))
151
+ document = build_document(source, corpus, digest)
152
+ except (FetchError, ExtractError, OSError) as error:
153
+ print(f"{source.doc_id}: {error}", file=sys.stderr)
154
+ failures += 1
155
+ continue
156
+ note = "" if source.sha256 else " (digest recorded in index.json)"
157
+ print(
158
+ f"{source.doc_id}: {len(document.sections)} sections, sha256 {digest[:12]}{note}"
159
+ )
160
+ documents.append(document)
161
+ write_index(index_path, documents)
162
+ print(f"wrote {index_path} with {len(documents)} document(s)")
163
+ if not build_after_fetch(SPEC, corpus):
164
+ failures += 1
165
+ return 1 if failures else 0
166
+
167
+
168
+ def main(argv: Sequence[str] | None = None) -> int:
169
+ parser = argparse.ArgumentParser(prog="usblib-fetch", description=__doc__)
170
+ parser.add_argument(
171
+ "--data-dir",
172
+ type=Path,
173
+ default=default_data_dir(),
174
+ help="directory holding sources.toml and corpus/ (default: $USBLIB_DATA, else the collection's home)",
175
+ )
176
+ parser.add_argument(
177
+ "--from-file",
178
+ action="append",
179
+ default=[],
180
+ metavar="DOC_ID=PATH",
181
+ help="use a PDF you already have for DOC_ID instead of downloading it (repeatable)",
182
+ )
183
+ parser.add_argument(
184
+ "doc_ids",
185
+ nargs="*",
186
+ help="only these documents (default: all, or those named by --from-file)",
187
+ )
188
+ args = parser.parse_args(argv)
189
+ try:
190
+ return run(args.data_dir, parse_from_files(args.from_file), args.doc_ids)
191
+ except (SourcesError, ExtractError) as error:
192
+ print(f"usblib-fetch: {error}", file=sys.stderr)
193
+ return 2
194
+
195
+
196
+ if __name__ == "__main__":
197
+ raise SystemExit(main())
@@ -0,0 +1,14 @@
1
+ """The usb corpus index: the shared one (primarysource.pdfcorpus.index)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from primarysource.pdfcorpus.index import (
6
+ SCHEMA,
7
+ document_from_dict,
8
+ read_index,
9
+ sha256_bytes,
10
+ sha256_file,
11
+ write_index,
12
+ )
13
+
14
+ __all__ = ["SCHEMA", "document_from_dict", "read_index", "sha256_bytes", "sha256_file", "write_index"]
@@ -0,0 +1,85 @@
1
+ """Parser for data/sources.toml, the list of publications ``usblib-fetch`` may download."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ import tomllib
7
+ from dataclasses import dataclass
8
+ from pathlib import Path
9
+
10
+ _DOC_ID = re.compile(r"^[a-z0-9][a-z0-9._-]*$")
11
+ _DATE = re.compile(r"^\d{4}-(0[1-9]|1[0-2])$")
12
+ _SHA256 = re.compile(r"^[0-9a-f]{64}$")
13
+ _REQUIRED = ("doc_id", "url", "number", "title", "date", "status")
14
+ _KNOWN = (*_REQUIRED, "sha256")
15
+
16
+
17
+ class SourcesError(ValueError):
18
+ """Raised when sources.toml is missing, malformed, or inconsistent."""
19
+
20
+
21
+ @dataclass(frozen=True, slots=True)
22
+ class Source:
23
+ """One entry of sources.toml. ``sha256`` is empty until recorded."""
24
+
25
+ doc_id: str
26
+ url: str
27
+ number: str
28
+ title: str
29
+ date: str
30
+ status: str
31
+ sha256: str = ""
32
+
33
+
34
+ def parse_sources(text: str) -> tuple[Source, ...]:
35
+ """Parse the text of sources.toml: an array of ``[[source]]`` tables."""
36
+ try:
37
+ data = tomllib.loads(text)
38
+ except tomllib.TOMLDecodeError as error:
39
+ raise SourcesError(f"sources.toml is not valid TOML: {error}") from error
40
+ entries = data.get("source")
41
+ if not isinstance(entries, list) or not entries:
42
+ raise SourcesError("sources.toml has no [[source]] entries")
43
+ sources: list[Source] = []
44
+ seen: set[str] = set()
45
+ for position, entry in enumerate(entries, start=1):
46
+ label = f"source #{position}"
47
+ if not isinstance(entry, dict):
48
+ raise SourcesError(f"{label}: not a table")
49
+ missing = [
50
+ key
51
+ for key in _REQUIRED
52
+ if not isinstance(entry.get(key), str) or not entry[key].strip()
53
+ ]
54
+ if missing:
55
+ raise SourcesError(
56
+ f"{label}: missing or empty field(s): {', '.join(missing)}"
57
+ )
58
+ unknown = sorted(set(entry) - set(_KNOWN))
59
+ if unknown:
60
+ raise SourcesError(f"{label}: unknown field(s): {', '.join(unknown)}")
61
+ doc_id = entry["doc_id"]
62
+ label = f"source {doc_id!r}"
63
+ if not _DOC_ID.match(doc_id):
64
+ raise SourcesError(f"{label}: doc_id must be a lowercase file-safe slug")
65
+ if doc_id in seen:
66
+ raise SourcesError(f"{label}: duplicate doc_id")
67
+ seen.add(doc_id)
68
+ if not entry["url"].startswith("https://"):
69
+ raise SourcesError(f"{label}: url must be https")
70
+ if not _DATE.match(entry["date"]):
71
+ raise SourcesError(f"{label}: date must be YYYY-MM, got {entry['date']!r}")
72
+ digest = entry.get("sha256", "")
73
+ if digest and not _SHA256.match(digest):
74
+ raise SourcesError(f"{label}: sha256 must be 64 lowercase hex digits")
75
+ sources.append(Source(**{key: entry[key] for key in _KNOWN if key in entry}))
76
+ return tuple(sources)
77
+
78
+
79
+ def load_sources(path: Path) -> tuple[Source, ...]:
80
+ """Read and parse ``path``."""
81
+ try:
82
+ text = path.read_text(encoding="utf-8")
83
+ except OSError as error:
84
+ raise SourcesError(f"cannot read {path}: {error}") from error
85
+ return parse_sources(text)
@@ -0,0 +1,7 @@
1
+ """The usb collection's document model: the shared one (primarysource.pdfcorpus.models)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from primarysource.pdfcorpus.models import Document, Section
6
+
7
+ __all__ = ["Document", "Section"]
@@ -0,0 +1,33 @@
1
+ """Where the usb collection keeps its files (primarysource PLAN.md §5.8).
2
+
3
+ The data directory holds `sources.toml` overrides and the fetched `corpus/`: `$USBLIB_DATA` if set (the old
4
+ setting), else `primarysource.home.collection_home("usb")`, which is `$PRIMARYSOURCE_HOME/usb` or the per-user
5
+ `primarysource/usb`. Never the working directory and never beside the installed package. The tracked metadata
6
+ (`sources.toml`, catalogues, digests) ships in the package under `data/`.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import os
12
+ from pathlib import Path
13
+
14
+ from primarysource.home import collection_home
15
+
16
+ PACKAGE_DATA = Path(__file__).resolve().parent / "data"
17
+
18
+
19
+ def data_dir() -> Path:
20
+ env = os.environ.get("USBLIB_DATA")
21
+ return Path(env) if env else collection_home("usb", legacy_app="usblib")
22
+
23
+
24
+ def corpus_dir() -> Path:
25
+ """`$USBLIB_CORPUS` if set, else `corpus/` in the data directory."""
26
+ env = os.environ.get("USBLIB_CORPUS")
27
+ return Path(env) if env else data_dir() / "corpus"
28
+
29
+
30
+ def sources_file(data: Path, name: str = "sources.toml") -> Path:
31
+ """The data directory's own `sources.toml` if it has one, else the one shipped in the package."""
32
+ local = data / name
33
+ return local if local.is_file() else PACKAGE_DATA / name
@@ -0,0 +1,3 @@
1
+ """Read-only MCP server over the locally fetched USB-IF corpus. Makes no network calls."""
2
+
3
+ from __future__ import annotations
@@ -0,0 +1,38 @@
1
+ """Load the usb corpus: the shared loader (primarysource.pdfcorpus.corpus) with the usb spec."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from primarysource.usb.spec import SPEC
8
+ from primarysource.pdfcorpus import corpus as _shared
9
+ from primarysource.pdfcorpus.corpus import SCHEMA_VERSION, CorpusError
10
+
11
+ ENV_VAR = SPEC.env_var
12
+ FETCH_COMMAND = SPEC.fetch_command
13
+ __all__ = ["ENV_VAR", "FETCH_COMMAND", "SCHEMA_VERSION", "Corpus", "CorpusError", "default_corpus_dir", "load_corpus"]
14
+
15
+
16
+ class Corpus(_shared.Corpus):
17
+ """A usb corpus: the shared one with this collection's hooks as defaults."""
18
+
19
+ def __init__(self, root: Path, documents: tuple[_shared.Document, ...], defaults: dict[str, str] | None = None) -> None:
20
+ super().__init__(
21
+ root,
22
+ documents,
23
+ defaults or {},
24
+ SPEC.alias,
25
+ fetch_command=SPEC.fetch_command,
26
+ index_command=SPEC.index_command,
27
+ prefix=f"{SPEC.name}_",
28
+ )
29
+
30
+
31
+ def default_corpus_dir() -> Path:
32
+ """``$USBLIB_CORPUS`` if set, else ``corpus/`` in the collection's data directory."""
33
+ return SPEC.corpus_dir()
34
+
35
+
36
+ def load_corpus(root: Path | None = None) -> _shared.Corpus:
37
+ """Read and validate the index; raise ``CorpusError`` whose ``action`` is the fetch command."""
38
+ return _shared.load_corpus(SPEC, root)
@@ -0,0 +1,19 @@
1
+ """MCP server for the local USB-IF specifications: the shared server (primarysource.pdfcorpus.server) with the
2
+ usb spec. `primarysource mcp --collection usb` serves `mcp`."""
3
+
4
+ from __future__ import annotations
5
+
6
+ from primarysource.usb.spec import SPEC
7
+ from primarysource.pdfcorpus.server import CorpusServer
8
+
9
+ SERVER = CorpusServer(SPEC)
10
+ mcp = SERVER.mcp
11
+
12
+
13
+ def main() -> None:
14
+ """Serve over stdio (`primarysource mcp --collection usb` is the usual way)."""
15
+ mcp.run(transport="stdio")
16
+
17
+
18
+ if __name__ == "__main__":
19
+ main()