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.
- primarysource/__init__.py +5 -0
- primarysource/bbf/__init__.py +56 -0
- primarysource/bbf/_xml.py +63 -0
- primarysource/bbf/catalogue.json +777 -0
- primarysource/bbf/catalogue.py +137 -0
- primarysource/bbf/cli.py +79 -0
- primarysource/bbf/datamodel.py +281 -0
- primarysource/bbf/importer.py +151 -0
- primarysource/bbf/library.py +380 -0
- primarysource/bbf/server.py +72 -0
- primarysource/budget.py +63 -0
- primarysource/c2sp/__init__.py +53 -0
- primarysource/c2sp/_fetch.py +5 -0
- primarysource/c2sp/catalogue.json +231 -0
- primarysource/c2sp/catalogue.py +95 -0
- primarysource/c2sp/cli.py +62 -0
- primarysource/c2sp/importer.py +90 -0
- primarysource/c2sp/library.py +309 -0
- primarysource/c2sp/markdown.py +31 -0
- primarysource/c2sp/server.py +68 -0
- primarysource/cavp/__init__.py +42 -0
- primarysource/cavp/cli.py +84 -0
- primarysource/cavp/data/vectors.toml +47 -0
- primarysource/cavp/importer.py +156 -0
- primarysource/cavp/model.py +279 -0
- primarysource/cavp/parse.py +269 -0
- primarysource/cavp/paths.py +17 -0
- primarysource/cavp/server.py +67 -0
- primarysource/cavp/tools.py +169 -0
- primarysource/cli.py +107 -0
- primarysource/cursor.py +82 -0
- primarysource/cve/__init__.py +5 -0
- primarysource/cve/__main__.py +6 -0
- primarysource/cve/cli.py +144 -0
- primarysource/cve/collection.py +20 -0
- primarysource/cve/config.py +58 -0
- primarysource/cve/errors.py +16 -0
- primarysource/cve/kernel.py +232 -0
- primarysource/cve/mcp.py +139 -0
- primarysource/cve/parse.py +318 -0
- primarysource/cve/py.typed +0 -0
- primarysource/cve/service.py +760 -0
- primarysource/cve/store.py +214 -0
- primarysource/cve/sync.py +438 -0
- primarysource/cve/views.py +304 -0
- primarysource/debpolicy/__init__.py +16 -0
- primarysource/debpolicy/catalogue.py +263 -0
- primarysource/debpolicy/cli.py +12 -0
- primarysource/debpolicy/server.py +11 -0
- primarysource/draft/__init__.py +11 -0
- primarysource/draft/catalogue.py +117 -0
- primarysource/draft/cli.py +149 -0
- primarysource/draft/collection.py +21 -0
- primarysource/draft/datatracker.py +158 -0
- primarysource/draft/importer.py +163 -0
- primarysource/draft/licence.py +89 -0
- primarysource/draft/names.py +64 -0
- primarysource/draft/net.py +133 -0
- primarysource/draft/segment.py +192 -0
- primarysource/draft/server.py +91 -0
- primarysource/draft/status.py +146 -0
- primarysource/draft/store.py +199 -0
- primarysource/draft/tracked.json +104 -0
- primarysource/draft/views.py +430 -0
- primarysource/draft/xmlmeta.py +74 -0
- primarysource/ecma/__init__.py +52 -0
- primarysource/ecma/_pdf.py +143 -0
- primarysource/ecma/catalogue.json +22 -0
- primarysource/ecma/catalogue.py +100 -0
- primarysource/ecma/cli.py +67 -0
- primarysource/ecma/identify.py +25 -0
- primarysource/ecma/importer.py +108 -0
- primarysource/ecma/library.py +240 -0
- primarysource/ecma/server.py +67 -0
- primarysource/errors.py +29 -0
- primarysource/fetch.py +360 -0
- primarysource/fsutil.py +150 -0
- primarysource/gitdocs/__init__.py +15 -0
- primarysource/gitdocs/catalogue.py +116 -0
- primarysource/gitdocs/cli.py +12 -0
- primarysource/gitdocs/server.py +8 -0
- primarysource/home.py +65 -0
- primarysource/iana/__init__.py +32 -0
- primarysource/iana/catalogue.json +244 -0
- primarysource/iana/catalogue.py +69 -0
- primarysource/iana/cli.py +69 -0
- primarysource/iana/importer.py +157 -0
- primarysource/iana/model.py +325 -0
- primarysource/iana/reader.py +315 -0
- primarysource/iana/server.py +67 -0
- primarysource/iana/store.py +83 -0
- primarysource/iana/xmlsafe.py +47 -0
- primarysource/identify.py +186 -0
- primarysource/ieee/__init__.py +58 -0
- primarysource/ieee/_html.py +49 -0
- primarysource/ieee/catalogue.json +74 -0
- primarysource/ieee/catalogue.py +123 -0
- primarysource/ieee/cli.py +79 -0
- primarysource/ieee/identify.py +24 -0
- primarysource/ieee/ieee802.py +47 -0
- primarysource/ieee/importer.py +305 -0
- primarysource/ieee/posix.py +233 -0
- primarysource/ieee/server.py +79 -0
- primarysource/index.py +446 -0
- primarysource/iso/__init__.py +51 -0
- primarysource/iso/cli.py +98 -0
- primarysource/iso/designation.py +95 -0
- primarysource/iso/identify.py +100 -0
- primarysource/iso/importer.py +224 -0
- primarysource/iso/library.py +249 -0
- primarysource/iso/server.py +69 -0
- primarysource/itu/__init__.py +41 -0
- primarysource/itu/__main__.py +8 -0
- primarysource/itu/asn1mod.py +478 -0
- primarysource/itu/catalog.py +472 -0
- primarysource/itu/cli.py +445 -0
- primarysource/itu/collection.py +20 -0
- primarysource/itu/corpus.py +118 -0
- primarysource/itu/errata.py +16 -0
- primarysource/itu/formal.py +565 -0
- primarysource/itu/identify.py +26 -0
- primarysource/itu/index.py +490 -0
- primarysource/itu/mcp.py +474 -0
- primarysource/itu/py.typed +0 -0
- primarysource/itu/sources.py +600 -0
- primarysource/itu/text.py +609 -0
- primarysource/kconfig/__init__.py +16 -0
- primarysource/kconfig/catalogue.py +98 -0
- primarysource/kconfig/cli.py +12 -0
- primarysource/kconfig/server.py +8 -0
- primarysource/model.py +63 -0
- primarysource/nist/__init__.py +5 -0
- primarysource/nist/collection.py +46 -0
- primarysource/nist/data/acquired-nist.json +1657 -0
- primarysource/nist/data/nist-catalog.json +12090 -0
- primarysource/nist/data/sources.toml +1996 -0
- primarysource/nist/identify.py +41 -0
- primarysource/nist/ingest/__init__.py +1 -0
- primarysource/nist/ingest/acquire_nist.py +128 -0
- primarysource/nist/ingest/controls.py +82 -0
- primarysource/nist/ingest/discover_nist.py +218 -0
- primarysource/nist/ingest/extract.py +48 -0
- primarysource/nist/ingest/fetch.py +211 -0
- primarysource/nist/ingest/index.py +14 -0
- primarysource/nist/ingest/oscal.py +480 -0
- primarysource/nist/ingest/prose_layouts.py +236 -0
- primarysource/nist/ingest/sources.py +94 -0
- primarysource/nist/ingest/status.py +255 -0
- primarysource/nist/models.py +7 -0
- primarysource/nist/paths.py +33 -0
- primarysource/nist/server/__init__.py +3 -0
- primarysource/nist/server/corpus.py +38 -0
- primarysource/nist/server/mcp.py +19 -0
- primarysource/nist/server/tools.py +35 -0
- primarysource/nist/spec.py +38 -0
- primarysource/pdfcorpus/__init__.py +16 -0
- primarysource/pdfcorpus/corpus.py +159 -0
- primarysource/pdfcorpus/fts.py +175 -0
- primarysource/pdfcorpus/index.py +70 -0
- primarysource/pdfcorpus/models.py +30 -0
- primarysource/pdfcorpus/server.py +96 -0
- primarysource/pdfcorpus/spec.py +37 -0
- primarysource/pdfcorpus/split.py +391 -0
- primarysource/pdfcorpus/tools.py +244 -0
- primarysource/pep/__init__.py +15 -0
- primarysource/pep/catalogue.py +191 -0
- primarysource/pep/cli.py +12 -0
- primarysource/pep/server.py +8 -0
- primarysource/pkgdocs/__init__.py +19 -0
- primarysource/pkgdocs/fetch.py +356 -0
- primarysource/pkgdocs/index.py +89 -0
- primarysource/pkgdocs/model.py +76 -0
- primarysource/pkgdocs/readers/__init__.py +1 -0
- primarysource/pkgdocs/readers/_common.py +134 -0
- primarysource/pkgdocs/readers/asciidoc.py +160 -0
- primarysource/pkgdocs/readers/latex.py +315 -0
- primarysource/pkgdocs/readers/markdown.py +173 -0
- primarysource/pkgdocs/readers/pod.py +132 -0
- primarysource/pkgdocs/readers/rst.py +239 -0
- primarysource/pkgdocs/readers/scdoc.py +108 -0
- primarysource/pkgdocs/runtime.py +552 -0
- primarysource/pkgdocs/serving.py +158 -0
- primarysource/pkgdocs/store.py +167 -0
- primarysource/pms/__init__.py +16 -0
- primarysource/pms/catalogue.py +88 -0
- primarysource/pms/cli.py +12 -0
- primarysource/pms/server.py +8 -0
- primarysource/py.typed +0 -0
- primarysource/readers/__init__.py +10 -0
- primarysource/readers/bookmarks.py +192 -0
- primarysource/readers/html.py +1312 -0
- primarysource/readers/markdown.py +189 -0
- primarysource/readers/pdf.py +187 -0
- primarysource/readers/xml.py +230 -0
- primarysource/refs.py +87 -0
- primarysource/registry.py +161 -0
- primarysource/render.py +49 -0
- primarysource/rfc/__init__.py +3 -0
- primarysource/rfc/__main__.py +6 -0
- primarysource/rfc/cite.py +204 -0
- primarysource/rfc/cli.py +903 -0
- primarysource/rfc/collection.py +19 -0
- primarysource/rfc/config.py +138 -0
- primarysource/rfc/errata.py +203 -0
- primarysource/rfc/fsutil.py +82 -0
- primarysource/rfc/graph.py +213 -0
- primarysource/rfc/index/__init__.py +1 -0
- primarysource/rfc/index/chunker.py +281 -0
- primarysource/rfc/index/embed.py +166 -0
- primarysource/rfc/index/hybrid.py +305 -0
- primarysource/rfc/index/lexical.py +223 -0
- primarysource/rfc/index/rfcxml.py +673 -0
- primarysource/rfc/index/search.py +508 -0
- primarysource/rfc/index/txtseg.py +585 -0
- primarysource/rfc/library.py +294 -0
- primarysource/rfc/mcp/__init__.py +1 -0
- primarysource/rfc/mcp/server.py +381 -0
- primarysource/rfc/packs/__init__.py +1 -0
- primarysource/rfc/packs/builder.py +228 -0
- primarysource/rfc/packs/collections/all-current-txt.json +10 -0
- primarysource/rfc/packs/collections/essentials.json +19 -0
- primarysource/rfc/packs/collections/fyi-current.json +10 -0
- primarysource/rfc/packs/collections/standards-current.json +10 -0
- primarysource/rfc/packs/collections/year-2025.json +10 -0
- primarysource/rfc/packs/collections/year-2026.json +10 -0
- primarysource/rfc/packs/collections.py +163 -0
- primarysource/rfc/packs/docsetexport.py +163 -0
- primarysource/rfc/packs/epub.py +159 -0
- primarysource/rfc/packs/export.py +306 -0
- primarysource/rfc/packs/htmlexport.py +82 -0
- primarysource/rfc/packs/serve.py +143 -0
- primarysource/rfc/packs/zimexport.py +171 -0
- primarysource/rfc/query.py +466 -0
- primarysource/rfc/subjects.py +457 -0
- primarysource/rfc/sync/__init__.py +1 -0
- primarysource/rfc/sync/downloader.py +132 -0
- primarysource/rfc/sync/manifest.py +162 -0
- primarysource/rfc/sync/scanner.py +49 -0
- primarysource/rfc/sync/state.py +464 -0
- primarysource/rfc/views.py +1067 -0
- primarysource/rpmdocs/__init__.py +15 -0
- primarysource/rpmdocs/catalogue.py +117 -0
- primarysource/rpmdocs/cli.py +12 -0
- primarysource/rpmdocs/server.py +8 -0
- primarysource/scaffold.py +223 -0
- primarysource/serve.py +75 -0
- primarysource/settings.py +152 -0
- primarysource/usb/__init__.py +5 -0
- primarysource/usb/collection.py +37 -0
- primarysource/usb/data/acquired.json +2025 -0
- primarysource/usb/data/sources-usb.toml +852 -0
- primarysource/usb/data/sources.toml +10 -0
- primarysource/usb/data/usb-catalog.json +1663 -0
- primarysource/usb/data/usb-classification.json +1986 -0
- primarysource/usb/data/usb-corpus-build.json +608 -0
- primarysource/usb/data/usb-pdf-choice.json +839 -0
- primarysource/usb/data/usb-split-measure.json +1803 -0
- primarysource/usb/ingest/__init__.py +1 -0
- primarysource/usb/ingest/acquire.py +130 -0
- primarysource/usb/ingest/discover_usb.py +88 -0
- primarysource/usb/ingest/extract.py +48 -0
- primarysource/usb/ingest/fetch.py +197 -0
- primarysource/usb/ingest/index.py +14 -0
- primarysource/usb/ingest/sources.py +85 -0
- primarysource/usb/models.py +7 -0
- primarysource/usb/paths.py +33 -0
- primarysource/usb/server/__init__.py +3 -0
- primarysource/usb/server/corpus.py +38 -0
- primarysource/usb/server/mcp.py +19 -0
- primarysource/usb/server/tools.py +35 -0
- primarysource/usb/spec.py +20 -0
- primarysource/usbids/__init__.py +56 -0
- primarysource/usbids/cli.py +65 -0
- primarysource/usbids/importer.py +272 -0
- primarysource/usbids/parse.py +283 -0
- primarysource/usbids/server.py +55 -0
- primarysource/usbids/store.py +80 -0
- primarysource/usbids/tools.py +174 -0
- primarysource/zim/__init__.py +5 -0
- primarysource/zim/__main__.py +8 -0
- primarysource/zim/_libzim.py +37 -0
- primarysource/zim/catalog.py +594 -0
- primarysource/zim/cli.py +555 -0
- primarysource/zim/collection.py +41 -0
- primarysource/zim/errors.py +100 -0
- primarysource/zim/groups.py +30 -0
- primarysource/zim/library.py +668 -0
- primarysource/zim/manage.py +636 -0
- primarysource/zim/markdown.py +1247 -0
- primarysource/zim/mcp.py +201 -0
- primarysource/zim/purl.py +130 -0
- primarysource/zim/py.typed +0 -0
- primarysource/zim/refs.py +90 -0
- primarysource/zim/service.py +1182 -0
- primarysource/zim/snapshot.py +197 -0
- primarysource/zim/sync.py +269 -0
- primarysource/zim/views.py +230 -0
- primarysource-0.1.0.dist-info/METADATA +118 -0
- primarysource-0.1.0.dist-info/RECORD +303 -0
- primarysource-0.1.0.dist-info/WHEEL +4 -0
- primarysource-0.1.0.dist-info/entry_points.txt +2 -0
- primarysource-0.1.0.dist-info/licenses/LICENSE +203 -0
- 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"]
|
primarysource/iso/cli.py
ADDED
|
@@ -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"]
|