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,445 @@
1
+ """`primarysource itu` (formerly `itulib`) - fetch, index, then ask.
2
+
3
+ `fetch` and `sync` are the only commands that touch the network, and they write outside the checkout by
4
+ default. Everything that reads refuses to answer when the corpus or index is absent, naming the command that
5
+ fixes it; an empty result here is always a real empty, never a swallowed failure.
6
+
7
+ References use one grammar everywhere: `X.690§11.6¶2` (`¶n` is the nth sentence of the extracted clause), or
8
+ the looser `X.690 11.6` and `11.6` (which means X.690). `X.690§11.6¶5-` is the clause from its 5th sentence on,
9
+ which is what a paged read says to continue with.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import argparse
15
+ import sys
16
+ from collections.abc import Callable
17
+
18
+ from primarysource.errors import LibError
19
+
20
+ from . import __version__, corpus, index, sources, text
21
+ from .errata import errata_text
22
+
23
+ DEFAULT = "X.690"
24
+
25
+
26
+ def _resolve(ref: str) -> tuple[sources.Edition, str, int | None]:
27
+ rec, number, para = text.parse_ref(ref)
28
+ return sources.resolve(rec or DEFAULT), number, para
29
+
30
+
31
+ def _join(parts: list[str]) -> str:
32
+ return " ".join(parts)
33
+
34
+
35
+ def cmd_info(_args: argparse.Namespace) -> int:
36
+ for item in (*sources.IN_FORCE, *sources.SUPERSEDED):
37
+ state = "fetched" if corpus.has(item) else "-"
38
+ print(f"{item.key:18} {item.status:11} {item.octets:>10,} B {state:8} {item.title}")
39
+ for amendment in sources.AMENDMENTS:
40
+ fetched = "fetched" if amendment.artifact and corpus.has(amendment) else "-"
41
+ print(f"{amendment.key:18} {amendment.status:11} {'':>12} {fetched:8} {amendment.label}")
42
+ print(f"corpus home: {corpus.home()}")
43
+ return 0
44
+
45
+
46
+ def cmd_fetch(args: argparse.Namespace) -> int:
47
+ document = sources.resolve_document(args.target)
48
+ path = corpus.fetch(document, refresh=args.refresh)
49
+ print(f"fetched {document.key} -> {path} ({path.stat().st_size:,} B)")
50
+ return 0
51
+
52
+
53
+ def cmd_sync(args: argparse.Namespace) -> int:
54
+ """Fetch and index each in-force edition. One failure is reported and does not stop the rest, and the exit
55
+ status is non-zero if any failed, because a sync that quietly skips reports the same green as one that ran."""
56
+ everything = (*sources.IN_FORCE, *sources.SUPERSEDED)
57
+ targets = (
58
+ [sources.resolve(name) for name in args.targets]
59
+ if args.targets
60
+ else list(everything if args.all else sources.IN_FORCE)
61
+ )
62
+ failed: list[str] = []
63
+ for edition in targets:
64
+ try:
65
+ corpus.fetch(edition)
66
+ count = index.build(edition)
67
+ print(f"{edition.key:16} ok {count:>5} clauses")
68
+ except LibError as error:
69
+ failed.append(edition.key)
70
+ print(f"{edition.key:16} FAILED {error.as_text()}", file=sys.stderr)
71
+ except (RuntimeError, OSError) as error:
72
+ failed.append(edition.key)
73
+ print(f"{edition.key:16} FAILED {error}", file=sys.stderr)
74
+ return 3 if failed else 0
75
+
76
+
77
+ def cmd_index(args: argparse.Namespace) -> int:
78
+ edition = sources.resolve(args.edition)
79
+ print(f"{edition.key}: {index.build(edition)} clauses indexed")
80
+ return 0
81
+
82
+
83
+ def cmd_search(args: argparse.Namespace) -> int:
84
+ edition = sources.resolve(args.edition)
85
+ query = _join(args.query)
86
+ found = index.search(edition, query, args.limit, args.start)
87
+ if found.relaxed:
88
+ print("note: no clause contains every term; these contain some of them\n", file=sys.stderr)
89
+ if not found.hits:
90
+ print(f"{edition.key}: no clause matches {query!r} (this is a real empty: the index is loaded)")
91
+ return 1
92
+ for hit in found.hits:
93
+ print(f"{index.citation(edition, hit.number)} {hit.title}\n {hit.excerpt}\n")
94
+ if found.next_start is not None:
95
+ print(f"[more: continue with --start {found.next_start}]")
96
+ return 0
97
+
98
+
99
+ def cmd_read(args: argparse.Namespace) -> int:
100
+ ref = _join(args.ref)
101
+ document = next((a for a in sources.AMENDMENTS if a.key == args.ref[0]), None)
102
+ if document is not None:
103
+ print(document.label, "", errata_text(document), sep="\n")
104
+ return 0
105
+ target = text.parse_target(ref)
106
+ edition, number, para = sources.resolve(target.rec or DEFAULT), target.number, target.para
107
+ start = para if target.onward and para is not None else args.start
108
+ if target.onward:
109
+ para = None
110
+ if args.view == "normative":
111
+ found = index.normative_statements(edition, number)
112
+ if found is None:
113
+ print(f"{edition.key}: no clause §{number}", file=sys.stderr)
114
+ return 1
115
+ for n, sentence in found:
116
+ print(f"{index.citation(edition, number, n)}\n {sentence}")
117
+ return 0
118
+ if args.view == "refs":
119
+ return _print_links(edition, number)
120
+ if para is not None:
121
+ one = index.sentence(edition, number, para)
122
+ if one is None:
123
+ print(f"{edition.key}: §{number} has no ¶{para}", file=sys.stderr)
124
+ return 1
125
+ print(f"{index.citation(edition, number, para)}\n\n{one}")
126
+ return 0
127
+ page = index.page(edition, number, start=start, max_tokens=args.max_tokens)
128
+ if page is None or page.total == 0:
129
+ kids = index.children(edition, number)
130
+ if kids:
131
+ print(f"{index.citation(edition, number)}: heading only; subclauses:")
132
+ for child, title in kids:
133
+ print(f" {child:10} {title}")
134
+ return 0
135
+ if page is None:
136
+ print(f"{edition.key}: no clause §{number}", file=sys.stderr)
137
+ return 1
138
+ print(f"{index.citation(edition, number)} {page.title}: heading with no text and no indexed subclauses")
139
+ return 0
140
+ print(f"{index.citation(edition, page.number)} {page.title}\n")
141
+ print(page.text)
142
+ if page.next is not None:
143
+ doc = edition.rec if sources.resolve(edition.rec) is edition else edition.key
144
+ print(
145
+ f"\n[¶{page.first}-{page.last} of {page.total}; continue with: read '{doc}§{page.number}¶{page.next}-']"
146
+ )
147
+ extra = index.fragments(edition, number) - 1
148
+ if extra:
149
+ print(
150
+ f"\nwarning: {extra} other extracted fragment(s) share the number {number}; this is the first. "
151
+ "Check the PDF before relying on it.",
152
+ file=sys.stderr,
153
+ )
154
+ return 0
155
+
156
+
157
+ def cmd_cite(args: argparse.Namespace) -> int:
158
+ edition, number, para = _resolve(_join(args.ref))
159
+ if index.read(edition, number) is None:
160
+ print(f"{edition.key}: no clause §{number}; refusing to cite what is not there", file=sys.stderr)
161
+ return 1
162
+ print(index.citation(edition, number, para))
163
+ return 0
164
+
165
+
166
+ def _print_links(edition: sources.Edition, number: str) -> int:
167
+ found = index.links(edition, number)
168
+ if found is None:
169
+ print(f"{edition.key}: no clause §{number}", file=sys.stderr)
170
+ return 1
171
+ print(index.citation(edition, number))
172
+ for label, values in (
173
+ ("points at", found.clauses),
174
+ ("unresolved", found.unresolved),
175
+ ("recommendations", found.recommendations),
176
+ ("standards", found.standards),
177
+ ("cited by", found.cited_by),
178
+ ):
179
+ print(f" {label:16} {', '.join(values) if values else '-'}")
180
+ return 0
181
+
182
+
183
+ def cmd_compare(args: argparse.Namespace) -> int:
184
+ first, second = sources.resolve(args.edition_a), sources.resolve(args.edition_b)
185
+ _rec, number, _para = text.parse_ref(args.number)
186
+ result = index.compare(first, second, number)
187
+ print(f"{index.citation(first, number)} -> {index.citation(second, number)}")
188
+ if not (result.in_a and result.in_b):
189
+ side = first.key if not result.in_a else second.key
190
+ print(f"§{number} does not exist in {side}; the rule may have moved rather than gone.")
191
+ return 1
192
+ if result.same:
193
+ print("identical (ignoring whitespace and case)")
194
+ return 0
195
+ for para, sentence in result.removed:
196
+ print(f"- [¶{para}] {sentence}")
197
+ for para, sentence in result.added:
198
+ print(f"+ [¶{para}] {sentence}")
199
+ return 0
200
+
201
+
202
+ def cmd_catalog_sync(args: argparse.Namespace) -> int:
203
+ """Read series listings (about one request each), then, with --details, each recommendation's pages."""
204
+ from . import catalog
205
+
206
+ letters = list(sources.SERIES) if args.series == ["all"] else [s.upper() for s in args.series]
207
+ fetcher = catalog.Fetcher(catalog.catalog_dir() / "cache")
208
+ for letter in letters:
209
+ count = catalog.sync_listing(letter, fetcher, refresh=args.refresh)
210
+ print(f"series {letter}: {count} recommendations listed")
211
+ if args.details:
212
+ done, pages = catalog.sync_details(
213
+ letter,
214
+ fetcher,
215
+ limit=args.limit,
216
+ only=args.rec or None,
217
+ include_withdrawn=args.include_withdrawn,
218
+ refresh=args.refresh,
219
+ reparse=args.reparse,
220
+ progress=lambda message: print(" " + message, flush=True),
221
+ )
222
+ print(f"series {letter}: {done} recommendations detailed, {pages} component pages read")
223
+ print(f"{fetcher.requests} request(s) made to itu.int")
224
+ return 0
225
+
226
+
227
+ def cmd_catalog_list(args: argparse.Namespace) -> int:
228
+ from . import catalog
229
+
230
+ recs = catalog.load(args.series.upper())
231
+ found = catalog.rows(recs, text=args.text, fmt=args.format, free_only=args.free)
232
+ for row in found[: args.limit]:
233
+ print(f"{row.rec:10} {row.kind:11} {row.label:42} {row.summary():34} {row.title}")
234
+ shown = min(len(found), args.limit)
235
+ detailed = sum(1 for entry in recs.values() if entry.get("components") is not None)
236
+ print(
237
+ f"\n{shown} of {len(found)} shown; {detailed} of {len(recs)} recommendations in series {args.series.upper()}"
238
+ f" have details (sync with --details for the rest). * = not freely available."
239
+ )
240
+ return 0 if found else 1
241
+
242
+
243
+ def cmd_catalog_show(args: argparse.Namespace) -> int:
244
+ from . import catalog
245
+
246
+ rec = args.rec
247
+ entry = catalog.load(rec[0].upper()).get(rec)
248
+ if entry is None:
249
+ print(f"{rec}: not in the catalogue. Run: primarysource itu catalog sync {rec[0].upper()}", file=sys.stderr)
250
+ return 1
251
+ print(f"{rec} [{entry.get('status')}] {entry.get('title')}")
252
+ if entry.get("note"):
253
+ print(f" note: {entry['note']}")
254
+ components = entry.get("components")
255
+ if components is None:
256
+ print(f" no details yet. Run: primarysource itu catalog sync {rec[0].upper()} --details")
257
+ return 0
258
+ for component in components: # type: ignore[attr-defined]
259
+ assets = component.get("assets") or []
260
+ formats = " ".join(f"{a['fmt']}{'' if a['access'] == 'free' else '*'}" for a in assets)
261
+ head = f"{component['section']:10} {component['kind']:11} {component['label']:42}"
262
+ print(f" {head} {component['status']:24} {formats}")
263
+ return 0
264
+
265
+
266
+ def cmd_links(args: argparse.Namespace) -> int:
267
+ edition, number, _para = _resolve(_join(args.ref))
268
+ return _print_links(edition, number)
269
+
270
+
271
+ def cmd_grep(args: argparse.Namespace) -> int:
272
+ edition = sources.resolve(args.edition)
273
+ hits, more = index.grep(edition, args.pattern, start=args.start, max_tokens=args.max_tokens)
274
+ for hit in hits:
275
+ print(f"{index.citation(edition, hit.number)}\n {hit.excerpt}\n")
276
+ if more is not None:
277
+ print(f"[cut by --max-tokens; continue with --start {more}, or narrow the pattern]", file=sys.stderr)
278
+ return 0 if hits else 1
279
+
280
+
281
+ def cmd_browse(args: argparse.Namespace) -> int:
282
+ if not args.path:
283
+ registered = {item.rec.split(".")[0] for item in sources.IN_FORCE}
284
+ for letter, title in sources.SERIES.items():
285
+ print(f"{letter} {'*' if letter in registered else ' '} {title}")
286
+ print("\n* = has registered recommendations")
287
+ return 0
288
+ if len(args.path) == 1:
289
+ for item in sources.IN_FORCE:
290
+ if item.rec.startswith(args.path.upper() + "."):
291
+ print(f"{item.rec:8} {'fetched' if corpus.has(item) else '-':8} {item.title}")
292
+ return 0
293
+ for number, title in index.top_level(sources.resolve(args.path), args.depth):
294
+ print(f"{number:10} {title}")
295
+ return 0
296
+
297
+
298
+ def cmd_errata(args: argparse.Namespace) -> int:
299
+ items = [a for a in sources.AMENDMENTS if a.rec == args.rec]
300
+ if not items:
301
+ print(f"{args.rec}: nothing registered", file=sys.stderr)
302
+ return 1
303
+ for item in items:
304
+ state = "fetched" if item.artifact and corpus.has(item) else ("not fetched" if item.artifact else "no id")
305
+ print(f"{item.key} [{item.status}; {state}]\n {item.label}")
306
+ return 0
307
+
308
+
309
+ def cmd_mcp(_args: argparse.Namespace) -> int:
310
+ from . import mcp
311
+
312
+ try:
313
+ mcp.require_extra()
314
+ except ImportError as error:
315
+ print(error, file=sys.stderr)
316
+ return 2
317
+ mcp.main()
318
+ return 0
319
+
320
+
321
+ def build_parser() -> argparse.ArgumentParser:
322
+ parser = argparse.ArgumentParser(
323
+ prog="primarysource itu", description=__doc__, formatter_class=argparse.RawTextHelpFormatter
324
+ )
325
+ parser.add_argument("--version", action="version", version=f"primarysource itu {__version__}")
326
+ sub = parser.add_subparsers(dest="command", required=True)
327
+
328
+ def add(name: str, handler: Callable[[argparse.Namespace], int], help_text: str) -> argparse.ArgumentParser:
329
+ command = sub.add_parser(name, help=help_text, description=help_text)
330
+ command.set_defaults(handler=handler)
331
+ return command
332
+
333
+ add("info", cmd_info, "registered editions, amendments and what is fetched")
334
+
335
+ fetch = add("fetch", cmd_fetch, "download one edition or erratum into ITULIB_HOME and verify its digest")
336
+ fetch.add_argument("target", nargs="?", default=DEFAULT, help="edition or erratum key (default X.690)")
337
+ fetch.add_argument("--refresh", action="store_true", help="download again even if present")
338
+
339
+ sync = add("sync", cmd_sync, "fetch and index every in-force edition (or those named)")
340
+ sync.add_argument("targets", nargs="*", help="editions to sync (default: every in-force edition)")
341
+ sync.add_argument("--all", action="store_true", help="include superseded editions that have a digest")
342
+
343
+ idx = add("index", cmd_index, "build the clause index for a fetched edition")
344
+ idx.add_argument("edition", nargs="?", default=DEFAULT)
345
+
346
+ search = add("search", cmd_search, "full-text clause search")
347
+ search.add_argument("edition")
348
+ search.add_argument("query", nargs="+")
349
+ search.add_argument("--limit", type=int, default=8)
350
+ search.add_argument("--start", type=int, default=0, help="0-based offset of the first hit (paging)")
351
+
352
+ read = add("read", cmd_read, "print a clause (or one sentence, or an erratum) with its citation")
353
+ read.add_argument("ref", nargs="+", help="X.690§11.6, X.690 11.6¶2, X.690§11.6¶5-, 11.6, or an erratum key")
354
+ read.add_argument("--start", type=int, default=1, help="first sentence to print (paging)")
355
+ read.add_argument("--max-tokens", type=int, default=2000)
356
+ read.add_argument("--view", choices=["text", "normative", "refs"], default="text")
357
+
358
+ cite = add("cite", cmd_cite, "the citation string for a clause or sentence")
359
+ cite.add_argument("ref", nargs="+")
360
+
361
+ compare = add("compare", cmd_compare, "what changed in a clause between two editions")
362
+ compare.add_argument("number")
363
+ compare.add_argument("edition_a", nargs="?", default="X.690#2008-11")
364
+ compare.add_argument("edition_b", nargs="?", default="X.690#2021-02")
365
+
366
+ links = add("links", cmd_links, "what a clause points at, and what points at it")
367
+ links.add_argument("ref", nargs="+")
368
+
369
+ grep = add("grep", cmd_grep, "regular expression over clause bodies")
370
+ grep.add_argument("edition")
371
+ grep.add_argument("pattern")
372
+ grep.add_argument("--max-tokens", type=int, default=1500)
373
+ grep.add_argument("--start", type=int, default=0, help="0-based offset of the first matching clause")
374
+
375
+ browse = add("browse", cmd_browse, "ITU-T series, a series' recommendations, or a recommendation's clauses")
376
+ browse.add_argument("path", nargs="?", default="")
377
+ browse.add_argument("--depth", type=int, default=2)
378
+
379
+ errata = add("errata", cmd_errata, "errata, corrigenda and amendments registered for a recommendation")
380
+ errata.add_argument("rec", nargs="?", default=DEFAULT)
381
+
382
+ catalog = add("catalog", lambda _args: 0, "the ITU-T catalogue: what exists and in which formats (metadata only)")
383
+ catalog_sub = catalog.add_subparsers(dest="catalog_command", required=True)
384
+
385
+ def add_catalog(
386
+ name: str, handler: Callable[[argparse.Namespace], int], help_text: str
387
+ ) -> argparse.ArgumentParser:
388
+ command = catalog_sub.add_parser(name, help=help_text, description=help_text)
389
+ command.set_defaults(handler=handler)
390
+ return command
391
+
392
+ csync = add_catalog(
393
+ "sync", cmd_catalog_sync, "read series listings from itu.int (1 request/second, cached under ITULIB_HOME)"
394
+ )
395
+ csync.add_argument("series", nargs="*", default=["X"], help="series letters, or 'all' (default: X)")
396
+ csync.add_argument("--details", action="store_true", help="also read each recommendation's pages (resumable)")
397
+ csync.add_argument("--limit", type=int, help="stop after this many recommendations (with --details)")
398
+ csync.add_argument(
399
+ "--rec", nargs="*", help="only these recommendations, for example X.509 X.690 (with --details)"
400
+ )
401
+ csync.add_argument("--include-withdrawn", action="store_true")
402
+ csync.add_argument("--refresh", action="store_true", help="ignore the page cache and fetch again")
403
+ csync.add_argument("--reparse", action="store_true", help="rebuild details from cached pages (no requests)")
404
+
405
+ clist = add_catalog("list", cmd_catalog_list, "in-force editions with their available formats")
406
+ clist.add_argument("--series", default="X")
407
+ clist.add_argument("--text", default="", help="substring of number or title")
408
+ clist.add_argument("--format", default="", help="only editions offering this format id (PDF, EPB, MSW, TOC-HTM)")
409
+ clist.add_argument("--free", action="store_true", help="count only freely available assets for --format")
410
+ clist.add_argument("--limit", type=int, default=50)
411
+
412
+ cshow = add_catalog("show", cmd_catalog_show, "every component of one recommendation, with formats")
413
+ cshow.add_argument("rec", help="for example X.509")
414
+
415
+ add("mcp", cmd_mcp, "serve the MCP tools over stdio (needs the mcp extra)")
416
+ return parser
417
+
418
+
419
+ def main(argv: list[str] | None = None) -> int:
420
+ # The corpus is full of characters cp1252 has no room for: X.680 §47.3 contains U+2212 MINUS SIGN, and
421
+ # printing that clause killed the command with UnicodeEncodeError on a redirected Windows console. A tool
422
+ # whose whole job is to hand back clause text cannot be allowed to die on a minus sign, so stdout is
423
+ # UTF-8 with replacement rather than whatever the console's code page happens to be.
424
+ sys.stdout.reconfigure(encoding="utf-8", errors="replace") # type: ignore[union-attr]
425
+ args = build_parser().parse_args(argv)
426
+ # A missing corpus is a known state with a known remedy, so it is reported as one rather than as a
427
+ # traceback: a stack ending in CorpusMissing reads like a crash, an `action:` line reads as instructions.
428
+ try:
429
+ return int(args.handler(args))
430
+ except LibError as error:
431
+ print(error.as_text(), file=sys.stderr)
432
+ return 3
433
+ except KeyError as error:
434
+ print(error.args[0], file=sys.stderr)
435
+ return 2
436
+ except ValueError as error:
437
+ print(error, file=sys.stderr)
438
+ return 2
439
+ except RuntimeError as error:
440
+ print(str(error), file=sys.stderr)
441
+ return 3
442
+
443
+
444
+ if __name__ == "__main__":
445
+ raise SystemExit(main())
@@ -0,0 +1,20 @@
1
+ """ITU-T recommendations as a primarysource collection. Importing this needs nothing beyond the core."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from primarysource.model import Unit
6
+ from primarysource.refs import RefGrammar
7
+ from primarysource.registry import Collection
8
+
9
+ # itulib's refs: X.690§11.6¶2, with an edition key after the number (X.690#2008-11§8.1). ¶n counts sentences of
10
+ # the extracted clause. The tools already carry the itu_ prefix.
11
+ COLLECTION = Collection(
12
+ name="itu",
13
+ title="ITU-T recommendations (the ASN.1 family X.680-X.697), fetched by the user",
14
+ prefix="itu_",
15
+ unit=Unit.SENTENCE,
16
+ grammar=RefGrammar(r"[A-Z]\.\d+(?:\.\d+)?(?:#[\d-]+)?"),
17
+ server="primarysource.itu.mcp:build",
18
+ cli="primarysource.itu.cli:main",
19
+ identify="primarysource.itu.identify:identify",
20
+ )
@@ -0,0 +1,118 @@
1
+ """The corpus lives on the user's machine, and this repository never carries it.
2
+
3
+ What decides the design is the notice printed on page iii of the recommendation itself: ITU reserves all
4
+ rights and permits no part of the publication to be reproduced by any means without its prior written
5
+ permission. That is quoted here in paraphrase rather than verbatim for the same reason the text is not
6
+ vendored, and `tests/test_license.py` fails if a clause sentence ever appears in this tree.
7
+
8
+ So `fetch` is a command the user runs, the download lands outside the checkout by default, and a clone of this
9
+ repository contains code, clause arithmetic and pinned digests - never spec text. The tripwire exists because
10
+ the tempting way to make a search test pass is to commit the PDF, and that is the one move a git history
11
+ cannot walk back.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import hashlib
17
+ import json
18
+ import os
19
+ import pathlib
20
+ import time
21
+ import urllib.request
22
+
23
+ from primarysource.errors import LibError
24
+
25
+ from .sources import Amendment, Edition
26
+
27
+ Document = Edition | Amendment
28
+
29
+ # itulib's own default, kept so an existing library is found after the move into primarysource.
30
+ DEFAULT_HOME = pathlib.Path.home() / ".itulib"
31
+
32
+
33
+ def home() -> pathlib.Path:
34
+ """`$ITULIB_HOME`, else an existing `~/.itulib`, else `primarysource.home.collection_home("itu")`
35
+ (`$PRIMARYSOURCE_HOME/itu` or the per-user `primarysource/itu`)."""
36
+ override = os.environ.get("ITULIB_HOME")
37
+ if override:
38
+ return pathlib.Path(override)
39
+ if DEFAULT_HOME.is_dir() and not os.environ.get("PRIMARYSOURCE_HOME"):
40
+ return DEFAULT_HOME
41
+ from primarysource.home import collection_home
42
+
43
+ return collection_home("itu")
44
+
45
+
46
+ def pdf_path(edition: Document) -> pathlib.Path:
47
+ return home() / "corpus" / f"{edition.key.replace('#', '-').replace('!', '-')}.pdf"
48
+
49
+
50
+ def manifest_path() -> pathlib.Path:
51
+ return home() / "manifest.json"
52
+
53
+
54
+ def _record() -> dict[str, dict[str, object]]:
55
+ path = manifest_path()
56
+ if not path.is_file():
57
+ return {}
58
+ loaded = json.loads(path.read_text(encoding="utf-8"))
59
+ return loaded if isinstance(loaded, dict) else {}
60
+
61
+
62
+ def has(edition: Document) -> bool:
63
+ """Present and, if a digest is registered, byte-identical to it."""
64
+ path = pdf_path(edition)
65
+ if not path.is_file():
66
+ return False
67
+ if not edition.sha256:
68
+ return True
69
+ return hashlib.sha256(path.read_bytes()).hexdigest() == edition.sha256
70
+
71
+
72
+ def require(edition: Document) -> pathlib.Path:
73
+ """The local PDF, or an error naming the command that produces it."""
74
+ path = pdf_path(edition)
75
+ if not has(edition):
76
+ raise CorpusMissing(
77
+ f"{edition.key} is not in {home()}. The file is copyright ITU and is fetched to your machine rather "
78
+ "than shipped with this package, so there is no way to obtain it from the repository.",
79
+ "itu_info shows which editions are fetched",
80
+ f"primarysource itu fetch {edition.key}",
81
+ )
82
+ return path
83
+
84
+
85
+ class CorpusMissing(LibError, RuntimeError):
86
+ """The corpus has not been fetched. The remedy is a command (`action`), never a committed file."""
87
+
88
+
89
+ def fetch(edition: Document, *, refresh: bool = False) -> pathlib.Path:
90
+ """Download one edition, verify it against the registered digest, and record what arrived."""
91
+ path = pdf_path(edition)
92
+ if has(edition) and not refresh:
93
+ return path
94
+ url = edition.pdf_url
95
+ if url is None:
96
+ raise RuntimeError(f"{edition.key}: no download id is registered, so there is nothing to fetch")
97
+ path.parent.mkdir(parents=True, exist_ok=True)
98
+ request = urllib.request.Request(url, headers={"User-Agent": "itulib-fetch"}) # noqa: S310
99
+ with urllib.request.urlopen(request, timeout=300) as response: # noqa: S310
100
+ body = response.read()
101
+ if not body.startswith(b"%PDF"):
102
+ raise RuntimeError(f"{edition.key}: server returned {len(body)} bytes that are not a PDF")
103
+ digest = hashlib.sha256(body).hexdigest()
104
+ if edition.sha256 and digest != edition.sha256:
105
+ raise RuntimeError(
106
+ f"{edition.key}: digest {digest} does not match the registered {edition.sha256}. "
107
+ f"ITU may have re-published the edition; re-verify before trusting any citation from it."
108
+ )
109
+ path.write_bytes(body)
110
+ records = _record()
111
+ records[edition.key] = {
112
+ "sha256": digest,
113
+ "octets": len(body),
114
+ "fetched_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
115
+ "source": url,
116
+ }
117
+ manifest_path().write_text(json.dumps(records, indent=2), encoding="utf-8")
118
+ return path
@@ -0,0 +1,16 @@
1
+ """The text of an erratum, from the fetched covering note. Not indexed: it has no clause structure."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from . import corpus, text
6
+ from .sources import Amendment
7
+
8
+
9
+ def errata_text(amendment: Amendment) -> str:
10
+ """Extracted text of a fetched erratum, or CorpusMissing naming the fetch command.
11
+
12
+ The covering note is a figure replacement, so the extract carries the instruction and the figure caption
13
+ but not the figure itself; the PDF is the authority for what the corrected figure shows.
14
+ """
15
+ pdf = corpus.require(amendment)
16
+ return text.tidy(" ".join(text.pages(pdf)))