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
|
@@ -0,0 +1,466 @@
|
|
|
1
|
+
"""Query API shared by the CLI and the MCP server. Everything reads the local
|
|
2
|
+
DB/mirror; content missing locally is fetched on demand from rfc-editor.org."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import hashlib
|
|
7
|
+
import json
|
|
8
|
+
import re
|
|
9
|
+
import sqlite3
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from . import subjects as subj
|
|
13
|
+
from .config import BASE_URL
|
|
14
|
+
from .sync import downloader
|
|
15
|
+
from .sync.state import file_path
|
|
16
|
+
|
|
17
|
+
DEFAULT_READ_CHARS = 6000
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class NotFound(Exception):
|
|
21
|
+
pass
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def normalize_doc_id(raw: str) -> str:
|
|
25
|
+
"""Accept 'rfc 9000', '9000', 'RFC9000', 'bcp-14', 'std1' -> 'RFC9000' etc."""
|
|
26
|
+
s = re.sub(r"[\s_/,]+", "", raw.strip().lower())
|
|
27
|
+
if re.fullmatch(r"\d{1,5}", s):
|
|
28
|
+
s = "rfc" + s
|
|
29
|
+
m = re.fullmatch(r"(rfc|bcp|std|fyi|ien)(\d{1,5})", s)
|
|
30
|
+
if not m:
|
|
31
|
+
raise ValueError(f"not a valid RFC-series id: {raw!r}")
|
|
32
|
+
return m.group(1).upper() + m.group(2)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def get_doc_row(conn: sqlite3.Connection, doc_id: str) -> sqlite3.Row:
|
|
36
|
+
row = conn.execute("SELECT * FROM docs WHERE doc_id = ?", (doc_id,)).fetchone()
|
|
37
|
+
if row is None:
|
|
38
|
+
raise NotFound(doc_id)
|
|
39
|
+
return row
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def resolve(conn: sqlite3.Connection, doc_id: str) -> str:
|
|
43
|
+
"""Follow is-also aliases (BCP/STD/FYI -> underlying RFC). Returns the
|
|
44
|
+
doc_id whose content should be served."""
|
|
45
|
+
cur = doc_id
|
|
46
|
+
for _ in range(6):
|
|
47
|
+
if cur.startswith("RFC"):
|
|
48
|
+
return cur # reached a content-bearing RFC; stop (avoids alias loops)
|
|
49
|
+
rows = conn.execute(
|
|
50
|
+
"SELECT dst FROM doc_relations WHERE src=? AND kind='alias'", (cur,)
|
|
51
|
+
).fetchall()
|
|
52
|
+
if not rows:
|
|
53
|
+
return cur
|
|
54
|
+
targets = [r["dst"] for r in rows]
|
|
55
|
+
best = next((t for t in targets if t.startswith("RFC")), None)
|
|
56
|
+
if best is None or best == cur:
|
|
57
|
+
return cur
|
|
58
|
+
cur = best
|
|
59
|
+
return cur
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _relations(conn: sqlite3.Connection, doc_id: str) -> dict[str, list[str]]:
|
|
63
|
+
# The manifest records each obsoletes/updates pair from both sides; union
|
|
64
|
+
# the direct and inverse views and de-duplicate.
|
|
65
|
+
out: dict[str, set[str]] = {}
|
|
66
|
+
for r in conn.execute(
|
|
67
|
+
"SELECT kind, dst FROM doc_relations WHERE src=?", (doc_id,)):
|
|
68
|
+
out.setdefault(r["kind"], set()).add(r["dst"])
|
|
69
|
+
inverse = {"obsoletes": "obsoleted_by", "obsoleted_by": "obsoletes",
|
|
70
|
+
"updates": "updated_by", "updated_by": "updates", "alias": "alias"}
|
|
71
|
+
for r in conn.execute(
|
|
72
|
+
"SELECT kind, src FROM doc_relations WHERE dst=?", (doc_id,)):
|
|
73
|
+
out.setdefault(inverse[r["kind"]], set()).add(r["src"])
|
|
74
|
+
return {k: sorted(v) for k, v in out.items()}
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _row_to_meta(conn: sqlite3.Connection, row: sqlite3.Row) -> dict:
|
|
78
|
+
d = dict(row)
|
|
79
|
+
d["authors"] = json.loads(d.get("authors") or "[]")
|
|
80
|
+
d["formats_offered"] = json.loads(d.get("formats") or "[]")
|
|
81
|
+
d.pop("formats", None)
|
|
82
|
+
d["formats_local"] = [
|
|
83
|
+
r["fmt"] for r in conn.execute(
|
|
84
|
+
"SELECT fmt FROM files WHERE doc_id=? ORDER BY fmt", (d["doc_id"],))]
|
|
85
|
+
d["relations"] = _relations(conn, d["doc_id"])
|
|
86
|
+
d["subjects"] = subj.subjects_of(conn, d["doc_id"])
|
|
87
|
+
d["canonical_url"] = (
|
|
88
|
+
f"{BASE_URL}/info/{d['doc_id'].lower()}" if d["series"] == "RFC"
|
|
89
|
+
else f"{BASE_URL}/{d['series'].lower()}/{d['doc_id'].lower()}/")
|
|
90
|
+
return d
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def get_metadata(conn: sqlite3.Connection, doc_id: str) -> dict:
|
|
94
|
+
row = get_doc_row(conn, doc_id)
|
|
95
|
+
meta = _row_to_meta(conn, row)
|
|
96
|
+
if row["series"] != "RFC":
|
|
97
|
+
aliases = [a for a in meta["relations"].get("alias", []) if a != doc_id]
|
|
98
|
+
if aliases:
|
|
99
|
+
targets = []
|
|
100
|
+
for a in aliases:
|
|
101
|
+
try:
|
|
102
|
+
targets.append(get_metadata(conn, a))
|
|
103
|
+
except NotFound:
|
|
104
|
+
pass
|
|
105
|
+
if targets:
|
|
106
|
+
meta["alias_of"] = targets
|
|
107
|
+
return meta
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def search(conn: sqlite3.Connection, q: str, limit: int = 10,
|
|
111
|
+
include_obsolete: bool = False, stream: str | None = None,
|
|
112
|
+
status: str | None = None,
|
|
113
|
+
subject: str | None = None) -> list[dict]:
|
|
114
|
+
limit = max(1, min(limit, 50))
|
|
115
|
+
words = re.findall(r"[A-Za-z0-9]+", q)
|
|
116
|
+
params: list = []
|
|
117
|
+
where = []
|
|
118
|
+
if not include_obsolete:
|
|
119
|
+
where.append("d.is_current = 1")
|
|
120
|
+
if stream:
|
|
121
|
+
where.append("d.stream = ?")
|
|
122
|
+
params.append(stream.upper())
|
|
123
|
+
if status:
|
|
124
|
+
where.append("(d.current_status LIKE ? OR d.status LIKE ?)")
|
|
125
|
+
params += [f"%{status.upper()}%", f"%{status.upper()}%"]
|
|
126
|
+
if subject:
|
|
127
|
+
slugs = subj._subtree_slugs(conn, subj.resolve_subject(conn, subject))
|
|
128
|
+
where.append("d.doc_id IN (SELECT doc_id FROM doc_subjects "
|
|
129
|
+
f"WHERE slug IN ({','.join('?' * len(slugs))}))")
|
|
130
|
+
params.extend(slugs)
|
|
131
|
+
tail = (" AND " + " AND ".join(where)) if where else ""
|
|
132
|
+
|
|
133
|
+
rows: list[sqlite3.Row] = []
|
|
134
|
+
if words:
|
|
135
|
+
match = " AND ".join(f'"{w}"' for w in words)
|
|
136
|
+
try:
|
|
137
|
+
rows = conn.execute(
|
|
138
|
+
f"""SELECT d.*, bm25(docs_fts) AS rank FROM docs_fts
|
|
139
|
+
JOIN docs d ON d.doc_id = docs_fts.doc_id
|
|
140
|
+
WHERE docs_fts MATCH ?{tail}
|
|
141
|
+
ORDER BY rank LIMIT ?""",
|
|
142
|
+
[match, *params, limit]).fetchall()
|
|
143
|
+
except sqlite3.OperationalError:
|
|
144
|
+
rows = []
|
|
145
|
+
if not rows and words: # LIKE fallback (doc-number fragments etc.)
|
|
146
|
+
like = f"%{q.strip()}%"
|
|
147
|
+
rows = conn.execute(
|
|
148
|
+
f"""SELECT d.* FROM docs d
|
|
149
|
+
WHERE (d.doc_id LIKE ? OR d.title LIKE ?){tail}
|
|
150
|
+
ORDER BY d.pub_date DESC LIMIT ?""",
|
|
151
|
+
[like, like, *params, limit]).fetchall()
|
|
152
|
+
return [_row_to_meta(conn, r) for r in rows]
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _content_path(conn: sqlite3.Connection, doc_id: str, fmt: str,
|
|
156
|
+
mirror_root: Path, fetch: bool) -> Path | None:
|
|
157
|
+
row = conn.execute(
|
|
158
|
+
"SELECT path FROM files WHERE doc_id=? AND fmt=?",
|
|
159
|
+
(doc_id, fmt)).fetchone()
|
|
160
|
+
if row:
|
|
161
|
+
local = file_path(conn, row["path"], mirror_root)
|
|
162
|
+
if local.exists():
|
|
163
|
+
return local
|
|
164
|
+
if not fetch:
|
|
165
|
+
return None
|
|
166
|
+
results = downloader.fetch_docs(conn, mirror_root, [doc_id], [fmt])
|
|
167
|
+
conn.commit()
|
|
168
|
+
for d, f, status in results:
|
|
169
|
+
if status == "ok":
|
|
170
|
+
rel = downloader.doc_relpath(d, f)
|
|
171
|
+
if rel:
|
|
172
|
+
return mirror_root / rel
|
|
173
|
+
return None
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def get_document(conn: sqlite3.Connection, doc_id: str, fmt: str = "txt",
|
|
177
|
+
mirror_root: Path | None = None, start_line: int = 1,
|
|
178
|
+
max_lines: int = 400) -> dict:
|
|
179
|
+
"""Return (possibly paginated) document text plus metadata flags."""
|
|
180
|
+
row = get_doc_row(conn, doc_id)
|
|
181
|
+
meta = _row_to_meta(conn, row)
|
|
182
|
+
root = mirror_root if mirror_root is not None else Path("mirror")
|
|
183
|
+
content_id = doc_id
|
|
184
|
+
|
|
185
|
+
path = _content_path(conn, doc_id, fmt, root, fetch=True)
|
|
186
|
+
if path is None:
|
|
187
|
+
target = resolve(conn, doc_id) # e.g. BCP14 -> RFC2119
|
|
188
|
+
if target != doc_id:
|
|
189
|
+
content_id = target
|
|
190
|
+
path = _content_path(conn, target, fmt, root, fetch=True)
|
|
191
|
+
|
|
192
|
+
if path is None:
|
|
193
|
+
return {
|
|
194
|
+
"doc_id": doc_id,
|
|
195
|
+
"metadata": meta,
|
|
196
|
+
"content": None,
|
|
197
|
+
"note": f"no {fmt.upper()} rendering available from RFC Editor "
|
|
198
|
+
f"for this document; try format='txt' or get_metadata",
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
text = path.read_text(encoding="utf-8", errors="replace")
|
|
202
|
+
if text.startswith("\ufeff"):
|
|
203
|
+
text = text.lstrip("\ufeff")
|
|
204
|
+
lines = text.splitlines()
|
|
205
|
+
total = len(lines)
|
|
206
|
+
start = max(1, start_line)
|
|
207
|
+
chunk = "\n".join(lines[start - 1: start - 1 + max_lines])
|
|
208
|
+
return {
|
|
209
|
+
"doc_id": doc_id,
|
|
210
|
+
"content_served_via": content_id if content_id != doc_id else None,
|
|
211
|
+
"format": fmt,
|
|
212
|
+
"path": str(path),
|
|
213
|
+
"total_lines": total,
|
|
214
|
+
"start_line": start,
|
|
215
|
+
"end_line": min(total, start + max_lines - 1),
|
|
216
|
+
"truncated": start - 1 + max_lines < total,
|
|
217
|
+
"content": chunk,
|
|
218
|
+
"is_obsolete": not bool(row["is_current"]),
|
|
219
|
+
"obsoleted_by": meta["relations"].get("obsoleted_by", []),
|
|
220
|
+
"metadata": {k: meta[k] for k in
|
|
221
|
+
("title", "status", "current_status", "stream", "pub_date",
|
|
222
|
+
"authors", "doi", "errata_url", "canonical_url")},
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def span_hash(text: str) -> str:
|
|
227
|
+
"""16 hex chars of sha256 over the canonical span text (what quote()
|
|
228
|
+
returns after page-furniture stripping). Short enough to live in a code
|
|
229
|
+
comment, long enough to detect drift; files.sha256 stays authoritative
|
|
230
|
+
for the whole document."""
|
|
231
|
+
return hashlib.sha256(text.encode("utf-8", "replace")).hexdigest()[:16]
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _url_of(meta: dict) -> str:
|
|
235
|
+
return meta.get("canonical_url") or ""
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def anchor_url(meta: dict, frag: str = "", served: str | None = None) -> str:
|
|
239
|
+
"""URL that honours a section/paragraph fragment. The /info/ page has no
|
|
240
|
+
section anchors, so fragments point at the RFC's HTML rendering (of the
|
|
241
|
+
RFC that carries the text, for BCP/STD aliases)."""
|
|
242
|
+
if not frag:
|
|
243
|
+
return _url_of(meta)
|
|
244
|
+
doc = served or meta["doc_id"]
|
|
245
|
+
if not doc.startswith("RFC"):
|
|
246
|
+
return _url_of(meta)
|
|
247
|
+
return f"{BASE_URL}/rfc/{doc.lower()}.html#{frag}"
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _served_text(conn: sqlite3.Connection, doc_id: str, fmt: str,
|
|
251
|
+
mirror_root: Path) -> tuple[str | None, str, Path | None]:
|
|
252
|
+
"""(text, content_doc_id, path), following BCP/STD aliases to the RFC
|
|
253
|
+
that carries the text. Missing files are fetched on demand."""
|
|
254
|
+
path = _content_path(conn, doc_id, fmt, mirror_root, fetch=True)
|
|
255
|
+
served = doc_id
|
|
256
|
+
if path is None:
|
|
257
|
+
target = resolve(conn, doc_id)
|
|
258
|
+
if target != doc_id:
|
|
259
|
+
served = target
|
|
260
|
+
path = _content_path(conn, target, fmt, mirror_root, fetch=True)
|
|
261
|
+
if path is None:
|
|
262
|
+
return None, served, None
|
|
263
|
+
text = path.read_text(encoding="utf-8", errors="replace")
|
|
264
|
+
return text.lstrip("\ufeff"), served, path
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _fit(body: str, max_chars: int) -> tuple[str, int | None]:
|
|
268
|
+
"""Cut a text block at a line boundary; return (text, lines_shown+1)."""
|
|
269
|
+
if len(body) <= max_chars:
|
|
270
|
+
return body, None
|
|
271
|
+
cut = body.rfind("\n", 0, max_chars)
|
|
272
|
+
if cut <= 0:
|
|
273
|
+
cut = max_chars
|
|
274
|
+
shown = body[:cut]
|
|
275
|
+
return shown, shown.count("\n") + 1
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def read_document(conn: sqlite3.Connection, doc_id: str, *,
|
|
279
|
+
mirror_root: Path, fmt: str = "txt",
|
|
280
|
+
section: str | None = None, start_line: int | None = None,
|
|
281
|
+
count: int | None = None, mode: str = "clean",
|
|
282
|
+
max_chars: int = DEFAULT_READ_CHARS,
|
|
283
|
+
deep: bool = True) -> str:
|
|
284
|
+
"""Plain text of a document or ONE section of it — returned as a string,
|
|
285
|
+
because JSON-escaping a text block roughly doubles what it costs to read.
|
|
286
|
+
|
|
287
|
+
section: '3.4', 'Appendix C', 'C.1', a range ('3.1/3.3') or a title
|
|
288
|
+
substring ('ABNF') — resolved through the section index, so there is no
|
|
289
|
+
line arithmetic and no page furniture to pay for.
|
|
290
|
+
mode: 'clean' (default) drops running headers/footers and [Page N]
|
|
291
|
+
markers (chunker.strip_page_furniture); 'raw' is byte-exact, which is
|
|
292
|
+
what quote()/verify() use.
|
|
293
|
+
max_chars is a hard budget and the result always ends with a footer
|
|
294
|
+
naming the line to continue from — nothing disappears silently.
|
|
295
|
+
Non-txt formats come back as a raw slice (section addressing is txt-only).
|
|
296
|
+
"""
|
|
297
|
+
from .index import search as _s
|
|
298
|
+
from .index.chunker import strip_page_furniture
|
|
299
|
+
row = get_doc_row(conn, doc_id)
|
|
300
|
+
text, served, _path = _served_text(conn, doc_id, fmt, mirror_root)
|
|
301
|
+
meta = _row_to_meta(conn, row)
|
|
302
|
+
if text is None:
|
|
303
|
+
return (f"NO CONTENT: {doc_id} has no {fmt.upper()} rendering at the "
|
|
304
|
+
f"RFC Editor. Try format='txt', or get_metadata.")
|
|
305
|
+
obsolete = "" if meta["is_current"] else (
|
|
306
|
+
" | OBSOLETE (superseded by "
|
|
307
|
+
+ (", ".join(meta["relations"].get("obsoleted_by", [])) or "n/a") + ")")
|
|
308
|
+
lines = text.splitlines()
|
|
309
|
+
total = len(lines)
|
|
310
|
+
label, frag, sec_note = "", "", ""
|
|
311
|
+
|
|
312
|
+
if fmt != "txt":
|
|
313
|
+
start = max(1, start_line or 1)
|
|
314
|
+
end = min(total, start + (count or 200) - 1)
|
|
315
|
+
shown, nxt = _fit("\n".join(lines[start - 1:end]), max_chars)
|
|
316
|
+
return (f"{doc_id} ({fmt}) | lines {start}-{end} of {total}{obsolete}\n"
|
|
317
|
+
f"{_url_of(meta)}\n{shown}"
|
|
318
|
+
+ ("" if nxt is None else
|
|
319
|
+
f"\n[truncated at {max_chars} chars — continue with "
|
|
320
|
+
f"start_line={start + nxt}]"))
|
|
321
|
+
|
|
322
|
+
if section:
|
|
323
|
+
span = _resolve_section_arg(conn, served, section)
|
|
324
|
+
if not span.get("ok"):
|
|
325
|
+
extra = ""
|
|
326
|
+
if span.get("suggestions"):
|
|
327
|
+
extra = "\nsimilar sections: " + ", ".join(span["suggestions"])
|
|
328
|
+
if span.get("outline"):
|
|
329
|
+
extra += "\noutline: " + "; ".join(span["outline"][:20])
|
|
330
|
+
return (f"NO SUCH SECTION: {doc_id} {section}\n"
|
|
331
|
+
f"{span.get('reason')}{extra}\n"
|
|
332
|
+
f"retry: read(doc_id='{doc_id}', section='<one of above>')")
|
|
333
|
+
start, end = span["start_line"], span["end_line"]
|
|
334
|
+
if not deep:
|
|
335
|
+
rows = _s.section_rows(conn, served, section, deep=False)
|
|
336
|
+
if rows:
|
|
337
|
+
end = max(r["end_line"] for r in rows)
|
|
338
|
+
sec_note = " (own text only, deep=False)"
|
|
339
|
+
label = f" \u00a7{span['sec_num']}" + (
|
|
340
|
+
f" \"{span['sec_title']}\"" if span.get("sec_title") else "")
|
|
341
|
+
frag = span.get("url_frag", "")
|
|
342
|
+
else:
|
|
343
|
+
start = max(1, start_line or 1)
|
|
344
|
+
end = min(total, start + (count or 300) - 1)
|
|
345
|
+
|
|
346
|
+
body = "\n".join(lines[start - 1:end])
|
|
347
|
+
if mode == "clean":
|
|
348
|
+
body = strip_page_furniture(body)
|
|
349
|
+
shown, nxt = _fit(body, max_chars)
|
|
350
|
+
head = (f"{doc_id}{label} | lines {start}-{end} of {total}{sec_note} | "
|
|
351
|
+
f"mode={mode} | sha256:{span_hash(body)}{obsolete}\n"
|
|
352
|
+
f"{anchor_url(meta, frag, served)}\n")
|
|
353
|
+
foot = ""
|
|
354
|
+
if nxt is not None:
|
|
355
|
+
foot = (f"\n[truncated at {max_chars} chars — continue with "
|
|
356
|
+
f"start_line={start + nxt}"
|
|
357
|
+
+ (f", section='{section}'" if section else "") + "]")
|
|
358
|
+
return head + shown + foot
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def _resolve_section_arg(conn: sqlite3.Connection, doc_id: str,
|
|
362
|
+
section: str) -> dict:
|
|
363
|
+
"""resolve_span plus two conveniences callers always want: a range
|
|
364
|
+
('3.1/3.3') and a title substring ('ABNF', 'Security Considerations')."""
|
|
365
|
+
from .index import search as _s
|
|
366
|
+
if not section.strip().startswith("Appendix"):
|
|
367
|
+
for sep in ("-", "\u2013", "/"):
|
|
368
|
+
if sep not in section:
|
|
369
|
+
continue
|
|
370
|
+
a, b = [p.strip() for p in section.split(sep, 1)][:2]
|
|
371
|
+
r1 = _s.resolve_span(conn, doc_id, a)
|
|
372
|
+
r2 = _s.resolve_span(conn, doc_id, b)
|
|
373
|
+
if r1.get("ok") and r2.get("ok"):
|
|
374
|
+
return {"ok": True,
|
|
375
|
+
"sec_num": f"{r1['sec_num']}-{r2['sec_num']}",
|
|
376
|
+
"sec_title": "range", "start_line": r1["start_line"],
|
|
377
|
+
"end_line": r2["end_line"],
|
|
378
|
+
"url_frag": r1["url_frag"], "subsections": []}
|
|
379
|
+
span = _s.resolve_span(conn, doc_id, section)
|
|
380
|
+
if not span.get("ok") and span.get("indexed"):
|
|
381
|
+
like = f"%{(section or '').strip().lower()}%"
|
|
382
|
+
row = conn.execute(
|
|
383
|
+
"SELECT sec_num, MIN(start_line) a, MAX(end_line) b FROM chunks "
|
|
384
|
+
"WHERE doc_id=? AND lower(sec_title) LIKE ? AND sec_num <> '' "
|
|
385
|
+
"GROUP BY sec_num ORDER BY a", (doc_id, like)).fetchone()
|
|
386
|
+
if row:
|
|
387
|
+
return {"ok": True, "sec_num": row["sec_num"],
|
|
388
|
+
"sec_title": section.strip().title(),
|
|
389
|
+
"start_line": row["a"], "end_line": row["b"],
|
|
390
|
+
"url_frag": _s.url_frag(row["sec_num"]),
|
|
391
|
+
"subsections": []}
|
|
392
|
+
return span
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def grep_document(conn: sqlite3.Connection, doc_id: str, pattern: str, *,
|
|
396
|
+
mirror_root: Path, ctx: int = 0, max_hits: int = 25,
|
|
397
|
+
ignore_case: bool = True,
|
|
398
|
+
max_chars: int = DEFAULT_READ_CHARS) -> str:
|
|
399
|
+
"""Every line matching a regex inside ONE document, labelled with the
|
|
400
|
+
section it belongs to — the cheap way to locate an ABNF rule or keyword
|
|
401
|
+
instead of paging through a spec hoping to land on it.
|
|
402
|
+
|
|
403
|
+
Invalid regexes fall back to a literal search. A hit is citable as-is
|
|
404
|
+
(section + line); follow up with read(section=...) rather than widening
|
|
405
|
+
the grep.
|
|
406
|
+
"""
|
|
407
|
+
import bisect
|
|
408
|
+
import re as _re
|
|
409
|
+
get_doc_row(conn, doc_id)
|
|
410
|
+
try:
|
|
411
|
+
rx = _re.compile(pattern, _re.I if ignore_case else 0)
|
|
412
|
+
shown_pat = f"/{pattern}/"
|
|
413
|
+
except _re.error:
|
|
414
|
+
rx = _re.compile(_re.escape(pattern), _re.I if ignore_case else 0)
|
|
415
|
+
shown_pat = f"{pattern!r} (literal)"
|
|
416
|
+
text, served, _p = _served_text(conn, doc_id, "txt", mirror_root)
|
|
417
|
+
if text is None:
|
|
418
|
+
return f"NO CONTENT: {doc_id} has no local text copy."
|
|
419
|
+
lines = text.splitlines()
|
|
420
|
+
marks = [(r["start_line"],
|
|
421
|
+
(f"{r['sec_num']} {r['sec_title']}".strip() or "front matter"))
|
|
422
|
+
for r in conn.execute(
|
|
423
|
+
"SELECT DISTINCT start_line, sec_num, sec_title FROM chunks "
|
|
424
|
+
"WHERE doc_id=? ORDER BY start_line", (served,))]
|
|
425
|
+
starts = [m[0] for m in marks]
|
|
426
|
+
|
|
427
|
+
def sec_of(n: int) -> str:
|
|
428
|
+
i = bisect.bisect_right(starts, n) - 1
|
|
429
|
+
return marks[i][1] if i >= 0 else "(not section-indexed)"
|
|
430
|
+
|
|
431
|
+
out, hits, shown_hits, i = [], 0, 0, 0
|
|
432
|
+
while i < len(lines):
|
|
433
|
+
if not rx.search(lines[i]):
|
|
434
|
+
i += 1
|
|
435
|
+
continue
|
|
436
|
+
hits += 1
|
|
437
|
+
if shown_hits < max_hits:
|
|
438
|
+
lo, hi = max(0, i - ctx), min(len(lines), i + ctx + 1)
|
|
439
|
+
out.append(f"{i + 1} [{sec_of(i + 1)}]")
|
|
440
|
+
out.extend((">> " if j == i else " ") + lines[j][:160]
|
|
441
|
+
for j in range(lo, hi))
|
|
442
|
+
shown_hits += 1
|
|
443
|
+
i += 1
|
|
444
|
+
if not hits:
|
|
445
|
+
return (f"NO MATCH for {shown_pat} in {doc_id} ({len(lines)} lines). "
|
|
446
|
+
f"Try search_content to search the whole corpus, or loosen "
|
|
447
|
+
f"the pattern.")
|
|
448
|
+
body, _n = _fit("\n".join(out), max_chars)
|
|
449
|
+
more = (f"\n[showing first {shown_hits} of {hits} matches — narrow the "
|
|
450
|
+
f"pattern, or read the section above]" if shown_hits < hits else "")
|
|
451
|
+
return (f"{hits} match(es) for {shown_pat} in {doc_id} "
|
|
452
|
+
f"[{shown_hits} shown]\n{body}{more}")
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def list_recent(conn: sqlite3.Connection, n: int = 20,
|
|
456
|
+
series: str = "RFC") -> list[dict]:
|
|
457
|
+
n = max(1, min(n, 100))
|
|
458
|
+
rows = conn.execute(
|
|
459
|
+
"""SELECT * FROM docs
|
|
460
|
+
WHERE series = ?
|
|
461
|
+
ORDER BY pub_date DESC,
|
|
462
|
+
CAST(SUBSTR(doc_id, LENGTH(series) + 1) AS INTEGER) DESC
|
|
463
|
+
LIMIT ?""",
|
|
464
|
+
(series.upper(), n)).fetchall()
|
|
465
|
+
return [_row_to_meta(conn, r) for r in rows]
|
|
466
|
+
|