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
primarysource/index.py ADDED
@@ -0,0 +1,446 @@
1
+ """The keyword index every collection shares: SQLite FTS5, ranked by BM25 (PLAN.md §3, §5.7).
2
+
3
+ One database per collection. Its shape:
4
+
5
+ - `documents`: one row per document (id, title, declared unit, section count).
6
+ - `sections`: one row per section, in reading order, with the section's units joined into a body.
7
+ - `sections_fts`: an external-content FTS5 table over `sections` (document title, section title, body), kept in
8
+ step by triggers, tokenized `porter unicode61` so `encoded` finds `encoding` and `resume` finds `résumé`.
9
+
10
+ How it is opened is the point of most of this module:
11
+
12
+ - **Readers** (`open_read`) open `?mode=ro` and never create or migrate anything, so a shared server can run on a
13
+ read-only volume and can never damage the index it serves.
14
+ - **Writers** (`open_write`) use WAL and a busy timeout, and `index_document` holds `BEGIN IMMEDIATE` around each
15
+ document, so two writers queue for the lock up front instead of failing half-way through.
16
+ - **Both** check `PRAGMA user_version` against `SCHEMA_VERSION` and refuse a mismatch with a `LibError` whose
17
+ hint is the collection's rebuild command. There are no migrations: an index is derived data, rebuilt from the
18
+ corpus.
19
+ - **A rebuild** (`rebuild`) writes a temporary file in the same directory and `os.replace`s it over the old index,
20
+ so a reader sees the old index or the new one, never a half-built one.
21
+
22
+ Search (`search`) quotes every user term (`fts_query`), so FTS5 syntax in a question cannot raise. It asks for
23
+ every term first; only when nothing has them all does it ask for any term, and says so with `relaxed`.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import os
29
+ import re
30
+ import sqlite3
31
+ import tempfile
32
+ from collections.abc import Callable, Iterable
33
+ from dataclasses import dataclass
34
+ from pathlib import Path
35
+
36
+ from primarysource.errors import LibError
37
+ from primarysource.model import Document
38
+
39
+ SCHEMA_VERSION = 1
40
+ BUSY_TIMEOUT_MS = 15_000
41
+
42
+ # Statements, not one script: `executescript` commits first, and the schema is created inside the writer's
43
+ # `BEGIN IMMEDIATE` so two processes creating the same new index cannot both do it.
44
+ _SCHEMA = (
45
+ "CREATE TABLE documents("
46
+ "doc_id TEXT PRIMARY KEY, title TEXT NOT NULL, unit TEXT NOT NULL, sections INTEGER NOT NULL)",
47
+ "CREATE TABLE sections("
48
+ "id INTEGER PRIMARY KEY, doc_id TEXT NOT NULL, section_id TEXT NOT NULL, ord INTEGER NOT NULL, "
49
+ "depth INTEGER NOT NULL, doc_title TEXT NOT NULL, title TEXT NOT NULL, body TEXT NOT NULL)",
50
+ "CREATE INDEX sections_doc ON sections(doc_id, ord)",
51
+ "CREATE VIRTUAL TABLE sections_fts USING fts5("
52
+ "doc_title, title, body, content='sections', content_rowid='id', tokenize='porter unicode61')",
53
+ "CREATE TRIGGER sections_ai AFTER INSERT ON sections BEGIN "
54
+ "INSERT INTO sections_fts(rowid, doc_title, title, body) VALUES (new.id, new.doc_title, new.title, new.body); "
55
+ "END",
56
+ "CREATE TRIGGER sections_ad AFTER DELETE ON sections BEGIN "
57
+ "INSERT INTO sections_fts(sections_fts, rowid, doc_title, title, body) "
58
+ "VALUES ('delete', old.id, old.doc_title, old.title, old.body); "
59
+ "END",
60
+ "CREATE TRIGGER sections_au AFTER UPDATE ON sections BEGIN "
61
+ "INSERT INTO sections_fts(sections_fts, rowid, doc_title, title, body) "
62
+ "VALUES ('delete', old.id, old.doc_title, old.title, old.body); "
63
+ "INSERT INTO sections_fts(rowid, doc_title, title, body) VALUES (new.id, new.doc_title, new.title, new.body); "
64
+ "END",
65
+ f"PRAGMA user_version = {SCHEMA_VERSION}",
66
+ )
67
+
68
+
69
+ # --- opening ---------------------------------------------------------------------------------------------------
70
+
71
+
72
+ def _remedy(rebuild_command: str) -> str:
73
+ return f"run: {rebuild_command}"
74
+
75
+
76
+ def _check_version(conn: sqlite3.Connection, path: Path, rebuild_command: str) -> None:
77
+ """Refuse an index built by another schema version, or a file that is not an index at all."""
78
+ try:
79
+ version = conn.execute("PRAGMA user_version").fetchone()[0]
80
+ except sqlite3.DatabaseError as error:
81
+ raise LibError(f"{path} is not a readable index: {error}", _remedy(rebuild_command)) from error
82
+ if version != SCHEMA_VERSION:
83
+ raise LibError(
84
+ f"the index at {path} has schema version {version}; this primarysource reads version {SCHEMA_VERSION}",
85
+ _remedy(rebuild_command),
86
+ )
87
+
88
+
89
+ def open_read(path: Path, *, rebuild_command: str, busy_timeout_ms: int = BUSY_TIMEOUT_MS) -> sqlite3.Connection:
90
+ """A query connection: `?mode=ro`, no schema setup, version checked.
91
+
92
+ The file must already exist; a missing index is an error naming `rebuild_command`, never an empty database
93
+ created on the spot. A reader still waits up to `busy_timeout_ms` for a writer.
94
+ """
95
+ if not path.is_file():
96
+ raise LibError(f"no index at {path}", _remedy(rebuild_command))
97
+ try:
98
+ conn = sqlite3.connect(
99
+ f"{path.resolve().as_uri()}?mode=ro", uri=True, timeout=busy_timeout_ms / 1000, isolation_level=None
100
+ )
101
+ except sqlite3.Error as error:
102
+ raise LibError(f"cannot open the index at {path}: {error}", _remedy(rebuild_command)) from error
103
+ try:
104
+ conn.execute(f"PRAGMA busy_timeout = {int(busy_timeout_ms)}")
105
+ _check_version(conn, path, rebuild_command)
106
+ except BaseException:
107
+ conn.close()
108
+ raise
109
+ return conn
110
+
111
+
112
+ def _has_tables(conn: sqlite3.Connection) -> bool:
113
+ return conn.execute("SELECT 1 FROM sqlite_master LIMIT 1").fetchone() is not None
114
+
115
+
116
+ def _connect_write(path: Path, busy_timeout_ms: int) -> sqlite3.Connection:
117
+ path.parent.mkdir(parents=True, exist_ok=True)
118
+ # isolation_level=None: transactions are begun and ended here, explicitly, never implicitly by the driver.
119
+ conn = sqlite3.connect(path, timeout=busy_timeout_ms / 1000, isolation_level=None)
120
+ conn.execute(f"PRAGMA busy_timeout = {int(busy_timeout_ms)}")
121
+ return conn
122
+
123
+
124
+ def open_write(path: Path, *, rebuild_command: str, busy_timeout_ms: int = BUSY_TIMEOUT_MS) -> sqlite3.Connection:
125
+ """A writer connection: WAL, a busy timeout, and the schema created if the file is new.
126
+
127
+ A file that is neither new nor at `SCHEMA_VERSION` is refused with a hint naming `rebuild_command`.
128
+ """
129
+ conn = _connect_write(path, busy_timeout_ms)
130
+ try:
131
+ try:
132
+ conn.execute("PRAGMA journal_mode = WAL")
133
+ conn.execute("BEGIN IMMEDIATE")
134
+ except sqlite3.DatabaseError as error:
135
+ raise LibError(f"{path} is not a writable index: {error}", _remedy(rebuild_command)) from error
136
+ try:
137
+ version = conn.execute("PRAGMA user_version").fetchone()[0]
138
+ if version == 0 and not _has_tables(conn):
139
+ for statement in _SCHEMA:
140
+ conn.execute(statement)
141
+ else:
142
+ _check_version(conn, path, rebuild_command)
143
+ conn.execute("COMMIT")
144
+ except BaseException:
145
+ conn.execute("ROLLBACK")
146
+ raise
147
+ except BaseException:
148
+ conn.close()
149
+ raise
150
+ return conn
151
+
152
+
153
+ # --- writing ---------------------------------------------------------------------------------------------------
154
+
155
+
156
+ def _write_document(conn: sqlite3.Connection, document: Document) -> int:
157
+ conn.execute("DELETE FROM sections WHERE doc_id = ?", (document.id,))
158
+ conn.execute("DELETE FROM documents WHERE doc_id = ?", (document.id,))
159
+ conn.executemany(
160
+ "INSERT INTO sections(doc_id, section_id, ord, depth, doc_title, title, body) VALUES (?, ?, ?, ?, ?, ?, ?)",
161
+ [
162
+ (document.id, section.id, ord_, section.depth, document.title, section.title, "\n\n".join(section.units))
163
+ for ord_, section in enumerate(document.sections)
164
+ ],
165
+ )
166
+ conn.execute(
167
+ "INSERT INTO documents(doc_id, title, unit, sections) VALUES (?, ?, ?, ?)",
168
+ (document.id, document.title, str(document.unit), len(document.sections)),
169
+ )
170
+ return len(document.sections)
171
+
172
+
173
+ def index_document(conn: sqlite3.Connection, document: Document) -> int:
174
+ """Replace one document's sections; returns how many were written.
175
+
176
+ Runs under `BEGIN IMMEDIATE`, so the write lock is taken before anything is read and a second writer waits
177
+ (up to the busy timeout) at the start instead of colliding in the middle. A failure rolls the whole document
178
+ back. Called inside a transaction the caller already holds, it joins that transaction and leaves the commit to
179
+ the caller.
180
+ """
181
+ own = not conn.in_transaction
182
+ if own:
183
+ conn.execute("BEGIN IMMEDIATE")
184
+ try:
185
+ written = _write_document(conn, document)
186
+ if own:
187
+ conn.execute("COMMIT")
188
+ except BaseException:
189
+ if own:
190
+ conn.execute("ROLLBACK")
191
+ raise
192
+ return written
193
+
194
+
195
+ def remove_document(conn: sqlite3.Connection, doc_id: str) -> bool:
196
+ """Drop one document; True if it was there."""
197
+ own = not conn.in_transaction
198
+ if own:
199
+ conn.execute("BEGIN IMMEDIATE")
200
+ try:
201
+ conn.execute("DELETE FROM sections WHERE doc_id = ?", (doc_id,))
202
+ removed = conn.execute("DELETE FROM documents WHERE doc_id = ?", (doc_id,)).rowcount > 0
203
+ if own:
204
+ conn.execute("COMMIT")
205
+ except BaseException:
206
+ if own:
207
+ conn.execute("ROLLBACK")
208
+ raise
209
+ return removed
210
+
211
+
212
+ def _quiesce(path: Path, rebuild_command: str, busy_timeout_ms: int) -> None:
213
+ """Empty the old index's WAL before it is replaced, or refuse.
214
+
215
+ A `-wal` file belongs to a database by name, not by content: frames left in `<index>-wal` would be replayed
216
+ onto the new file the next time a writer opens it. So the old WAL is checkpointed and truncated first, and if
217
+ a reader's open transaction prevents that, the rebuild stops with the old index untouched.
218
+ """
219
+ if not path.is_file():
220
+ return
221
+ try:
222
+ conn = sqlite3.connect(path, timeout=busy_timeout_ms / 1000, isolation_level=None)
223
+ except sqlite3.Error:
224
+ return
225
+ try:
226
+ conn.execute(f"PRAGMA busy_timeout = {int(busy_timeout_ms)}")
227
+ busy, _, _ = conn.execute("PRAGMA wal_checkpoint(TRUNCATE)").fetchone()
228
+ except sqlite3.DatabaseError:
229
+ return # not a database: nothing of it can be replayed, so it may simply be replaced
230
+ finally:
231
+ conn.close()
232
+ if busy:
233
+ raise LibError(
234
+ f"the index at {path} is being read, so its write-ahead log cannot be emptied before the rebuild",
235
+ f"retry when it is idle, or stop the servers reading it and run: {rebuild_command}",
236
+ )
237
+
238
+
239
+ def rebuild(
240
+ path: Path,
241
+ documents: Iterable[Document],
242
+ *,
243
+ rebuild_command: str,
244
+ busy_timeout_ms: int = BUSY_TIMEOUT_MS,
245
+ ) -> int:
246
+ """Build a fresh index from `documents` and swap it in atomically; returns the number of sections.
247
+
248
+ The new index is written to a temporary file beside `path` and moved over it with `os.replace`, so readers see
249
+ the old index or the new one and never a partial one. If building fails, the old index is left as it was and
250
+ the temporary file is removed. The new file is published in rollback-journal mode, so a `mode=ro` reader on a
251
+ read-only volume can open it without creating `-wal`/`-shm` files; the next `open_write` switches it to WAL.
252
+ """
253
+ path.parent.mkdir(parents=True, exist_ok=True)
254
+ handle, name = tempfile.mkstemp(dir=path.parent, prefix=f".{path.name}.", suffix=".tmp")
255
+ os.close(handle)
256
+ temporary = Path(name)
257
+ try:
258
+ conn = _connect_write(temporary, busy_timeout_ms)
259
+ try:
260
+ conn.execute("BEGIN IMMEDIATE")
261
+ for statement in _SCHEMA:
262
+ conn.execute(statement)
263
+ written = sum(_write_document(conn, document) for document in documents)
264
+ conn.execute("INSERT INTO sections_fts(sections_fts) VALUES ('optimize')")
265
+ conn.execute("COMMIT")
266
+ finally:
267
+ conn.close()
268
+ _quiesce(path, rebuild_command, busy_timeout_ms)
269
+ try:
270
+ os.replace(temporary, path)
271
+ except PermissionError as error:
272
+ raise LibError(
273
+ f"cannot replace the index at {path} while another process has it open: {error}",
274
+ f"stop the servers reading it, then run: {rebuild_command}",
275
+ ) from error
276
+ except BaseException:
277
+ temporary.unlink(missing_ok=True)
278
+ Path(f"{temporary}-journal").unlink(missing_ok=True)
279
+ raise
280
+ return written
281
+
282
+
283
+ # --- querying --------------------------------------------------------------------------------------------------
284
+
285
+ # Filler that must not be a required term: "how is the value encoded" should not demand a section containing
286
+ # "how", "is" and "the". A query made only of filler is still searched as typed. `and` and `or` typed as operators
287
+ # are filler too; `not` is kept, because in a specification it is the word that matters.
288
+ _FILLER = frozenset(
289
+ [
290
+ "a",
291
+ "an",
292
+ "the",
293
+ "of",
294
+ "for",
295
+ "to",
296
+ "in",
297
+ "on",
298
+ "at",
299
+ "by",
300
+ "with",
301
+ "from",
302
+ "as",
303
+ "is",
304
+ "are",
305
+ "was",
306
+ "were",
307
+ "be",
308
+ "been",
309
+ "do",
310
+ "does",
311
+ "did",
312
+ "and",
313
+ "or",
314
+ "what",
315
+ "which",
316
+ "who",
317
+ "whom",
318
+ "how",
319
+ "when",
320
+ "where",
321
+ "why",
322
+ "that",
323
+ "this",
324
+ "these",
325
+ "those",
326
+ "it",
327
+ "its",
328
+ ]
329
+ )
330
+ _WORD = re.compile(r"\w")
331
+
332
+
333
+ def query_terms(raw: str) -> tuple[str, ...]:
334
+ """The searchable terms of `raw`: whitespace-separated tokens holding a word character, filler dropped."""
335
+ tokens = [token for token in raw.split() if _WORD.search(token)]
336
+ content = [token for token in tokens if token.strip("?.,;:!()\"'").lower() not in _FILLER]
337
+ return tuple(dict.fromkeys(content or tokens))
338
+
339
+
340
+ def fts_query(raw: str, *, any_term: bool = False) -> str:
341
+ """A MATCH expression that FTS5 accepts whatever the user typed.
342
+
343
+ Every term is quoted as a phrase, with internal double quotes doubled (FTS5's own escape), so `set-of`,
344
+ `NEAR(`, `title:x`, `a*` and a stray `"` are searched as words instead of parsed as operators. Terms are ANDed,
345
+ or ORed with `any_term`. The trade is stated rather than hidden: FTS5 syntax does not work from a query box.
346
+ """
347
+ terms = query_terms(raw)
348
+ if not terms:
349
+ raise LibError("the query has no words to search for", "pass at least one word")
350
+ joiner = " OR " if any_term else " "
351
+ return joiner.join('"' + term.replace('"', '""') + '"' for term in terms)
352
+
353
+
354
+ @dataclass(frozen=True, slots=True)
355
+ class Hit:
356
+ """One matching section. `score` is higher-is-better: BM25 negated, times the collection's adjustment."""
357
+
358
+ doc_id: str
359
+ section_id: str
360
+ doc_title: str
361
+ title: str
362
+ snippet: str
363
+ score: float
364
+
365
+
366
+ RankAdjust = Callable[[Hit, str], float]
367
+ """Given a hit and the raw query, a factor for its score: below 1 demotes, above 1 promotes (itulib demotes
368
+ definitions and informative annexes by 0.6 unless the query asks what a term means)."""
369
+
370
+
371
+ @dataclass(frozen=True, slots=True)
372
+ class Ranking:
373
+ """A collection's ranking: BM25 column weights and an optional adjustment of each hit.
374
+
375
+ `candidates` is how many BM25 hits beyond the requested page are fetched for `adjust` to reorder.
376
+ """
377
+
378
+ doc_title: float = 1.0
379
+ title: float = 4.0
380
+ body: float = 1.0
381
+ adjust: RankAdjust | None = None
382
+ candidates: int = 200
383
+
384
+ def bm25(self) -> str:
385
+ # Floats formatted here, never user text: the weights are part of the SQL, as bm25() requires constants.
386
+ return f"bm25(sections_fts, {float(self.doc_title)!r}, {float(self.title)!r}, {float(self.body)!r})"
387
+
388
+
389
+ @dataclass(frozen=True, slots=True)
390
+ class SearchResult:
391
+ """`relaxed` is true when no section held every term and these hits hold only some of them.
392
+
393
+ `next_start` is the `start` for the next page, or None at the end. No hits means nothing matched.
394
+ """
395
+
396
+ hits: tuple[Hit, ...]
397
+ relaxed: bool
398
+ next_start: int | None
399
+
400
+
401
+ def _match(conn: sqlite3.Connection, expression: str, ranking: Ranking, fetch: int) -> list[Hit]:
402
+ order = ranking.bm25()
403
+ rows = conn.execute(
404
+ "SELECT s.doc_id, s.section_id, s.doc_title, s.title, " # noqa: S608 - only formatted floats are interpolated
405
+ f"snippet(sections_fts, 2, '', '', ' … ', 24), {order} "
406
+ "FROM sections_fts JOIN sections AS s ON s.id = sections_fts.rowid "
407
+ f"WHERE sections_fts MATCH ? ORDER BY {order}, s.id LIMIT ?",
408
+ (expression, fetch),
409
+ ).fetchall()
410
+ return [Hit(row[0], row[1], row[2], row[3], row[4] or row[3], -row[5]) for row in rows]
411
+
412
+
413
+ def search(
414
+ conn: sqlite3.Connection,
415
+ query: str,
416
+ *,
417
+ ranking: Ranking | None = None,
418
+ limit: int = 8,
419
+ start: int = 0,
420
+ ) -> SearchResult:
421
+ """The best sections for `query`, best first, `limit` from `start`.
422
+
423
+ Every term is required first. If no section holds them all and there is more than one term, any term will do
424
+ and the result says `relaxed`. `ranking.adjust`, when given, rescales each candidate and the candidates are
425
+ re-sorted before the page is cut.
426
+ """
427
+ ranking = ranking or Ranking()
428
+ fetch = start + limit + ranking.candidates
429
+ strict = fts_query(query)
430
+ hits = _match(conn, strict, ranking, fetch)
431
+ relaxed = False
432
+ if not hits:
433
+ loose = fts_query(query, any_term=True)
434
+ if loose != strict:
435
+ hits = _match(conn, loose, ranking, fetch)
436
+ relaxed = bool(hits)
437
+ if ranking.adjust is not None:
438
+ adjust = ranking.adjust
439
+ hits = [
440
+ Hit(hit.doc_id, hit.section_id, hit.doc_title, hit.title, hit.snippet, hit.score * adjust(hit, query))
441
+ for hit in hits
442
+ ]
443
+ hits.sort(key=lambda hit: -hit.score) # stable: equal scores keep BM25 order
444
+ page = tuple(hits[start : start + limit])
445
+ more = len(hits) > start + limit
446
+ return SearchResult(page, relaxed, start + len(page) if more else None)
@@ -0,0 +1,51 @@
1
+ """ISO standards from the owner's own purchased PDFs.
2
+
3
+ Placement is a personal volume: the files are the buyer's single-user copies, dropped into the collection's
4
+ home (`$PRIMARYSOURCE_ISO_HOME`, else `$PRIMARYSOURCE_HOME/iso`) and imported with `primarysource iso import`.
5
+ Nothing here fetches and nothing is copied into an image. The PDF itself is not copied either: the import stores
6
+ the extracted text and a record of the source (sha256, size, mtime), and builds the core FTS5 index. Reading a
7
+ PDF needs `pypdf` (the `[iso]` extra) at import time only; serving reads the stored extraction. `docs/iso.md` is
8
+ the user-facing description.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from primarysource.iso.designation import DOC as ISO_DOC
14
+ from primarysource.model import Unit
15
+ from primarysource.refs import Ref, RefGrammar
16
+ from primarysource.registry import Collection
17
+
18
+ ISO_SECTION = r"[0-9A-Za-z][0-9A-Za-z.\-]*"
19
+ """A clause as the PDF bookmarks number it (`6.5.2`, `A`, `A.1`) or the slug of an unnumbered bookmark."""
20
+
21
+
22
+ class _Refs:
23
+ """The collection's ref parser: `library.parse_ref` (which also takes the `iso:` prefix and a printed
24
+ designation), imported on first use so the collection loads without its reader."""
25
+
26
+ _grammar = RefGrammar(ISO_DOC, ISO_SECTION)
27
+
28
+ def parse(self, text: str) -> Ref:
29
+ from primarysource.iso.library import parse_ref
30
+
31
+ return parse_ref(text)
32
+
33
+ def format(self, ref: Ref) -> str:
34
+ return self._grammar.format(ref)
35
+
36
+
37
+ ISO = Collection(
38
+ name="iso",
39
+ title="ISO standards from the owner's own purchased PDFs",
40
+ prefix="iso_",
41
+ unit=Unit.SENTENCE,
42
+ grammar=_Refs(),
43
+ server="primarysource.iso.server:server",
44
+ cli="primarysource.iso.cli:main",
45
+ identify="primarysource.iso.identify:identify",
46
+ )
47
+ """Refs: `iso:9899@2024§6.5.2¶3`. The document is `<number>@<year>` (the newest imported year when omitted); a
48
+ printed designation such as `ISO/IEC 9899:2024§6.5.2` is accepted too. `¶n` is the nth sentence of the clause's
49
+ span, because a PDF gives sentences, not paragraphs."""
50
+
51
+ __all__ = ["ISO", "ISO_DOC", "ISO_SECTION"]
@@ -0,0 +1,98 @@
1
+ """`primarysource iso ...`: the collection's own commands.
2
+
3
+ primarysource iso import [FILE] [--designation "ISO/IEC 9899:2024"] # no FILE: every PDF in the iso home
4
+ primarysource iso index
5
+ primarysource iso docs
6
+ primarysource iso outline <doc> [--start N] [--limit N]
7
+ primarysource iso read <ref> [--max-tokens N]
8
+ primarysource iso search <query> [--doc DOC] [--limit N] [--start N]
9
+ primarysource iso cite <ref>
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import argparse
15
+ import sys
16
+ from collections.abc import Sequence
17
+ from pathlib import Path
18
+
19
+ from primarysource.errors import LibError
20
+ from primarysource.fsutil import bulk_lock
21
+ from primarysource.iso import importer, library
22
+
23
+
24
+ def _parser() -> argparse.ArgumentParser:
25
+ parser = argparse.ArgumentParser(prog="primarysource iso", description="ISO standards from your own PDFs")
26
+ commands = parser.add_subparsers(dest="command", required=True)
27
+ imp = commands.add_parser("import", help="extract and index your PDFs (default: every PDF in the iso home)")
28
+ imp.add_argument(
29
+ "file", nargs="?", type=Path, help="one PDF; without it, every new or changed PDF in the iso home"
30
+ )
31
+ imp.add_argument("--designation", help="as printed on the cover, e.g. 'ISO/IEC 9899:2024'")
32
+ commands.add_parser("index", help="rebuild the search index from what is imported")
33
+ commands.add_parser("docs", help="what is imported")
34
+ out = commands.add_parser("outline", help="a standard's clauses with refs")
35
+ out.add_argument("doc")
36
+ out.add_argument("--start", type=int, default=0)
37
+ out.add_argument("--limit", type=int, default=200)
38
+ read = commands.add_parser("read", help="read by ref")
39
+ read.add_argument("ref")
40
+ read.add_argument("--max-tokens", type=int, default=2000)
41
+ search = commands.add_parser("search", help="find clauses containing words")
42
+ search.add_argument("query")
43
+ search.add_argument("--doc")
44
+ search.add_argument("--limit", type=int, default=10)
45
+ search.add_argument("--start", type=int, default=0)
46
+ commands.add_parser("cite", help="the citation for a ref").add_argument("ref")
47
+ return parser
48
+
49
+
50
+ def _out(text: str) -> int:
51
+ sys.stdout.write(text if text.endswith("\n") else text + "\n")
52
+ return 0
53
+
54
+
55
+ def _line(result: importer.ImportResult) -> str:
56
+ line = (
57
+ f"imported iso:{result.doc} ({result.designation}): {result.sections} sections, "
58
+ f"{result.headings_found} headings found ({result.segmenter})"
59
+ )
60
+ return line + (f"\n note: {result.note}" if result.note else "")
61
+
62
+
63
+ def _import(args: argparse.Namespace, home: Path) -> int:
64
+ with bulk_lock(home, "import"):
65
+ if args.file is not None:
66
+ result = importer.import_file(home, args.file, args.designation)
67
+ return _out(_line(result))
68
+ if args.designation:
69
+ raise LibError("--designation needs a FILE", "", "primarysource iso import FILE --designation '...'")
70
+ done, skipped = importer.import_folder(home)
71
+ lines = [_line(r) for r in done]
72
+ lines += [f"skipped {name}: {why}" for name, why in skipped]
73
+ if not lines:
74
+ lines = [f"no new or changed PDFs in {home}"]
75
+ return _out("\n".join(lines))
76
+
77
+
78
+ def main(argv: Sequence[str]) -> int:
79
+ args = _parser().parse_args(list(argv))
80
+ home = library.default_home()
81
+ try:
82
+ if args.command == "import":
83
+ return _import(args, home)
84
+ if args.command == "index":
85
+ with bulk_lock(home, "import"):
86
+ return _out(f"indexed {importer.rebuild_index(home)} sections")
87
+ if args.command == "docs":
88
+ return _out(library.docs())
89
+ if args.command == "outline":
90
+ return _out(library.outline(args.doc, start=args.start, limit=args.limit))
91
+ if args.command == "read":
92
+ return _out(library.read(args.ref, max_tokens=args.max_tokens))
93
+ if args.command == "search":
94
+ return _out(library.search(args.query, doc=args.doc, limit=args.limit, start=args.start))
95
+ return _out(library.cite(args.ref))
96
+ except LibError as exc:
97
+ print(exc.as_text(), file=sys.stderr)
98
+ return 1
@@ -0,0 +1,95 @@
1
+ """ISO designations, as printed (`ISO/IEC 9899:2024`, `ISO 8601-1:2019/Amd 1:2022`, `ISO/IEC TR 24772-1:2019`) and
2
+ as the ISO store names its files (`ISO_IEC_9899_2024(en).pdf`).
3
+
4
+ The collection's document id is `<number>@<year>` (`9899@2024`, `8601-1@2019`), with `+amd1-2022` or `+cor1-2022`
5
+ for an amendment or a corrigendum. It is not the designation itself, because `:` already means a line range in a
6
+ ref and `/` is not a safe file name.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import re
12
+ from dataclasses import dataclass
13
+
14
+ from primarysource.errors import LibError
15
+
16
+ _PATTERN = re.compile(
17
+ r"(?P<pub>ISO(?:[/_ ]IEC)?(?:[/_ ]IEEE)?)"
18
+ r"(?:[/_ ](?P<kind>TS|TR|PAS|Guide))?"
19
+ r"[/_ ]+(?P<number>\d{1,5}(?:-\d{1,3})*)"
20
+ r"[_:\- ](?P<year>(?:19|20)\d{2})"
21
+ r"(?:\s*[/_ ]\s*(?P<supp>Amd|Cor)[_ .]*(?P<sn>\d{1,2})[_:\- ](?P<syear>(?:19|20)\d{2}))?",
22
+ re.IGNORECASE,
23
+ )
24
+
25
+ DOC = r"\d{1,5}(?:-\d{1,3})*(?:@(?:19|20)\d{2}(?:\+(?:amd|cor)\d{1,2}-(?:19|20)\d{2})?)?"
26
+ """A document id in a ref: `9899`, `9899@2024`, `27001@2022+amd1-2024`."""
27
+
28
+ _DOC = re.compile(r"(?P<number>\d{1,5}(?:-\d{1,3})*)(?:@(?P<rest>.+))?")
29
+
30
+
31
+ @dataclass(frozen=True, slots=True)
32
+ class Designation:
33
+ publisher: str
34
+ kind: str
35
+ number: str
36
+ year: int
37
+ supplement: str = ""
38
+ """`Amd 1:2022` or `Cor 1:2022`, or empty for the standard itself."""
39
+
40
+ @property
41
+ def label(self) -> str:
42
+ """The designation as ISO prints it: `ISO/IEC TR 24772-1:2019/Amd 1:2022`."""
43
+ head = self.publisher + (f" {self.kind}" if self.kind else "")
44
+ tail = f"/{self.supplement}" if self.supplement else ""
45
+ return f"{head} {self.number}:{self.year}{tail}"
46
+
47
+ @property
48
+ def doc(self) -> str:
49
+ """The id refs use: `9899@2024`, `27001@2022+amd1-2024`."""
50
+ suffix = ""
51
+ if self.supplement:
52
+ kind, _, rest = self.supplement.partition(" ")
53
+ number, _, year = rest.partition(":")
54
+ suffix = f"+{kind.lower()}{number}-{year}"
55
+ return f"{self.number}@{self.year}{suffix}"
56
+
57
+ @property
58
+ def key(self) -> str:
59
+ """The doc id as a file name: `9899_2024`, `27001_2022_amd1-2024`."""
60
+ return self.doc.replace("@", "_").replace("+", "_")
61
+
62
+
63
+ def _from_match(match: re.Match[str]) -> Designation:
64
+ publisher = "/".join(part.upper() for part in re.split(r"[/_ ]", match["pub"]))
65
+ kind = match["kind"] or ""
66
+ kind = "Guide" if kind.lower() == "guide" else kind.upper()
67
+ supplement = ""
68
+ if match["supp"]:
69
+ supplement = f"{match['supp'].capitalize()} {int(match['sn'])}:{match['syear']}"
70
+ return Designation(publisher, kind, match["number"], int(match["year"]), supplement)
71
+
72
+
73
+ def parse(text: str) -> Designation:
74
+ """A designation as printed (`ISO/IEC 9899:2024`), or a `LibError` that shows the expected form."""
75
+ match = _PATTERN.fullmatch(text.strip())
76
+ if match is None:
77
+ raise LibError(
78
+ f"not an ISO designation: {text!r}",
79
+ "write it as printed on the cover, e.g. 'ISO/IEC 9899:2024' or 'ISO 8601-1:2019/Amd 1:2022'",
80
+ )
81
+ return _from_match(match)
82
+
83
+
84
+ def from_filename(name: str) -> Designation | None:
85
+ """The designation an ISO store file name carries (`ISO_IEC_9899_2024(en).pdf`), or None."""
86
+ match = _PATTERN.search(name)
87
+ return _from_match(match) if match else None
88
+
89
+
90
+ def split_doc(token: str) -> tuple[str, str | None]:
91
+ """A ref's document token as (number, the part after `@` or None): `9899@2024` is ('9899', '2024')."""
92
+ match = _DOC.fullmatch(token.strip())
93
+ if match is None or not re.fullmatch(DOC, token.strip()):
94
+ raise LibError(f"not an ISO document: {token!r}", "use the number and year, e.g. 9899@2024, or just 9899")
95
+ return match["number"], match["rest"]