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,472 @@
1
+ """The ITU-T catalogue: which recommendations exist, which editions are in force, and in which formats.
2
+
3
+ ITU publishes no bulk download and no machine-readable index of recommendations, so this reads the same public
4
+ HTML pages a person would, politely: one request per second, an identifying User-Agent, a disk cache, and only
5
+ paths that robots.txt does not disallow (`/rec/`). Three page types matter:
6
+
7
+ - the **series listing** (`/rec/T-REC-X/en`): every recommendation number with its title and, for withdrawn
8
+ ones, a `[Withdrawn]` marker;
9
+ - the **recommendation page** (`/rec/T-REC-X.509/en`): its components, in two sections ("In force components",
10
+ "Superseded and Withdrawn components"), each an edition, corrigendum, amendment, erratum or collective file;
11
+ - the **component page** (`/rec/T-REC-X.509-201910-I/en`): the assets of one component, each with a format,
12
+ language, size, posting date and an access class (freely available, or TIES users only).
13
+
14
+ Only metadata is kept: numbers, titles, statuses, sizes, dates and URLs. No recommendation text, and nothing is
15
+ written into the repository: everything lives under `ITULIB_HOME/catalog/`. The parsers are pure functions of
16
+ HTML so they are tested on invented pages; the shapes they expect were read from live pages on 2026-11-07, and a
17
+ page that no longer matches yields an empty result that `sync` reports, never a silent partial catalogue.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import hashlib
23
+ import html
24
+ import json
25
+ import re
26
+ import time
27
+ import urllib.error
28
+ import urllib.request
29
+ from collections.abc import Callable, Iterable
30
+ from dataclasses import asdict, dataclass, field
31
+ from pathlib import Path
32
+
33
+ from . import __version__, corpus
34
+
35
+ BASE = "https://www.itu.int"
36
+ USER_AGENT = f"itulib-catalog/{__version__} (+https://github.com/b1tninja/itulib)"
37
+ MIN_INTERVAL = 1.0
38
+
39
+ # Asset format ids as they appear in download links, with what they are.
40
+ FORMATS = {
41
+ "PDF": "PDF (acrobat)",
42
+ "EPB": "EPUB",
43
+ "MSW": "Word 2007",
44
+ "ZWD": "zip of Word files",
45
+ "SUM-HTM": "summary (HTML)",
46
+ "TOC-HTM": "table of contents (HTML)",
47
+ }
48
+
49
+
50
+ @dataclass(frozen=True)
51
+ class Listing:
52
+ rec: str # "X.509"
53
+ title: str
54
+ status: str # "active" | "withdrawn" | other marker, lowercased
55
+ note: str = ""
56
+
57
+
58
+ @dataclass(frozen=True)
59
+ class Component:
60
+ id: str # "T-REC-X.509-201910-I", "T-REC-X.509-200003-S!Cor1"
61
+ label: str # "X.509 (10/19)"
62
+ kind: str # edition | corrigendum | amendment | erratum | supplement | collection | other
63
+ status: str # "in force" | "superseded" | "withdrawn" | as printed, lowercased
64
+ section: str # "in force" | "superseded"
65
+ title: str = ""
66
+
67
+
68
+ @dataclass(frozen=True)
69
+ class Asset:
70
+ fmt: str # "PDF", "EPB", "MSW", "ZWD", "SUM-HTM", "TOC-HTM"
71
+ lang: str # "E", "F", "S", ...
72
+ url: str
73
+ size: int | None # octets, when the page prints one
74
+ posted: str # ISO date, or ""
75
+ access: str # "free" | "ties" | "unknown"
76
+
77
+
78
+ @dataclass(frozen=True)
79
+ class ComponentPage:
80
+ id: str
81
+ title: str
82
+ status: str
83
+ approved: str
84
+ assets: tuple[Asset, ...] = field(default_factory=tuple)
85
+
86
+
87
+ # --- parsing ------------------------------------------------------------------------------------------------
88
+
89
+ _TAG = re.compile(r"<[^>]+>")
90
+ _SPACE = re.compile(r"\s+")
91
+ _ROW = re.compile(r"<tr\b[^>]*>(.*?)</tr>", re.S | re.I)
92
+ _CELLS = re.compile(r"<td\b[^>]*>(.*?)</td>", re.S | re.I)
93
+ _REC_LINK = re.compile(r'href="\./recommendation\.asp\?lang=en&amp;parent=(T-REC-[^"&]+)"', re.I)
94
+ _COMPONENT = re.compile(r"^T-REC-([A-Z]\.[\w]+(?:-[A-Z]\.[\w]+)?)-(\d{6})-([A-Z])(?:!(.+))?$")
95
+ _DOWNLOAD = re.compile(r'href="([^"]*dologin_pub\.asp\?[^"]*?id=([^"&]+)[^"]*)"', re.I)
96
+ _STATIC = re.compile(r'href="(/dms_pubrec/[^"]+!!(SUM-HTM|TOC-HTM)-([A-Z])\.htm)"', re.I)
97
+
98
+
99
+ def text_of(fragment: str) -> str:
100
+ cleaned = html.unescape(_TAG.sub(" ", fragment)).replace("�", " ").replace("\xa0", " ")
101
+ return _SPACE.sub(" ", cleaned).strip()
102
+
103
+
104
+ def decode(body: bytes) -> str:
105
+ """ITU's pages are a mix of UTF-8 and cp1252. Try the strict one first so the fallback is never silent."""
106
+ try:
107
+ return body.decode("utf-8")
108
+ except UnicodeDecodeError:
109
+ return body.decode("cp1252", errors="replace")
110
+
111
+
112
+ def parse_series(page: str) -> list[Listing]:
113
+ """One entry per recommendation number on a series listing page."""
114
+ found: list[Listing] = []
115
+ for row in _ROW.findall(page):
116
+ link = _REC_LINK.search(row)
117
+ cells = _CELLS.findall(row)
118
+ if not link or len(cells) < 2 or _COMPONENT.match(link.group(1)): # no link, or a component row
119
+ continue
120
+ ident = link.group(1).removeprefix("T-REC-")
121
+ rendered = text_of(cells[1])
122
+ marker = re.match(r"\[([^\]]+)\]\s*", rendered)
123
+ status = marker.group(1).lower() if marker else "active"
124
+ rendered = rendered[marker.end() :] if marker else rendered
125
+ note = ""
126
+ if "<br" in cells[1].lower():
127
+ head, _, tail = cells[1].partition("<br")
128
+ rendered = text_of(head).removeprefix(marker.group(0)) if marker else text_of(head)
129
+ note = text_of("<br" + tail)
130
+ found.append(Listing(rec=ident, title=rendered, status=status, note=note))
131
+ return found
132
+
133
+
134
+ def _kind(suffix: str | None, collection: bool) -> str:
135
+ if collection:
136
+ return "collection"
137
+ if not suffix:
138
+ return "edition"
139
+ lowered = suffix.lower()
140
+ for word, kind in (("cor", "corrigendum"), ("amd", "amendment"), ("err", "erratum"), ("sup", "supplement")):
141
+ if lowered.startswith(word):
142
+ return kind
143
+ return "other"
144
+
145
+
146
+ def parse_recommendation(page: str) -> list[Component]:
147
+ """The components listed on a recommendation page, with the section each one sits under."""
148
+ out: list[Component] = []
149
+ section = "in force"
150
+ for row in _ROW.findall(page):
151
+ header = text_of(row).lower()
152
+ if 'colspan="3"' in row.lower() or "colspan='3'" in row.lower():
153
+ section = "superseded" if "superseded" in header or "withdrawn" in header else "in force"
154
+ continue
155
+ link = _REC_LINK.search(row)
156
+ cells = _CELLS.findall(row)
157
+ if not link or len(cells) < 3:
158
+ continue
159
+ match = _COMPONENT.match(link.group(1))
160
+ if not match:
161
+ continue
162
+ status = text_of(cells[2]).lower()
163
+ out.append(
164
+ Component(
165
+ id=link.group(1),
166
+ label=text_of(cells[0]),
167
+ kind=_kind(match.group(4), "-" in match.group(1)),
168
+ status=status,
169
+ section=section,
170
+ title=text_of(cells[1]),
171
+ )
172
+ )
173
+ return out
174
+
175
+
176
+ def _size(near: str) -> int | None:
177
+ m = re.search(r"(\d[\d,]*)\s*bytes", near)
178
+ return int(m.group(1).replace(",", "")) if m else None
179
+
180
+
181
+ def parse_component(page: str, component_id: str) -> ComponentPage:
182
+ """Title, status, approval date and every downloadable asset of one component page."""
183
+ plain = text_of(re.sub(r"<script.*?</script>|<style.*?</style>", " ", page, flags=re.S | re.I))
184
+ status = re.search(r"Status\s*:\s*([A-Za-z ]+?)(?:\s{2,}|\s+(?:Table|Summary|Access)\b|$)", plain)
185
+ approved = re.search(r"Approved in\s*(\d{4}-\d{2}-\d{2})", plain)
186
+ title = re.search(r"<title>(.*?)</title>", page, re.S | re.I)
187
+ # Access labels in document order ("Access : Freely available items", "Access : TIES users"). The label's
188
+ # words can sit outside the tag that holds "Access :", so classify the text that follows it, not the tag.
189
+ labels = [
190
+ (m.start(), _access_class(text_of(page[m.end() : m.end() + 240])[:40]))
191
+ for m in re.finditer(r"Access\s*:", page)
192
+ ]
193
+ assets: list[Asset] = []
194
+ for m in _DOWNLOAD.finditer(page):
195
+ # editions use "!!PDF-E", corrigenda/amendments/errata a single "!" ("...!Err1!PDF-E")
196
+ parsed = re.search(r"!([A-Z]+(?:-[A-Z]+)?)-([A-Z])$", m.group(2))
197
+ if not parsed:
198
+ continue
199
+ # The size and posting date are cells of the same table row as the download link, before it in some
200
+ # tables and after it in others, so read the whole enclosing row.
201
+ stop = page.find("</tr>", m.end())
202
+ row = text_of(page[max(page.rfind("<tr", 0, m.start()), 0) : stop if stop > 0 else m.end()])
203
+ date = re.search(r"(\d{4}-\d{2}-\d{2})", row)
204
+ before = [kind for pos, kind in labels if pos < m.start()]
205
+ assets.append(
206
+ Asset(
207
+ fmt=parsed.group(1),
208
+ lang=parsed.group(2),
209
+ url=_absolute(html.unescape(m.group(1)).strip()),
210
+ size=_size(row),
211
+ posted=date.group(1) if date else "",
212
+ access=before[-1] if before else "unknown",
213
+ )
214
+ )
215
+ for m in _STATIC.finditer(page):
216
+ assets.append(Asset(m.group(2), m.group(3), _absolute(m.group(1)), None, "", "free"))
217
+ unique = {(a.fmt, a.lang, a.url): a for a in assets}
218
+ return ComponentPage(
219
+ id=component_id,
220
+ title=text_of(title.group(1)) if title else "",
221
+ status=status.group(1).strip().lower() if status else "",
222
+ approved=approved.group(1) if approved else "",
223
+ assets=tuple(unique.values()),
224
+ )
225
+
226
+
227
+ def _access_class(label: str) -> str:
228
+ lowered = label.lower()
229
+ if "free" in lowered:
230
+ return "free"
231
+ if "ties" in lowered:
232
+ return "ties"
233
+ return "unknown"
234
+
235
+
236
+ def _absolute(href: str) -> str:
237
+ return href if href.startswith("http") else BASE + (href if href.startswith("/") else "/rec/" + href)
238
+
239
+
240
+ # --- fetching -----------------------------------------------------------------------------------------------
241
+
242
+
243
+ class Fetcher:
244
+ """GET with a minimum interval between requests, an identifying User-Agent and a disk cache.
245
+
246
+ `opener` and the clock are injectable so the pacing and the cache are tested without a network.
247
+ """
248
+
249
+ def __init__(
250
+ self,
251
+ cache: Path | None = None,
252
+ *,
253
+ interval: float = MIN_INTERVAL,
254
+ opener: Callable[[urllib.request.Request], bytes] | None = None,
255
+ sleep: Callable[[float], None] = time.sleep,
256
+ clock: Callable[[], float] = time.monotonic,
257
+ ) -> None:
258
+ self.cache = cache
259
+ self.interval = interval
260
+ self._opener = opener or self._open
261
+ self._sleep = sleep
262
+ self._clock = clock
263
+ self._last = float("-inf")
264
+ self.requests = 0
265
+
266
+ @staticmethod
267
+ def _open(request: urllib.request.Request) -> bytes:
268
+ with urllib.request.urlopen(request, timeout=60) as response: # noqa: S310
269
+ return bytes(response.read())
270
+
271
+ def get(self, url: str, *, refresh: bool = False) -> str:
272
+ if not url.startswith(BASE + "/rec/") and not url.startswith(BASE + "/dms_pubrec/"):
273
+ raise ValueError(f"refusing to fetch {url}: the catalogue reads only /rec/ and /dms_pubrec/")
274
+ slot = self.cache / (hashlib.sha256(url.encode()).hexdigest()[:24] + ".html") if self.cache else None
275
+ if slot is not None and slot.is_file() and not refresh:
276
+ return decode(slot.read_bytes())
277
+ wait = self.interval - (self._clock() - self._last)
278
+ if wait > 0:
279
+ self._sleep(wait)
280
+ self._last = self._clock()
281
+ self.requests += 1
282
+ body = self._opener(urllib.request.Request(url, headers={"User-Agent": USER_AGENT})) # noqa: S310
283
+ if slot is not None:
284
+ slot.parent.mkdir(parents=True, exist_ok=True)
285
+ slot.write_bytes(body)
286
+ return decode(body)
287
+
288
+
289
+ # --- manifest -----------------------------------------------------------------------------------------------
290
+
291
+
292
+ def catalog_dir() -> Path:
293
+ return corpus.home() / "catalog"
294
+
295
+
296
+ def _manifest(series: str) -> Path:
297
+ return catalog_dir() / f"series-{series}.json"
298
+
299
+
300
+ def series_url(letter: str) -> str:
301
+ return f"{BASE}/rec/T-REC-{letter}/en"
302
+
303
+
304
+ def load(series: str) -> dict[str, dict[str, object]]:
305
+ path = _manifest(series)
306
+ if not path.is_file():
307
+ raise corpus.CorpusMissing(
308
+ f"no catalogue for series {series}",
309
+ "",
310
+ f"primarysource itu catalog sync {series} --details",
311
+ )
312
+ loaded = json.loads(path.read_text(encoding="utf-8"))
313
+ recs = loaded.get("recs", {}) if isinstance(loaded, dict) else {}
314
+ return recs if isinstance(recs, dict) else {}
315
+
316
+
317
+ def _save(series: str, recs: dict[str, dict[str, object]]) -> None:
318
+ path = _manifest(series)
319
+ path.parent.mkdir(parents=True, exist_ok=True)
320
+ stamp = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
321
+ path.write_text(json.dumps({"series": series, "updated": stamp, "recs": recs}, indent=1), encoding="utf-8")
322
+
323
+
324
+ def sync_listing(letter: str, fetcher: Fetcher, *, refresh: bool = False) -> int:
325
+ """Read the series listing and merge it into the manifest, keeping any details already fetched."""
326
+ listings = parse_series(fetcher.get(series_url(letter), refresh=refresh))
327
+ if not listings:
328
+ raise RuntimeError(
329
+ f"series {letter}: the listing page parsed to 0 recommendations. The page layout probably changed; "
330
+ f"refusing to write an empty catalogue."
331
+ )
332
+ try:
333
+ recs = load(letter)
334
+ except corpus.CorpusMissing:
335
+ recs = {}
336
+ for item in listings:
337
+ entry = recs.setdefault(item.rec, {})
338
+ entry.update(asdict(item))
339
+ _save(letter, recs)
340
+ return len(listings)
341
+
342
+
343
+ def sync_details(
344
+ letter: str,
345
+ fetcher: Fetcher,
346
+ *,
347
+ limit: int | None = None,
348
+ include_withdrawn: bool = False,
349
+ refresh: bool = False,
350
+ reparse: bool = False,
351
+ only: Iterable[str] | None = None,
352
+ progress: Callable[[str], None] = lambda _message: None,
353
+ ) -> tuple[int, int]:
354
+ """Fetch the components, and the assets of the in-force ones, for recommendations without details yet.
355
+
356
+ Resumable: a recommendation with details is skipped unless `refresh` (also ignore the page cache) or
357
+ `reparse` (redo the details from the cached pages, with no requests: for after a parser change). The
358
+ manifest is saved after each one, so an interrupted run loses nothing. Returns (recommendations done,
359
+ component pages read).
360
+ """
361
+ recs = load(letter)
362
+ wanted = set(only) if only is not None else None
363
+ done = pages = 0
364
+ for rec, entry in list(recs.items()):
365
+ if wanted is not None and rec not in wanted:
366
+ continue
367
+ if entry.get("status") != "active" and not include_withdrawn:
368
+ continue
369
+ if entry.get("components") is not None and not (refresh or reparse):
370
+ continue
371
+ if limit is not None and done >= limit:
372
+ break
373
+ components = parse_recommendation(fetcher.get(f"{BASE}/rec/T-REC-{rec}/en", refresh=refresh))
374
+ details: list[dict[str, object]] = []
375
+ for component in components:
376
+ record: dict[str, object] = asdict(component)
377
+ if component.section == "in force" and component.kind != "collection":
378
+ page = parse_component(fetcher.get(f"{BASE}/rec/{component.id}/en", refresh=refresh), component.id)
379
+ pages += 1
380
+ record.update(approved=page.approved, assets=[asdict(a) for a in page.assets])
381
+ details.append(record)
382
+ entry["components"] = details
383
+ _save(letter, recs)
384
+ done += 1
385
+ progress(f"{rec}: {len(components)} components")
386
+ return done, pages
387
+
388
+
389
+ def flatten(recs: dict[str, dict[str, object]]) -> Iterable[tuple[str, dict[str, object], dict[str, object]]]:
390
+ """(rec, recommendation entry, component) for every component that has details."""
391
+ for rec, entry in recs.items():
392
+ components = entry.get("components")
393
+ if isinstance(components, list):
394
+ for component in components:
395
+ if isinstance(component, dict):
396
+ yield rec, entry, component
397
+
398
+
399
+ def assets_of(component: dict[str, object]) -> list[dict[str, object]]:
400
+ """The asset records of a component, or [] when its assets were not fetched."""
401
+ raw = component.get("assets")
402
+ return [a for a in raw if isinstance(a, dict)] if isinstance(raw, list) else []
403
+
404
+
405
+ def best_source(component: dict[str, object]) -> dict[str, object]:
406
+ """Which assets to use for one in-force component, in order of how much structure they carry.
407
+
408
+ EPUB carries the document's own structure but is rare; the PDF is nearly always there and free; the static
409
+ table-of-contents page is an independent check on the PDF's bookmarks. Word is never preferred: it is
410
+ TIES-only for most documents.
411
+ """
412
+ assets = assets_of(component)
413
+ free = [a for a in assets if a.get("access") == "free" and a.get("lang") == "E"]
414
+ by_fmt = {str(a["fmt"]): a for a in free}
415
+ return {
416
+ "primary": by_fmt.get("EPB") or by_fmt.get("PDF"),
417
+ "pdf": by_fmt.get("PDF"),
418
+ "epub": by_fmt.get("EPB"),
419
+ "toc": by_fmt.get("TOC-HTM"),
420
+ "summary": by_fmt.get("SUM-HTM"),
421
+ }
422
+
423
+
424
+ @dataclass(frozen=True)
425
+ class Row:
426
+ rec: str
427
+ title: str
428
+ component: str
429
+ label: str
430
+ kind: str # edition | corrigendum | amendment | erratum | supplement | other
431
+ approved: str
432
+ formats: tuple[str, ...] # format ids; a trailing * marks one that is not freely available
433
+
434
+ def summary(self) -> str:
435
+ return " ".join(self.formats)
436
+
437
+
438
+ def rows(
439
+ recs: dict[str, dict[str, object]],
440
+ *,
441
+ text: str = "",
442
+ fmt: str = "",
443
+ status: str = "in force",
444
+ free_only: bool = False,
445
+ ) -> list[Row]:
446
+ """One row per in-force component with details (editions, and their corrigenda, amendments and errata)."""
447
+ wanted = text.lower()
448
+ found: list[Row] = []
449
+ for rec, entry, component in flatten(recs):
450
+ # Collective files ("X.680-X.693") are Word archives of several recommendations, not a document of this one.
451
+ if component.get("section") != "in force" or component.get("kind") == "collection":
452
+ continue
453
+ if status and not str(component.get("status", "")).startswith(status):
454
+ continue
455
+ assets = assets_of(component)
456
+ if fmt and not any(a.get("fmt") == fmt and (a.get("access") == "free" or not free_only) for a in assets):
457
+ continue
458
+ haystack = f"{rec} {entry.get('title', '')} {component.get('label', '')}".lower()
459
+ if wanted and wanted not in haystack:
460
+ continue
461
+ found.append(
462
+ Row(
463
+ rec=rec,
464
+ title=str(entry.get("title", "")),
465
+ component=str(component["id"]),
466
+ label=str(component.get("label", "")),
467
+ kind=str(component.get("kind", "")),
468
+ approved=str(component.get("approved", "")),
469
+ formats=tuple(sorted({f"{a['fmt']}{'' if a.get('access') == 'free' else '*'}" for a in assets})),
470
+ )
471
+ )
472
+ return found