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,123 @@
1
+ """The catalogue of IEEE editions: designations, titles, sources and pins. Metadata only, never text.
2
+
3
+ `catalogue.json` is package data. The pins it holds are also exposed as module constants (`POSIX_2024`,
4
+ `POSIX_TERMS`, `IEEE802_TERMS`) so that the core's collection manifest (`cl.collection_manifest`, owned by the
5
+ core) can lift them when it lands. Until then this module is the only reader of the file.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ from dataclasses import dataclass
12
+ from importlib import resources
13
+ from typing import Any
14
+
15
+ from primarysource.errors import LibError
16
+
17
+ PLACEMENTS = ("layer", "volume", "none")
18
+
19
+
20
+ @dataclass(frozen=True, slots=True)
21
+ class Terms:
22
+ """What a collection's publisher allows, and therefore where its text may be placed."""
23
+
24
+ collection: str
25
+ licence: str
26
+ placement: str
27
+ terms_url: str
28
+ terms_read: str
29
+ summary: str
30
+
31
+
32
+ @dataclass(frozen=True, slots=True)
33
+ class Edition:
34
+ """One edition of one document. `sha256` is None until a pin exists (an 802 PDF the user has not supplied)."""
35
+
36
+ collection: str
37
+ id: str
38
+ designations: tuple[str, ...]
39
+ title: str
40
+ publisher: str
41
+ edition_date: str
42
+ page_url: str
43
+ source_url: str | None
44
+ archive: str | None
45
+ sha256: str | None
46
+ size: int | None
47
+ root: str | None = None
48
+
49
+
50
+ def _text(entry: dict[str, Any], key: str) -> str:
51
+ value = entry.get(key)
52
+ if not isinstance(value, str) or not value:
53
+ raise ValueError(f"catalogue entry {entry.get('id')!r}: {key} must be a non-empty string")
54
+ return value
55
+
56
+
57
+ def _optional(entry: dict[str, Any], key: str) -> str | None:
58
+ value = entry.get(key)
59
+ if value is not None and not isinstance(value, str):
60
+ raise ValueError(f"catalogue entry {entry.get('id')!r}: {key} must be a string or null")
61
+ return value
62
+
63
+
64
+ def parse(raw: str) -> tuple[dict[str, Terms], dict[str, Edition]]:
65
+ """Validate the catalogue text. A malformed catalogue is a packaging bug, so it raises ValueError."""
66
+ data = json.loads(raw)
67
+ if data.get("schema") != 1:
68
+ raise ValueError("catalogue schema must be 1")
69
+ terms: dict[str, Terms] = {}
70
+ editions: dict[str, Edition] = {}
71
+ for collection, body in data["collections"].items():
72
+ placement = _text(body, "placement")
73
+ if placement not in PLACEMENTS:
74
+ raise ValueError(f"{collection}: placement {placement!r} is not one of {PLACEMENTS}")
75
+ terms[collection] = Terms(
76
+ collection,
77
+ _text(body, "licence"),
78
+ placement,
79
+ _text(body, "terms_url"),
80
+ _text(body, "terms_read"),
81
+ _text(body, "terms_summary"),
82
+ )
83
+ for entry in body["editions"]:
84
+ digest = _optional(entry, "sha256")
85
+ if digest is not None and (len(digest) != 64 or digest.strip("0123456789abcdef")):
86
+ raise ValueError(f"{entry.get('id')!r}: sha256 must be 64 lowercase hex digits")
87
+ size = entry.get("size")
88
+ if size is not None and (not isinstance(size, int) or size <= 0):
89
+ raise ValueError(f"{entry.get('id')!r}: size must be a positive integer or null")
90
+ edition = Edition(
91
+ collection,
92
+ _text(entry, "id"),
93
+ tuple(entry["designations"]),
94
+ _text(entry, "title"),
95
+ _text(entry, "publisher"),
96
+ _text(entry, "edition_date"),
97
+ _text(entry, "page_url"),
98
+ _optional(entry, "source_url"),
99
+ _optional(entry, "archive"),
100
+ digest,
101
+ size,
102
+ _optional(entry, "root"),
103
+ )
104
+ if edition.id in editions:
105
+ raise ValueError(f"duplicate edition id {edition.id!r}")
106
+ editions[edition.id] = edition
107
+ return terms, editions
108
+
109
+
110
+ TERMS, EDITIONS = parse(resources.files("primarysource.ieee").joinpath("catalogue.json").read_text(encoding="utf-8"))
111
+
112
+ POSIX_TERMS = TERMS["posix"]
113
+ IEEE802_TERMS = TERMS["ieee802"]
114
+ POSIX_2024 = EDITIONS["posix-2024"]
115
+
116
+
117
+ def edition(collection: str, edition_id: str) -> Edition:
118
+ """The catalogued edition, or a LibError that lists the ones this collection knows."""
119
+ found = EDITIONS.get(edition_id)
120
+ if found is None or found.collection != collection:
121
+ known = ", ".join(sorted(e.id for e in EDITIONS.values() if e.collection == collection)) or "none"
122
+ raise LibError(f"{edition_id!r} is not in the {collection} catalogue (known: {known})", "")
123
+ return found
@@ -0,0 +1,79 @@
1
+ """`primarysource posix ...` and `primarysource ieee802 ...`: the collections' own commands.
2
+
3
+ primarysource posix import <susv5.tgz>
4
+ primarysource posix pages [volume]
5
+ primarysource posix outline <page>
6
+ primarysource posix read <ref> [--max-tokens N]
7
+ primarysource posix grep <text> [--volume V] [--limit N] [--start N]
8
+ primarysource ieee802 import <file.pdf> <designation>
9
+ primarysource ieee802 outline <designation>
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import argparse
15
+ import sys
16
+ from collections.abc import Sequence
17
+
18
+ from primarysource.errors import LibError
19
+ from primarysource.ieee import ieee802, importer, posix
20
+
21
+
22
+ def _posix_parser() -> argparse.ArgumentParser:
23
+ parser = argparse.ArgumentParser(prog="primarysource posix", description="IEEE Std 1003.1-2024 (POSIX)")
24
+ commands = parser.add_subparsers(dest="command", required=True)
25
+ commands.add_parser("import", help="verify and import the user's susv5.tgz").add_argument("archive")
26
+ commands.add_parser("pages", help="list page ids").add_argument("volume", nargs="?")
27
+ commands.add_parser("outline", help="a page's headings with refs").add_argument("page")
28
+ read = commands.add_parser("read", help="read by ref")
29
+ read.add_argument("ref")
30
+ read.add_argument("--max-tokens", type=int, default=2000)
31
+ grep = commands.add_parser("grep", help="find blocks containing text")
32
+ grep.add_argument("text")
33
+ grep.add_argument("--volume")
34
+ grep.add_argument("--limit", type=int, default=20)
35
+ grep.add_argument("--start", type=int, default=0)
36
+ return parser
37
+
38
+
39
+ def _ieee802_parser() -> argparse.ArgumentParser:
40
+ parser = argparse.ArgumentParser(prog="primarysource ieee802", description="IEEE 802 standards (GET Program)")
41
+ commands = parser.add_subparsers(dest="command", required=True)
42
+ imp = commands.add_parser("import", help="import a PDF you obtained yourself")
43
+ imp.add_argument("pdf")
44
+ imp.add_argument("designation")
45
+ commands.add_parser("outline", help="clauses from the PDF's bookmarks").add_argument("designation")
46
+ return parser
47
+
48
+
49
+ def _run(text: str) -> int:
50
+ sys.stdout.write(text if text.endswith("\n") else text + "\n")
51
+ return 0
52
+
53
+
54
+ def posix_main(argv: Sequence[str]) -> int:
55
+ args = _posix_parser().parse_args(list(argv))
56
+ try:
57
+ if args.command == "import":
58
+ return importer.main(["posix", args.archive])
59
+ if args.command == "pages":
60
+ return _run("\n".join(posix.pages(args.volume)))
61
+ if args.command == "outline":
62
+ return _run(posix.outline(args.page))
63
+ if args.command == "read":
64
+ return _run(posix.read(args.ref, max_tokens=args.max_tokens))
65
+ return _run(posix.grep(args.text, volume=args.volume, limit=args.limit, start=args.start))
66
+ except LibError as exc:
67
+ print(exc.as_text(), file=sys.stderr)
68
+ return 1
69
+
70
+
71
+ def ieee802_main(argv: Sequence[str]) -> int:
72
+ args = _ieee802_parser().parse_args(list(argv))
73
+ try:
74
+ if args.command == "import":
75
+ return importer.main(["ieee802", args.pdf, args.designation])
76
+ return _run(ieee802.outline(args.designation))
77
+ except LibError as exc:
78
+ print(exc.as_text(), file=sys.stderr)
79
+ return 1
@@ -0,0 +1,24 @@
1
+ """How the ieee802 collection recognises an IEEE 802 standard in a supplied PDF (`registry.Collection.identify`).
2
+
3
+ Covers and running headers read `IEEE Std 802.1Q-2022`. The importer takes the user's own PDF and the designation.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import re
9
+
10
+ from primarysource.identify import Candidate, Probe, candidates
11
+
12
+ _STD = re.compile(r"\bIEEE\s+Std\.?\s+(?P<designation>802(?:\.\d{1,2}[A-Za-z]{0,4})*-\d{4})\b")
13
+
14
+
15
+ def _label(match: re.Match[str]) -> str:
16
+ return f"IEEE Std {match['designation']}"
17
+
18
+
19
+ def identify(probe: Probe) -> list[Candidate]:
20
+ def command(label: str) -> str:
21
+ where = probe.path if probe.path is not None else probe.name
22
+ return f'primarysource ieee802 import "{where}" {label.removeprefix("IEEE Std ")}'
23
+
24
+ return candidates(probe, "ieee802", _STD, _label, command)
@@ -0,0 +1,47 @@
1
+ """Read what `importer.import_pdf` stored for an 802 standard: its clause outline from the PDF's bookmarks.
2
+
3
+ Reading a clause's sentences (`802.1Q-2022§5.4¶2`) needs a clause segmenter over the extracted page text; that is
4
+ the core's planned `cl.segment_bookmarks` (itulib's walker), so it is a `[ ]` row (`ieee.ieee802_read`), not code
5
+ written twice here.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ from pathlib import Path
12
+
13
+ from primarysource.errors import LibError
14
+ from primarysource.ieee import IEEE802
15
+ from primarysource.ieee.catalogue import edition
16
+ from primarysource.ieee.importer import INSTALL_RECORD, collection_home, normalise_designation
17
+ from primarysource.refs import Ref
18
+ from primarysource.render import render
19
+
20
+
21
+ def installed(designation: str, home: Path | None = None) -> Path:
22
+ """The directory of an imported 802 standard, or a LibError that says how to import it."""
23
+ pinned = edition("ieee802", normalise_designation(designation))
24
+ root = collection_home("ieee802", home) / pinned.id
25
+ if not (root / INSTALL_RECORD).is_file():
26
+ raise LibError(
27
+ f"{pinned.id} is not imported",
28
+ "",
29
+ f"obtain the PDF yourself from {pinned.page_url} (IEEE GET Program terms, see docs/ieee.md), then run "
30
+ f"primarysource ieee802 import <file.pdf> {pinned.id}",
31
+ )
32
+ return root
33
+
34
+
35
+ def outline(designation: str, *, home: Path | None = None) -> str:
36
+ """The PDF's bookmarks, indented by depth, with a ref for each numbered clause and its PDF page."""
37
+ root = installed(designation, home)
38
+ pinned = edition("ieee802", normalise_designation(designation))
39
+ entries = json.loads((root / "outline.json").read_text(encoding="utf-8"))
40
+ lines = []
41
+ for entry in entries:
42
+ ref = IEEE802.grammar.format(Ref(pinned.id, entry["clause"])) if entry["clause"] else ""
43
+ where = f"p. {entry['page']}" if entry["page"] else "no page"
44
+ parts = [entry["title"], ref, f"({where})"]
45
+ lines.append(" " * (entry["depth"] - 1) + " ".join(part for part in parts if part))
46
+ body = "\n".join(lines) if lines else f"{pinned.id} has no bookmarks"
47
+ return render(body, {"cite": f"{pinned.designations[0]}, {pinned.title}", "unit": IEEE802.unit})
@@ -0,0 +1,305 @@
1
+ """Import a user-supplied IEEE document into `$PRIMARYSOURCE_HOME/<collection>/<edition>/`.
2
+
3
+ Nothing here fetches. The user downloads the POSIX archive or an 802 PDF themselves, under the publisher's terms
4
+ (`docs/ieee.md`), and hands the file over; a name, an email address, a login or a terms checkbox is a human act
5
+ this code never performs.
6
+
7
+ `import_posix` checks the archive against the pinned sha256 before reading a single member, then streams the
8
+ tarball (`tarfile` mode `r|gz`, no seeking) and writes only the HTML pages, under bounds, into a staging directory
9
+ that replaces the installed edition in one rename. This is an installation step: the readers in `posix.py` then
10
+ read one page file at a time.
11
+
12
+ `import_pdf` reads an 802 PDF with `pypdf` (the `[ieee]` extra) and stores its bookmarks and page text.
13
+
14
+ Run as `python -m primarysource.ieee.importer posix <susv5.tgz>` or `... ieee802 <file.pdf> <designation>`.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import hashlib
20
+ import json
21
+ import os
22
+ import re
23
+ import secrets
24
+ import shutil
25
+ import sys
26
+ import tarfile
27
+ from collections import Counter
28
+ from collections.abc import Sequence
29
+ from dataclasses import dataclass, field
30
+ from datetime import UTC, datetime
31
+ from pathlib import Path, PurePosixPath
32
+ from typing import Any
33
+
34
+ from primarysource.errors import LibError
35
+ from primarysource.home import atomic_write
36
+ from primarysource.home import collection_home as core_collection_home
37
+ from primarysource.ieee import POSIX_PAGE
38
+ from primarysource.ieee.catalogue import POSIX_2024, Edition, edition
39
+
40
+ INSTALL_RECORD = "install.json"
41
+ INSTALL_HINT = 'pip install "primarysource[ieee]"'
42
+ _PAGE = re.compile(POSIX_PAGE)
43
+ _CHUNK = 1 << 20
44
+
45
+
46
+ @dataclass(frozen=True, slots=True)
47
+ class Limits:
48
+ """Ceilings on what an import will accept. The real POSIX 2024 archive measures 4,962,222 bytes, 1,715
49
+ members, 1,616 HTML pages, 24,248,288 bytes of HTML, largest page 459,326 bytes."""
50
+
51
+ archive_bytes: int = 64_000_000
52
+ members: int = 10_000
53
+ member_bytes: int = 4_000_000
54
+ total_bytes: int = 200_000_000
55
+ pdf_bytes: int = 300_000_000
56
+
57
+
58
+ @dataclass(frozen=True, slots=True)
59
+ class Report:
60
+ """What an import wrote and what it skipped, by reason."""
61
+
62
+ edition: str
63
+ path: Path
64
+ pages: int
65
+ bytes: int
66
+ skipped: dict[str, int] = field(default_factory=dict)
67
+
68
+
69
+ def collection_home(collection: str, home: Path | None = None) -> Path:
70
+ """The core's `home.collection_home` (`$PRIMARYSOURCE_HOME/<collection>`, PLAN.md §5.8), or
71
+ `<home>/<collection>` when a home is given (tests, the measurement script)."""
72
+ return home / collection if home is not None else core_collection_home(collection)
73
+
74
+
75
+ def sha256_file(path: Path, limit: int) -> tuple[str, int]:
76
+ """Digest and size of a file, refusing one larger than `limit` without reading past it."""
77
+ digest = hashlib.sha256()
78
+ size = 0
79
+ with path.open("rb") as stream:
80
+ while chunk := stream.read(_CHUNK):
81
+ size += len(chunk)
82
+ if size > limit:
83
+ raise LibError(f"{path.name} is over {limit:,} bytes; this is not the expected file", "")
84
+ digest.update(chunk)
85
+ return digest.hexdigest(), size
86
+
87
+
88
+ def _verify(path: Path, pinned: Edition, limit: int) -> tuple[str, int]:
89
+ if not path.is_file():
90
+ raise LibError(f"no file at {path}", f"download {pinned.source_url or pinned.page_url} first")
91
+ got, size = sha256_file(path, limit)
92
+ if pinned.sha256 is not None and got != pinned.sha256:
93
+ raise LibError(
94
+ f"{path.name} has sha256 {got}, not the {pinned.sha256} pinned for {pinned.id}: the file is damaged, "
95
+ "truncated, or a different edition or archive format",
96
+ f"download {pinned.source_url or pinned.page_url} again (docs/ieee.md says how)",
97
+ )
98
+ return got, size
99
+
100
+
101
+ def _member_path(name: str) -> PurePosixPath:
102
+ """The member's path, or a LibError for one that could escape the install directory."""
103
+ if "\x00" in name or "\\" in name or name.startswith("/") or re.match(r"[A-Za-z]:", name):
104
+ raise LibError(f"refusing archive member {name!r}: absolute or unsafe path", "")
105
+ path = PurePosixPath(name)
106
+ if ".." in path.parts:
107
+ raise LibError(f"refusing archive member {name!r}: path traversal", "")
108
+ return path
109
+
110
+
111
+ def _install(staging: Path, target: Path) -> None:
112
+ """Replace `target` with `staging` by renames, so a reader sees the old edition or the new one."""
113
+ old = target.with_name(f".{target.name}.old-{secrets.token_hex(4)}")
114
+ if target.exists():
115
+ os.replace(target, old)
116
+ try:
117
+ os.replace(staging, target)
118
+ except BaseException:
119
+ if old.exists():
120
+ os.replace(old, target)
121
+ raise
122
+ shutil.rmtree(old, ignore_errors=True)
123
+
124
+
125
+ def import_posix(
126
+ archive: Path | str,
127
+ *,
128
+ home: Path | None = None,
129
+ pinned: Edition = POSIX_2024,
130
+ limits: Limits | None = None,
131
+ ) -> Report:
132
+ """Verify and install the POSIX HTML archive. Refuses, naming the cause, on a digest mismatch, an unsafe or
133
+ oversize member, or more members or bytes than `limits` allow; nothing is installed unless all of it passed."""
134
+ limits = limits or Limits()
135
+ path = Path(archive)
136
+ if pinned.sha256 is None or pinned.root is None:
137
+ raise LibError(f"{pinned.id} has no pinned archive", "")
138
+ digest, size = _verify(path, pinned, limits.archive_bytes)
139
+ base = collection_home(pinned.collection, home)
140
+ base.mkdir(parents=True, exist_ok=True)
141
+ staging = base / f".{pinned.id}.part-{secrets.token_hex(4)}"
142
+ staging.mkdir()
143
+ skipped: Counter[str] = Counter()
144
+ pages = 0
145
+ written = 0
146
+ try:
147
+ with path.open("rb") as raw, tarfile.open(fileobj=raw, mode="r|gz") as tar:
148
+ for count, member in enumerate(tar, start=1):
149
+ if count > limits.members:
150
+ raise LibError(f"{path.name} has more than {limits.members:,} members", "")
151
+ name = _member_path(member.name)
152
+ if not member.isfile():
153
+ skipped["not a regular file"] += 1
154
+ continue
155
+ relative = str(name)
156
+ if not relative.startswith(pinned.root):
157
+ skipped["outside " + pinned.root] += 1
158
+ continue
159
+ relative = relative[len(pinned.root) :]
160
+ if not relative.endswith(".html"):
161
+ skipped["not html"] += 1
162
+ continue
163
+ if not _PAGE.fullmatch(relative[: -len(".html")]):
164
+ skipped["no page id"] += 1
165
+ continue
166
+ if member.size > limits.member_bytes:
167
+ raise LibError(f"refusing {member.name!r}: {member.size:,} bytes is over the per-page limit", "")
168
+ stream = tar.extractfile(member)
169
+ data = stream.read(limits.member_bytes + 1) if stream is not None else b""
170
+ if len(data) > limits.member_bytes:
171
+ raise LibError(f"refusing {member.name!r}: over the per-page limit", "")
172
+ written += len(data)
173
+ if written > limits.total_bytes:
174
+ raise LibError(f"{path.name} unpacks to more than {limits.total_bytes:,} bytes of pages", "")
175
+ destination = staging.joinpath(*PurePosixPath(relative).parts)
176
+ if staging.resolve() not in destination.resolve().parents:
177
+ raise LibError(f"refusing archive member {member.name!r}: resolves outside the install", "")
178
+ destination.parent.mkdir(parents=True, exist_ok=True)
179
+ destination.write_bytes(data)
180
+ pages += 1
181
+ record = {
182
+ "edition": pinned.id,
183
+ "archive": path.name,
184
+ "sha256": digest,
185
+ "size": size,
186
+ "pages": pages,
187
+ "bytes": written,
188
+ "skipped": dict(sorted(skipped.items())),
189
+ "imported": datetime.now(UTC).isoformat(timespec="seconds"),
190
+ }
191
+ (staging / INSTALL_RECORD).write_text(json.dumps(record, indent=2) + "\n", encoding="utf-8")
192
+ target = base / pinned.id
193
+ _install(staging, target)
194
+ except tarfile.TarError as exc:
195
+ raise LibError(f"{path.name} is not a readable gzip tar archive ({exc})", "") from exc
196
+ finally:
197
+ shutil.rmtree(staging, ignore_errors=True)
198
+ return Report(pinned.id, target, pages, written, dict(skipped))
199
+
200
+
201
+ _CLAUSE = re.compile(r"\s*(?:Annex\s+)?(?P<id>[0-9]+(?:\.[0-9]+)*|[A-Z](?:\.[0-9]+)*)\b[\s.:)-]*(?P<title>.*)")
202
+
203
+
204
+ def clause_of(title: str) -> str | None:
205
+ """The clause or annex number a bookmark title starts with (`5.4 Bridge`, `Annex A`), or None."""
206
+ match = _CLAUSE.match(title)
207
+ if match is None:
208
+ return None
209
+ ident = match["id"]
210
+ if len(ident) == 1 and ident.isalpha() and not title.lstrip().startswith(("Annex", ident + ".", ident + " ")):
211
+ return None
212
+ return ident
213
+
214
+
215
+ def normalise_designation(text: str) -> str:
216
+ """`IEEE Std 802.1Q-2022` and `IEEE 802.1Q-2022` -> `802.1Q-2022`."""
217
+ return re.sub(r"^\s*IEEE\s+(?:Std\.?\s+)?", "", text).strip()
218
+
219
+
220
+ def import_pdf(
221
+ pdf: Path | str,
222
+ designation: str,
223
+ *,
224
+ home: Path | None = None,
225
+ limits: Limits | None = None,
226
+ ) -> Report:
227
+ """Store the bookmarks and page text of a user-supplied 802 PDF. Refuses a designation the catalogue does not
228
+ list, a file that is not a PDF, a digest that differs from a pin, and an encrypted PDF (never decrypted)."""
229
+ limits = limits or Limits()
230
+ pinned = edition("ieee802", normalise_designation(designation))
231
+ path = Path(pdf)
232
+ if not path.is_file():
233
+ raise LibError(f"no file at {path}", f"download {pinned.id} from {pinned.page_url} yourself first")
234
+ with path.open("rb") as stream:
235
+ if stream.read(5) != b"%PDF-":
236
+ raise LibError(f"{path.name} is not a PDF", "")
237
+ digest, size = _verify(path, pinned, limits.pdf_bytes)
238
+ try:
239
+ import pypdf
240
+ except ModuleNotFoundError as exc:
241
+ raise LibError(f"importing an 802 PDF needs {exc.name!r}, which is not installed", INSTALL_HINT) from exc
242
+ try:
243
+ reader = pypdf.PdfReader(path)
244
+ if reader.is_encrypted:
245
+ raise LibError(f"{path.name} is encrypted; primarysource never decrypts a document", "")
246
+ outline = _outline(reader, reader.outline, 1)
247
+ pages = [page.extract_text() or "" for page in reader.pages]
248
+ except pypdf.errors.PyPdfError as exc:
249
+ raise LibError(f"{path.name} could not be read as a PDF ({exc})", "") from exc
250
+ target = collection_home("ieee802", home) / pinned.id
251
+ record: dict[str, Any] = {
252
+ "edition": pinned.id,
253
+ "file": path.name,
254
+ "sha256": digest,
255
+ "size": size,
256
+ "pinned": pinned.sha256 is not None,
257
+ "backend": f"pypdf {pypdf.__version__}",
258
+ "pages": len(pages),
259
+ "imported": datetime.now(UTC).isoformat(timespec="seconds"),
260
+ }
261
+ atomic_write(target / "outline.json", (json.dumps(outline, indent=1) + "\n").encode("utf-8"))
262
+ atomic_write(target / "pages.json", json.dumps(pages).encode("utf-8"))
263
+ atomic_write(target / INSTALL_RECORD, (json.dumps(record, indent=2) + "\n").encode("utf-8"))
264
+ return Report(pinned.id, target, len(pages), sum(len(p) for p in pages), {})
265
+
266
+
267
+ def _outline(reader: Any, items: Sequence[Any], depth: int) -> list[dict[str, Any]]:
268
+ """Bookmarks in reading order: depth, title, clause number (or None) and 1-based page."""
269
+ found: list[dict[str, Any]] = []
270
+ if depth > 12:
271
+ return found
272
+ for item in items:
273
+ if isinstance(item, list):
274
+ found.extend(_outline(reader, item, depth + 1))
275
+ continue
276
+ title = str(getattr(item, "title", "") or "").strip()
277
+ try:
278
+ page = reader.get_destination_page_number(item) + 1
279
+ except Exception: # a broken destination loses its page, not the import
280
+ page = None
281
+ found.append({"depth": depth, "title": title, "clause": clause_of(title), "page": page})
282
+ return found
283
+
284
+
285
+ def main(argv: Sequence[str] | None = None) -> int:
286
+ args = list(sys.argv[1:] if argv is None else argv)
287
+ try:
288
+ if len(args) == 2 and args[0] == "posix":
289
+ report = import_posix(args[1])
290
+ elif len(args) == 3 and args[0] == "ieee802":
291
+ report = import_pdf(args[1], args[2])
292
+ else:
293
+ print("usage: python -m primarysource.ieee.importer posix <susv5.tgz>", file=sys.stderr)
294
+ print(" python -m primarysource.ieee.importer ieee802 <file.pdf> <designation>", file=sys.stderr)
295
+ return 2
296
+ except LibError as exc:
297
+ print(exc.as_text(), file=sys.stderr)
298
+ return 1
299
+ skipped = ", ".join(f"{reason}: {n}" for reason, n in sorted(report.skipped.items())) or "none"
300
+ print(f"{report.edition}: {report.pages} pages, {report.bytes:,} bytes -> {report.path} (skipped: {skipped})")
301
+ return 0
302
+
303
+
304
+ if __name__ == "__main__":
305
+ raise SystemExit(main())