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,1067 @@
|
|
|
1
|
+
"""Agent-facing views over the library, addressed by citation refs.
|
|
2
|
+
|
|
3
|
+
One reference grammar is used everywhere, and every view both accepts and
|
|
4
|
+
emits it, so a page footer's "next:" IS the citation of where the next page
|
|
5
|
+
starts (resumable, readable, and citable as-is):
|
|
6
|
+
|
|
7
|
+
RFC9000 whole document (header, abstract, outline)
|
|
8
|
+
RFC9000§9.6 a section, subsections included
|
|
9
|
+
RFC9000§9.6¶3 paragraph 3 of §9.6 (RFCXML pn ids for RFC
|
|
10
|
+
8650+, txtseg paragraphs for older RFCs)
|
|
11
|
+
RFC9000#section-9.6-3 the same paragraph by its RFC Editor id/anchor
|
|
12
|
+
RFC9000#table-2 any RFCXML block id (tables, figures, refs)
|
|
13
|
+
RFC9000:2964-2979 a txt line range (any document)
|
|
14
|
+
|
|
15
|
+
Accepted spellings: 'rfc 9000', 'BCP14' (aliases follow to the RFC),
|
|
16
|
+
'section 9.6' / 's9.6' for §, 'p3' / 'para 3' for ¶.
|
|
17
|
+
|
|
18
|
+
Pages are cut on block (paragraph) boundaries within a character budget,
|
|
19
|
+
never mid-sentence, and text is the RFC Editor's own txt minus page
|
|
20
|
+
furniture, so line numbers stay true to the canonical file. Everything
|
|
21
|
+
returns plain text: JSON-escaping prose costs tokens for nothing.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import bisect
|
|
27
|
+
import re
|
|
28
|
+
import sqlite3
|
|
29
|
+
import sys
|
|
30
|
+
from dataclasses import dataclass
|
|
31
|
+
from pathlib import Path
|
|
32
|
+
|
|
33
|
+
from primarysource.errors import LibError
|
|
34
|
+
|
|
35
|
+
from . import cite as _cite
|
|
36
|
+
from . import query as q
|
|
37
|
+
from .index import search as _s
|
|
38
|
+
from .index.rfcxml import _FURNITURE
|
|
39
|
+
|
|
40
|
+
CHARS_PER_TOKEN = 4
|
|
41
|
+
NORM_RE = re.compile(r"\b(MUST NOT|MUST|SHALL NOT|SHALL|SHOULD NOT|SHOULD|"
|
|
42
|
+
r"NOT RECOMMENDED|RECOMMENDED|MAY|REQUIRED|OPTIONAL)\b")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class RefError(LibError, ValueError):
|
|
46
|
+
"""Bad or unresolvable reference, or a library not ready to answer. A
|
|
47
|
+
`LibError`: `hint` names the tool call to make next and `action` the
|
|
48
|
+
command a human runs (PLAN.md §5.10); the MCP server adds the ref forms
|
|
49
|
+
to a ref problem that names neither."""
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _fetch_failed(exc: BaseException) -> bool:
|
|
53
|
+
"""A fetch from rfc-editor.org failed: an OSError, or an httpx error.
|
|
54
|
+
httpx is imported only by the code that fetches, so when it is not
|
|
55
|
+
loaded no httpx error can have been raised."""
|
|
56
|
+
if isinstance(exc, OSError):
|
|
57
|
+
return True
|
|
58
|
+
httpx = sys.modules.get("httpx")
|
|
59
|
+
return httpx is not None and isinstance(exc, httpx.HTTPError)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass
|
|
63
|
+
class Ref:
|
|
64
|
+
doc: str # as asked ('BCP14')
|
|
65
|
+
served: str # document carrying the text ('RFC2119')
|
|
66
|
+
sec: str = "" # canonical sec_num
|
|
67
|
+
para: int | None = None
|
|
68
|
+
pn: str = "" # RFCXML block id
|
|
69
|
+
start: int | None = None
|
|
70
|
+
end: int | None = None
|
|
71
|
+
|
|
72
|
+
def is_doc(self) -> bool:
|
|
73
|
+
return not (self.sec or self.pn or self.para or self.start)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
_REF = re.compile(
|
|
77
|
+
r"^\s*(?P<doc>(?:rfc|bcp|std|fyi)?[\s\-_]*\d{1,5})\s*"
|
|
78
|
+
r"(?:(?:§|section\s+|sect?\.?\s*|s(?=\d|[A-Z]\b|[A-Z]\.))\s*"
|
|
79
|
+
r"(?P<sec>[^¶:#]+?))?\s*"
|
|
80
|
+
r"(?:(?:¶|para\s*|p)\s*(?P<para>\d+))?\s*"
|
|
81
|
+
r"(?:#(?P<pn>[\w.\-]+))?\s*"
|
|
82
|
+
r"(?::\s*(?P<a>\d+)(?:\s*[-–]\s*(?P<b>\d+))?)?\s*$", re.I)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _require_library(conn: sqlite3.Connection) -> None:
|
|
86
|
+
from . import library
|
|
87
|
+
if not library.initialized(conn):
|
|
88
|
+
raise RefError(*library.not_initialized(conn))
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _ensure_text(conn: sqlite3.Connection, doc: str) -> None:
|
|
92
|
+
"""Fetch + index a document on first use (see rfclib.library)."""
|
|
93
|
+
from . import library
|
|
94
|
+
from .config import NetworkDisabled
|
|
95
|
+
try:
|
|
96
|
+
library.ensure_indexed(conn, doc)
|
|
97
|
+
except NetworkDisabled as e:
|
|
98
|
+
raise RefError(e.message, e.hint, e.action) from None
|
|
99
|
+
except Exception as e:
|
|
100
|
+
if not _fetch_failed(e):
|
|
101
|
+
raise
|
|
102
|
+
raise RefError(f"could not fetch {doc} from rfc-editor.org ({e})",
|
|
103
|
+
"retry the same call later",
|
|
104
|
+
"rfclib sync (while online)") from None
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def parse_ref(conn: sqlite3.Connection, raw: str,
|
|
108
|
+
need_text: bool = True) -> Ref:
|
|
109
|
+
"""need_text: fetch and index the document if it is not local yet
|
|
110
|
+
(False for views that only need the catalog, e.g. errata)."""
|
|
111
|
+
m = _REF.match(raw or "")
|
|
112
|
+
if not m:
|
|
113
|
+
raise RefError(f"can't parse ref {raw!r}")
|
|
114
|
+
_require_library(conn)
|
|
115
|
+
try:
|
|
116
|
+
doc = q.normalize_doc_id(m.group("doc"))
|
|
117
|
+
q.get_doc_row(conn, doc)
|
|
118
|
+
except (ValueError, q.NotFound) as e:
|
|
119
|
+
raise RefError(f"unknown document in {raw!r}: {e}") from None
|
|
120
|
+
if need_text:
|
|
121
|
+
_ensure_text(conn, q.resolve(conn, doc))
|
|
122
|
+
sec = _s.norm_sec(m.group("sec")) if m.group("sec") else ""
|
|
123
|
+
ref = Ref(doc, q.resolve(conn, doc), sec,
|
|
124
|
+
int(m.group("para")) if m.group("para") else None,
|
|
125
|
+
m.group("pn") or "",
|
|
126
|
+
int(m.group("a")) if m.group("a") else None,
|
|
127
|
+
int(m.group("b")) if m.group("b") else None)
|
|
128
|
+
head = _heading_by_pn(conn, ref.served, ref.pn) if ref.pn else None
|
|
129
|
+
if head is not None:
|
|
130
|
+
# '#section-9.6' is a section anchor (html), not a paragraph
|
|
131
|
+
ref.sec, ref.pn = head["sec_num"], ""
|
|
132
|
+
if not ref.sec: # unnumbered section: address it by lines
|
|
133
|
+
ref.start, ref.end = head["start_line"], head["end_line"]
|
|
134
|
+
elif ref.pn.startswith("section-") and not has_xml(conn, ref.served):
|
|
135
|
+
m2 = re.fullmatch(r"section-(\d+(?:\.\d+)*)", ref.pn)
|
|
136
|
+
if m2: # txt-only doc, html-style section anchor
|
|
137
|
+
ref.sec, ref.pn = m2.group(1), ""
|
|
138
|
+
if ref.para is not None:
|
|
139
|
+
if not ref.sec:
|
|
140
|
+
raise RefError(f"¶ needs a section: e.g. '{doc}§9.6¶{ref.para}'")
|
|
141
|
+
h = _heading(conn, ref.served, ref.sec)
|
|
142
|
+
if h is None:
|
|
143
|
+
raise RefError(
|
|
144
|
+
f"{ref.served} is not paragraph-indexed (run `rfclib index "
|
|
145
|
+
f"build`). Use a section or line ref instead.")
|
|
146
|
+
ref.pn = f"{h['pn']}-{ref.para}"
|
|
147
|
+
if not _block(conn, ref.served, ref.pn):
|
|
148
|
+
n = conn.execute(
|
|
149
|
+
"SELECT COUNT(*) FROM blocks WHERE doc_id=? AND pn LIKE ? "
|
|
150
|
+
"AND kind<>'heading'", (ref.served, h["pn"] + "-%")
|
|
151
|
+
).fetchone()[0]
|
|
152
|
+
raise RefError(f"no ¶{ref.para} in {doc}§{ref.sec} "
|
|
153
|
+
f"(it has {n} top-level blocks)")
|
|
154
|
+
elif ref.pn and not _block(conn, ref.served, ref.pn):
|
|
155
|
+
raise RefError(f"no block #{ref.pn} in {ref.served}")
|
|
156
|
+
return ref
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
# --------------------------------------------------------------------------
|
|
160
|
+
# lookups
|
|
161
|
+
|
|
162
|
+
def _block(conn, doc: str, pn: str):
|
|
163
|
+
return conn.execute("SELECT * FROM blocks WHERE doc_id=? AND pn=?",
|
|
164
|
+
(doc, pn)).fetchone()
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _heading(conn, doc: str, sec: str):
|
|
168
|
+
return conn.execute(
|
|
169
|
+
"SELECT * FROM blocks WHERE doc_id=? AND kind='heading' AND sec_num=?",
|
|
170
|
+
(doc, sec)).fetchone() if sec else None
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _heading_by_pn(conn, doc: str, pn: str):
|
|
174
|
+
return conn.execute(
|
|
175
|
+
"SELECT * FROM blocks WHERE doc_id=? AND kind='heading' AND pn=?",
|
|
176
|
+
(doc, pn)).fetchone()
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _blocks_in(conn, doc: str, a: int, b: int) -> list[sqlite3.Row]:
|
|
180
|
+
return conn.execute(
|
|
181
|
+
"SELECT * FROM blocks WHERE doc_id=? AND kind<>'heading' AND "
|
|
182
|
+
"start_line>=? AND start_line<=? ORDER BY ord", (doc, a, b)).fetchall()
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _frag(conn, doc: str, pn: str, sec: str) -> str:
|
|
186
|
+
"""URL fragment for a ref. Paragraph pn ids are real anchors only in
|
|
187
|
+
RFCXML-rendered HTML (RFC 8650+); the RFC Editor's HTML for older RFCs
|
|
188
|
+
anchors sections only, so their paragraphs link to the section."""
|
|
189
|
+
if pn and conn.execute("SELECT 1 FROM files WHERE doc_id=? AND fmt='xml'",
|
|
190
|
+
(doc,)).fetchone():
|
|
191
|
+
return pn
|
|
192
|
+
return _s.url_frag(sec) if sec else ""
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def has_xml(conn, doc: str) -> bool:
|
|
196
|
+
return conn.execute("SELECT 1 FROM blocks WHERE doc_id=? LIMIT 1",
|
|
197
|
+
(doc,)).fetchone() is not None
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def block_ref(doc: str, row) -> str:
|
|
201
|
+
"""'RFC9000§9.6¶3' when the block is a numbered paragraph of a numbered
|
|
202
|
+
section, else 'RFC9000#<pn>'."""
|
|
203
|
+
pn, sec = row["pn"], row["sec_num"]
|
|
204
|
+
m = re.fullmatch(r"(section-.+)-(\d+)", pn)
|
|
205
|
+
if sec and m and row["kind"] != "heading":
|
|
206
|
+
return f"{doc}§{sec}¶{m.group(2)}"
|
|
207
|
+
return f"{doc}#{pn}"
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def sec_ref(doc: str, sec: str, a: int | None = None,
|
|
211
|
+
b: int | None = None) -> str:
|
|
212
|
+
if sec:
|
|
213
|
+
return f"{doc}§{sec}"
|
|
214
|
+
return f"{doc}:{a}-{b}" if a else doc
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _meta(conn, doc: str) -> dict:
|
|
218
|
+
return q._row_to_meta(conn, q.get_doc_row(conn, doc))
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _doc_line(conn, ref: Ref, meta: dict) -> str:
|
|
222
|
+
if meta["is_current"]:
|
|
223
|
+
state = "current"
|
|
224
|
+
else:
|
|
225
|
+
direct = meta["relations"].get("obsoleted_by", [])
|
|
226
|
+
now = current_successors(conn, ref.served)
|
|
227
|
+
state = ("OBSOLETE, superseded by " + (", ".join(direct) or "?")
|
|
228
|
+
+ (f" (current: {', '.join(now)})"
|
|
229
|
+
if now and set(now) != set(direct) else ""))
|
|
230
|
+
via = f" (via {ref.doc})" if ref.doc != ref.served else ""
|
|
231
|
+
return (f"{_cite.label(ref.served)}{via} — {meta.get('title') or ''} | "
|
|
232
|
+
f"{meta.get('current_status') or meta.get('status') or ''} | "
|
|
233
|
+
f"{(meta.get('pub_date') or '')[:7]} | {state}")
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _clean_lines(lines: list[str], a: int, b: int) -> list[tuple[int, str]]:
|
|
237
|
+
"""(line_no, text) for [a, b] minus page furniture, blank runs -> one."""
|
|
238
|
+
out: list[tuple[int, str]] = []
|
|
239
|
+
blank = False
|
|
240
|
+
for n in range(a, min(b, len(lines)) + 1):
|
|
241
|
+
ln = lines[n - 1].replace("\f", "").rstrip()
|
|
242
|
+
if any(p.search(ln) for p in _FURNITURE):
|
|
243
|
+
continue
|
|
244
|
+
if not ln.strip():
|
|
245
|
+
if out and not blank:
|
|
246
|
+
blank = True
|
|
247
|
+
continue
|
|
248
|
+
if blank:
|
|
249
|
+
out.append((0, ""))
|
|
250
|
+
blank = False
|
|
251
|
+
out.append((n, ln))
|
|
252
|
+
return out
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _text(conn, ref: Ref, mirror_root: Path) -> list[str]:
|
|
256
|
+
text, _served, _p = q._served_text(conn, ref.served, "txt", mirror_root)
|
|
257
|
+
if text is None:
|
|
258
|
+
raise RefError(f"{ref.served} has no txt rendering locally or "
|
|
259
|
+
"upstream", f"read('{ref.served}', format='html')")
|
|
260
|
+
return text.splitlines()
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _scope(conn, ref: Ref, total: int) -> tuple[int, int, str, str]:
|
|
264
|
+
"""(start, end, sec_num, sec_title) the ref addresses; a paragraph ref
|
|
265
|
+
scopes to its enclosing section, starting at the paragraph."""
|
|
266
|
+
if ref.pn:
|
|
267
|
+
b = _block(conn, ref.served, ref.pn)
|
|
268
|
+
h = _heading(conn, ref.served, b["sec_num"]) if b["sec_num"] else None
|
|
269
|
+
if h is None: # unnumbered section: run to the block's own section
|
|
270
|
+
return b["start_line"], b["end_line"], b["sec_num"], b["sec_title"]
|
|
271
|
+
return b["start_line"], h["end_line"], b["sec_num"], b["sec_title"]
|
|
272
|
+
if ref.sec:
|
|
273
|
+
span = q._resolve_section_arg(conn, ref.served, ref.sec)
|
|
274
|
+
if not span.get("ok"):
|
|
275
|
+
similar = ""
|
|
276
|
+
if span.get("suggestions"):
|
|
277
|
+
similar = "; similar: " + ", ".join(
|
|
278
|
+
f"{ref.doc}§{x}" for x in span["suggestions"])
|
|
279
|
+
raise RefError(f"no section {ref.sec} in {ref.served}{similar}",
|
|
280
|
+
f"outline('{ref.doc}')")
|
|
281
|
+
return (span["start_line"], span["end_line"], span["sec_num"],
|
|
282
|
+
span.get("sec_title") or "")
|
|
283
|
+
a = max(1, ref.start or 1)
|
|
284
|
+
b = min(total, ref.end or total)
|
|
285
|
+
if b < a:
|
|
286
|
+
raise RefError(f"empty line range {a}-{b} ({ref.served} has "
|
|
287
|
+
f"{total} lines)")
|
|
288
|
+
return a, b, "", ""
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _neighbours(conn, ref: Ref, sec: str, end: int) -> str:
|
|
292
|
+
rows = _s.toc(conn, ref.served)
|
|
293
|
+
nxt = next((r for r in rows if r["start_line"] > end and r["sec_num"]),
|
|
294
|
+
None)
|
|
295
|
+
bits = []
|
|
296
|
+
if sec and "." in sec:
|
|
297
|
+
bits.append(f"up: {ref.doc}§{sec.rsplit('.', 1)[0]}")
|
|
298
|
+
if nxt:
|
|
299
|
+
bits.append(f"after: {ref.doc}§{nxt['sec_num']} "
|
|
300
|
+
f"\"{nxt['title']}\"")
|
|
301
|
+
return " | ".join(bits)
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
# --------------------------------------------------------------------------
|
|
305
|
+
# read
|
|
306
|
+
|
|
307
|
+
def read(conn: sqlite3.Connection, raw: str, *, mirror_root: Path,
|
|
308
|
+
max_tokens: int = 2000, view: str = "text",
|
|
309
|
+
lang: str | None = None) -> str:
|
|
310
|
+
ref = parse_ref(conn, raw)
|
|
311
|
+
view = (view or "text").lower()
|
|
312
|
+
if view not in ("text", "normative", "code", "refs"):
|
|
313
|
+
raise RefError("view must be text | normative | code | refs")
|
|
314
|
+
if view == "refs":
|
|
315
|
+
return links(conn, raw, direction="out")
|
|
316
|
+
budget = max(400, int(max_tokens) * CHARS_PER_TOKEN)
|
|
317
|
+
meta = _meta(conn, ref.served)
|
|
318
|
+
if ref.is_doc() and view == "text":
|
|
319
|
+
return _doc_overview(conn, ref, meta, mirror_root)
|
|
320
|
+
lines = _text(conn, ref, mirror_root)
|
|
321
|
+
a, b, sec, title = _scope(conn, ref, len(lines))
|
|
322
|
+
head = _doc_line(conn, ref, meta)
|
|
323
|
+
label = sec_ref(ref.doc, sec) if sec else f"{ref.doc}:{a}-{b}"
|
|
324
|
+
frag = _frag(conn, ref.served, ref.pn, sec)
|
|
325
|
+
errata = _cite.errata_note(conn, ref.served, sec)
|
|
326
|
+
top = (f"{head}\n{label}" + (f' "{title}"' if title else "")
|
|
327
|
+
+ f" | lines {a}-{b} | {errata}\n"
|
|
328
|
+
f"{q.anchor_url(meta, frag, ref.served)}\n")
|
|
329
|
+
if view == "normative":
|
|
330
|
+
body, nxt = _normative(conn, ref, lines, a, b, budget)
|
|
331
|
+
elif view == "code":
|
|
332
|
+
body, nxt = _code(conn, ref, a, b, budget, lang)
|
|
333
|
+
else:
|
|
334
|
+
body, nxt = _page(conn, ref, lines, a, b, budget)
|
|
335
|
+
foot = [f"next: {nxt}" if nxt else "end of scope"]
|
|
336
|
+
nb = _neighbours(conn, ref, sec, b)
|
|
337
|
+
if nb:
|
|
338
|
+
foot.append(nb)
|
|
339
|
+
return top + "\n" + body.rstrip() + "\n\n— " + " | ".join(foot)
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def _page(conn, ref: Ref, lines: list[str], a: int, b: int,
|
|
343
|
+
budget: int) -> tuple[str, str | None]:
|
|
344
|
+
"""Text of [a, b] up to budget, cut on unit boundaries.
|
|
345
|
+
|
|
346
|
+
Units are RFCXML blocks (each preceded by a [§x¶n] marker, so every
|
|
347
|
+
paragraph on the page is citable) and subsection headings; txt-only
|
|
348
|
+
documents fall back to blank-line paragraphs. The next-page cursor is a
|
|
349
|
+
¶ ref while the page stays inside the scope's own section, else a line
|
|
350
|
+
range to the end of the scope, so paging a section never stops at the
|
|
351
|
+
end of its first subsection."""
|
|
352
|
+
marks: dict[int, tuple[str, str]] = {} # line -> (label, sec_num)
|
|
353
|
+
if has_xml(conn, ref.served):
|
|
354
|
+
for blk in _blocks_in(conn, ref.served, a, b):
|
|
355
|
+
marks.setdefault(blk["start_line"], (
|
|
356
|
+
block_ref(ref.doc, blk)[len(ref.doc):], blk["sec_num"]))
|
|
357
|
+
for h in conn.execute(
|
|
358
|
+
"SELECT sec_num, start_line FROM blocks WHERE doc_id=? AND "
|
|
359
|
+
"kind='heading' AND start_line>? AND start_line<=?",
|
|
360
|
+
(ref.served, a, b)):
|
|
361
|
+
marks[h["start_line"]] = ("", h["sec_num"])
|
|
362
|
+
units: list[tuple[str | None, str, list[tuple[int, str]]]] = []
|
|
363
|
+
for n, ln in _clean_lines(lines, a, b):
|
|
364
|
+
if not units or n in marks or (not marks and n == 0):
|
|
365
|
+
lab, sec = marks.get(n, (None, ""))
|
|
366
|
+
units.append((lab, sec, []))
|
|
367
|
+
units[-1][2].append((n, ln))
|
|
368
|
+
own_sec = ref.sec or (_block(conn, ref.served, ref.pn)["sec_num"]
|
|
369
|
+
if ref.pn else "")
|
|
370
|
+
out: list[str] = []
|
|
371
|
+
size = 0
|
|
372
|
+
for lab, sec, ls in units:
|
|
373
|
+
first = next((n for n, _ in ls if n), b)
|
|
374
|
+
usize = sum(len(x) + 1 for _, x in ls)
|
|
375
|
+
if out and size + usize > budget:
|
|
376
|
+
if lab and sec == own_sec:
|
|
377
|
+
return "\n".join(out), f"{ref.doc}{lab}"
|
|
378
|
+
return "\n".join(out), (f"{ref.doc}:{first}-{b}"
|
|
379
|
+
+ (f" (starts at {lab or '§' + sec})"
|
|
380
|
+
if sec else ""))
|
|
381
|
+
if lab:
|
|
382
|
+
out.append(f"[{lab}]")
|
|
383
|
+
for n, x in ls:
|
|
384
|
+
if out and size + len(x) > budget * 1.5 and n:
|
|
385
|
+
# one unit bigger than a page (giant table/figure): lines
|
|
386
|
+
return "\n".join(out), f"{ref.doc}:{n}-{b}"
|
|
387
|
+
out.append(x)
|
|
388
|
+
size += len(x) + 1
|
|
389
|
+
return "\n".join(out), None
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def _cursor(conn, ref: Ref, row, b: int) -> str:
|
|
393
|
+
"""Next-page ref for block `row`: its own ref while it belongs to the
|
|
394
|
+
scope's section (a block ref scopes to its OWN section, so a block in a
|
|
395
|
+
subsection would end the paging early), else a line range to b."""
|
|
396
|
+
own = ref.sec or (_block(conn, ref.served, ref.pn)["sec_num"]
|
|
397
|
+
if ref.pn else None)
|
|
398
|
+
if own is not None and row["sec_num"] == own:
|
|
399
|
+
return block_ref(ref.doc, row)
|
|
400
|
+
return f"{ref.doc}:{row['start_line']}-{b}"
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def _normative(conn, ref: Ref, lines, a, b, budget):
|
|
404
|
+
out, size = [], 0
|
|
405
|
+
if has_xml(conn, ref.served):
|
|
406
|
+
rows = [r for r in _blocks_in(conn, ref.served, a, b) if r["bcp14"]]
|
|
407
|
+
for r in rows:
|
|
408
|
+
item = f"[{block_ref(ref.doc, r)}] ({r['bcp14']}) {r['text']}"
|
|
409
|
+
if out and size + len(item) > budget:
|
|
410
|
+
return "\n\n".join(out), _cursor(conn, ref, r, b) + \
|
|
411
|
+
" (view=normative)"
|
|
412
|
+
out.append(item)
|
|
413
|
+
size += len(item) + 2
|
|
414
|
+
if not out:
|
|
415
|
+
return "(no BCP 14 keywords in this scope)", None
|
|
416
|
+
return "\n\n".join(out), None
|
|
417
|
+
# txt-only: uppercase keyword sentences (RFC 2119 convention; older
|
|
418
|
+
# documents may use the words loosely)
|
|
419
|
+
text = " ".join(t for _n, t in _clean_lines(lines, a, b) if t)
|
|
420
|
+
text = re.sub(r"\s+", " ", text)
|
|
421
|
+
for sent in re.split(r"(?<=[.;:])\s+(?=[A-Z(\"])", text):
|
|
422
|
+
found = sorted(set(NORM_RE.findall(sent)))
|
|
423
|
+
if not found:
|
|
424
|
+
continue
|
|
425
|
+
item = f"({','.join(found)}) {sent.strip()}"
|
|
426
|
+
if out and size + len(item) > budget:
|
|
427
|
+
return "\n\n".join(out) + "\n[more; narrow the ref]", None
|
|
428
|
+
out.append(item)
|
|
429
|
+
size += len(item) + 2
|
|
430
|
+
return ("\n\n".join(out) if out else
|
|
431
|
+
"(no uppercase BCP 14 keywords in this scope)"), None
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def _code(conn, ref: Ref, a, b, budget, lang):
|
|
435
|
+
if not has_xml(conn, ref.served):
|
|
436
|
+
raise RefError(f"view=code needs paragraph blocks; {ref.served} "
|
|
437
|
+
"is not indexed yet. Use grep(doc, pattern) or read "
|
|
438
|
+
"the section text.")
|
|
439
|
+
rows = [r for r in _blocks_in(conn, ref.served, a, b)
|
|
440
|
+
if r["kind"] in ("sourcecode", "artwork")
|
|
441
|
+
and (not lang or r["lang"].lower() == lang.lower())]
|
|
442
|
+
if not rows:
|
|
443
|
+
langs = sorted({r["lang"] for r in conn.execute(
|
|
444
|
+
"SELECT DISTINCT lang FROM blocks WHERE doc_id=? AND kind="
|
|
445
|
+
"'sourcecode'", (ref.served,)) if r["lang"]})
|
|
446
|
+
return ("(no code/figures in this scope"
|
|
447
|
+
+ (f"; this doc has: {', '.join(langs)}" if langs else "")
|
|
448
|
+
+ ")"), None
|
|
449
|
+
out, size = [], 0
|
|
450
|
+
for r in rows:
|
|
451
|
+
item = (f"[{block_ref(ref.doc, r)}] {r['kind']}"
|
|
452
|
+
+ (f" type={r['lang']}" if r["lang"] else "")
|
|
453
|
+
+ f" L{r['start_line']}-{r['end_line']}\n{r['text']}")
|
|
454
|
+
if out and size + len(item) > budget:
|
|
455
|
+
return "\n\n".join(out), _cursor(conn, ref, r, b) + " (view=code)"
|
|
456
|
+
out.append(item)
|
|
457
|
+
size += len(item) + 2
|
|
458
|
+
return "\n\n".join(out), None
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
def _doc_overview(conn, ref: Ref, meta: dict, mirror_root: Path) -> str:
|
|
462
|
+
abstract = re.sub(r"\s+", " ", meta.get("abstract") or "").strip()
|
|
463
|
+
out = [_doc_line(conn, ref, meta), q._url_of(meta), ""]
|
|
464
|
+
if abstract:
|
|
465
|
+
out += [abstract[:900] + ("…" if len(abstract) > 900 else ""), ""]
|
|
466
|
+
out.append(outline(conn, ref.doc, depth=1, header=False))
|
|
467
|
+
return "\n".join(out)
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
# --------------------------------------------------------------------------
|
|
471
|
+
# outline
|
|
472
|
+
|
|
473
|
+
def outline(conn: sqlite3.Connection, doc: str, depth: int | None = 2,
|
|
474
|
+
contains: str | None = None, header: bool = True,
|
|
475
|
+
max_rows: int = 200) -> str:
|
|
476
|
+
_require_library(conn)
|
|
477
|
+
try:
|
|
478
|
+
did = q.normalize_doc_id(doc)
|
|
479
|
+
q.get_doc_row(conn, did)
|
|
480
|
+
except (ValueError, q.NotFound) as e:
|
|
481
|
+
raise RefError(f"unknown document {doc!r}: {e}") from None
|
|
482
|
+
served = q.resolve(conn, did)
|
|
483
|
+
_ensure_text(conn, served)
|
|
484
|
+
all_rows = _s.toc(conn, served, contains=contains)
|
|
485
|
+
if not all_rows:
|
|
486
|
+
if contains and _s.toc(conn, served):
|
|
487
|
+
raise RefError(f"no section of {served} has {contains!r} in its "
|
|
488
|
+
"number or title",
|
|
489
|
+
f"outline('{did}') without contains, or "
|
|
490
|
+
f"grep('{did}', {contains!r})")
|
|
491
|
+
raise RefError(f"{served} is not section-indexed yet",
|
|
492
|
+
action="rfclib index build")
|
|
493
|
+
want = None if contains else depth # a filter searches every level
|
|
494
|
+
rows = [r for r in all_rows if want is None or not r["sec_num"]
|
|
495
|
+
or r["sec_num"].count(".") + 1 <= want]
|
|
496
|
+
note = ""
|
|
497
|
+
while len(rows) > max_rows and (want or 9) > 1:
|
|
498
|
+
want = (want or 9) - 1
|
|
499
|
+
rows = [r for r in all_rows if not r["sec_num"]
|
|
500
|
+
or r["sec_num"].count(".") + 1 <= want]
|
|
501
|
+
note = f" (reduced to depth {want} to fit; pass depth=None or contains=)"
|
|
502
|
+
out = []
|
|
503
|
+
if header:
|
|
504
|
+
meta = _meta(conn, served)
|
|
505
|
+
out.append(_doc_line(conn, Ref(did, served), meta))
|
|
506
|
+
out.append(f"{len(rows)} of {len(all_rows)} sections, depth "
|
|
507
|
+
f"{want if want else 'all'}{note}")
|
|
508
|
+
for r in rows:
|
|
509
|
+
sn = r["sec_num"]
|
|
510
|
+
ind = " " * sn.count(".") if sn and not sn.startswith("Appendix") \
|
|
511
|
+
else ""
|
|
512
|
+
tag = f"§{sn} " if sn else ""
|
|
513
|
+
extra = " (no own text; spans its subsections)" \
|
|
514
|
+
if r.get("parts") == 0 else ""
|
|
515
|
+
out.append(f"{ind}{tag}{r['title']} L{r['start_line']}-"
|
|
516
|
+
f"{r['end_line']}{extra}")
|
|
517
|
+
out.append(f"read: read('{did}§<num>') | unnumbered: read('{did}:<a>-<b>')")
|
|
518
|
+
return "\n".join(out)
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
# --------------------------------------------------------------------------
|
|
522
|
+
# grep
|
|
523
|
+
|
|
524
|
+
def grep(conn: sqlite3.Connection, doc: str, pattern: str, *,
|
|
525
|
+
mirror_root: Path, max_hits: int = 25,
|
|
526
|
+
ignore_case: bool = True) -> str:
|
|
527
|
+
ref = parse_ref(conn, doc)
|
|
528
|
+
try:
|
|
529
|
+
rx = re.compile(pattern, re.I if ignore_case else 0)
|
|
530
|
+
except re.error:
|
|
531
|
+
rx = re.compile(re.escape(pattern), re.I if ignore_case else 0)
|
|
532
|
+
lines = _text(conn, ref, mirror_root)
|
|
533
|
+
a, b = 1, len(lines)
|
|
534
|
+
if not ref.is_doc():
|
|
535
|
+
a, b, _sec, _t = _scope(conn, ref, len(lines))
|
|
536
|
+
blocks = _blocks_in(conn, ref.served, 1, len(lines)) if has_xml(
|
|
537
|
+
conn, ref.served) else []
|
|
538
|
+
bstarts = [r["start_line"] for r in blocks]
|
|
539
|
+
marks = [(r["start_line"], r["sec_num"]) for r in conn.execute(
|
|
540
|
+
"SELECT DISTINCT start_line, sec_num FROM chunks WHERE doc_id=? "
|
|
541
|
+
"ORDER BY start_line", (ref.served,))]
|
|
542
|
+
mstarts = [m[0] for m in marks]
|
|
543
|
+
|
|
544
|
+
def where(n: int) -> str:
|
|
545
|
+
i = bisect.bisect_right(bstarts, n) - 1
|
|
546
|
+
if i >= 0 and blocks[i]["end_line"] >= n:
|
|
547
|
+
return block_ref(ref.doc, blocks[i])
|
|
548
|
+
j = bisect.bisect_right(mstarts, n) - 1
|
|
549
|
+
if j >= 0 and marks[j][1]:
|
|
550
|
+
return f"{ref.doc}§{marks[j][1]}"
|
|
551
|
+
return f"{ref.doc}:{n}"
|
|
552
|
+
|
|
553
|
+
hits, out = 0, []
|
|
554
|
+
for n in range(a, b + 1):
|
|
555
|
+
ln = lines[n - 1]
|
|
556
|
+
if any(p.search(ln) for p in _FURNITURE) or not rx.search(ln):
|
|
557
|
+
continue
|
|
558
|
+
hits += 1
|
|
559
|
+
if len(out) < max_hits:
|
|
560
|
+
out.append(f"{where(n)} L{n}: {ln.strip()[:160]}")
|
|
561
|
+
if not hits:
|
|
562
|
+
return (f"no match for /{pattern}/ in {ref.doc}"
|
|
563
|
+
+ ("" if ref.is_doc() else f" lines {a}-{b}")
|
|
564
|
+
+ ". Try search_all for the whole corpus.")
|
|
565
|
+
more = (f"\n[{hits - len(out)} more; narrow the pattern or scope]"
|
|
566
|
+
if hits > len(out) else "")
|
|
567
|
+
return f"{hits} match(es) in {ref.doc}\n" + "\n".join(out) + more
|
|
568
|
+
|
|
569
|
+
|
|
570
|
+
# --------------------------------------------------------------------------
|
|
571
|
+
# cite
|
|
572
|
+
|
|
573
|
+
def cite(conn: sqlite3.Connection, raw: str, *, mirror_root: Path,
|
|
574
|
+
style: str = "verbose") -> str:
|
|
575
|
+
ref = parse_ref(conn, raw)
|
|
576
|
+
if not ref.pn:
|
|
577
|
+
sec = ref.sec or None
|
|
578
|
+
lines = (f"{ref.start}-{ref.end or ref.start}" if ref.start
|
|
579
|
+
else None)
|
|
580
|
+
out = _cite.cite(conn, ref.doc, section=sec, lines=lines,
|
|
581
|
+
style=style, mirror_root=mirror_root)
|
|
582
|
+
if out.startswith("CANNOT CITE"):
|
|
583
|
+
raise RefError(out)
|
|
584
|
+
return out
|
|
585
|
+
blk = _block(conn, ref.served, ref.pn)
|
|
586
|
+
meta = _meta(conn, ref.doc)
|
|
587
|
+
br = block_ref(ref.doc, blk)
|
|
588
|
+
short = _cite.label(ref.doc) + br[len(ref.doc):]
|
|
589
|
+
url = q.anchor_url(meta, _frag(conn, ref.served, ref.pn, blk["sec_num"]),
|
|
590
|
+
ref.served)
|
|
591
|
+
if style == "short":
|
|
592
|
+
return short
|
|
593
|
+
if style == "markdown":
|
|
594
|
+
return f"[{short}]({url})"
|
|
595
|
+
if style == "bibtex":
|
|
596
|
+
return _cite.cite(conn, ref.doc, style="bibtex")
|
|
597
|
+
body, _ = _cite.span_text(conn, ref.served, mirror_root=mirror_root,
|
|
598
|
+
lines=f"{blk['start_line']}-{blk['end_line']}")
|
|
599
|
+
status = meta.get("current_status") or meta.get("status") or ""
|
|
600
|
+
successor = ", ".join(meta["relations"].get("obsoleted_by", []))
|
|
601
|
+
state = "current" if meta["is_current"] else \
|
|
602
|
+
f"OBSOLETE by {successor or 'unknown'}"
|
|
603
|
+
sec_t = f' "{blk["sec_title"]}"' if blk["sec_title"] else ""
|
|
604
|
+
tail = (f"{short}{sec_t} lines {blk['start_line']}-{blk['end_line']} — "
|
|
605
|
+
f"{meta.get('title') or ''} ({(meta.get('pub_date') or '')[:7]}, "
|
|
606
|
+
f"{status}, {state})\n{url} | "
|
|
607
|
+
f"{_cite.errata_note(conn, ref.served, blk['sec_num'])} | "
|
|
608
|
+
f"span sha256:{q.span_hash(body)}")
|
|
609
|
+
if style == "comment":
|
|
610
|
+
return "\n".join("# " + ln for ln in tail.split("\n"))
|
|
611
|
+
return tail
|
|
612
|
+
|
|
613
|
+
|
|
614
|
+
# --------------------------------------------------------------------------
|
|
615
|
+
# links
|
|
616
|
+
|
|
617
|
+
def links(conn: sqlite3.Connection, raw: str, direction: str = "out",
|
|
618
|
+
limit: int = 30) -> str:
|
|
619
|
+
ref = parse_ref(conn, raw)
|
|
620
|
+
direction = (direction or "out").lower()
|
|
621
|
+
if direction == "related":
|
|
622
|
+
from . import subjects as subj_mod
|
|
623
|
+
if not subj_mod.subjects_ready(conn):
|
|
624
|
+
raise RefError("the subject tree is not in the library yet",
|
|
625
|
+
action="rfclib subjects sync")
|
|
626
|
+
r = subj_mod.related(conn, ref.served, limit=limit)
|
|
627
|
+
subs = ", ".join(s.get("name", "") for s in r.get("subjects", []))
|
|
628
|
+
out = [f"{ref.served} subjects: {subs or '(none)'}",
|
|
629
|
+
"documents sharing subjects:"]
|
|
630
|
+
for d in r.get("nearby_docs", [])[:limit]:
|
|
631
|
+
out.append(f" {d.get('doc_id')} — {d.get('title') or ''}"
|
|
632
|
+
f" ({d.get('shared_subjects')} shared)")
|
|
633
|
+
return "\n".join(out)
|
|
634
|
+
if direction not in ("out", "in"):
|
|
635
|
+
raise RefError("direction must be out | in | related")
|
|
636
|
+
sec = ref.sec
|
|
637
|
+
if ref.pn:
|
|
638
|
+
blk = _block(conn, ref.served, ref.pn)
|
|
639
|
+
sec = blk["sec_num"]
|
|
640
|
+
like = f"{sec}.%"
|
|
641
|
+
if direction == "out":
|
|
642
|
+
q_ = ("SELECT dst_doc peer, dst_sec psec, src_sec ssec, normative "
|
|
643
|
+
"FROM xrefs WHERE doc_id=?")
|
|
644
|
+
args: list = [ref.served]
|
|
645
|
+
if ref.pn:
|
|
646
|
+
q_ += " AND src_pn=?"
|
|
647
|
+
args.append(ref.pn)
|
|
648
|
+
elif sec:
|
|
649
|
+
q_ += " AND (src_sec=? OR src_sec LIKE ?)"
|
|
650
|
+
args += [sec, like]
|
|
651
|
+
else:
|
|
652
|
+
q_ = ("SELECT doc_id peer, src_sec psec, dst_sec ssec, normative "
|
|
653
|
+
"FROM xrefs WHERE dst_doc=?")
|
|
654
|
+
args = [ref.served]
|
|
655
|
+
if sec:
|
|
656
|
+
q_ += " AND (dst_sec=? OR dst_sec LIKE ?)"
|
|
657
|
+
args += [sec, like]
|
|
658
|
+
rows = conn.execute(q_, args).fetchall()
|
|
659
|
+
agg: dict[str, dict] = {}
|
|
660
|
+
for r in rows:
|
|
661
|
+
e = agg.setdefault(r["peer"], {"psec": set(), "ssec": set(),
|
|
662
|
+
"norm": False})
|
|
663
|
+
if r["psec"]:
|
|
664
|
+
e["psec"].add(r["psec"])
|
|
665
|
+
if r["ssec"]:
|
|
666
|
+
e["ssec"].add(r["ssec"])
|
|
667
|
+
e["norm"] |= bool(r["normative"])
|
|
668
|
+
scope = sec_ref(ref.doc, sec) if sec else ref.doc
|
|
669
|
+
out = []
|
|
670
|
+
if agg:
|
|
671
|
+
info = {d["doc_id"]: d for d in conn.execute(
|
|
672
|
+
"SELECT doc_id, title, current_status, is_current FROM docs "
|
|
673
|
+
"WHERE doc_id IN (%s)" % ",".join("?" * len(agg)), list(agg))}
|
|
674
|
+
order = sorted(agg, key=lambda p: (not agg[p]["norm"],
|
|
675
|
+
-len(agg[p]["psec"]), p))
|
|
676
|
+
verb = "cites" if direction == "out" else "is cited by"
|
|
677
|
+
out.append(f"{scope} {verb} {len(agg)} document(s) "
|
|
678
|
+
"(section-precise):")
|
|
679
|
+
for p in order[:limit]:
|
|
680
|
+
e, d = agg[p], info.get(p, {})
|
|
681
|
+
psecs = ", ".join(f"§{s}" for s in sorted(e["psec"],
|
|
682
|
+
key=_s_key)[:8])
|
|
683
|
+
ssecs = ", ".join(f"§{s}" for s in sorted(e["ssec"],
|
|
684
|
+
key=_s_key)[:6])
|
|
685
|
+
cur = "" if not d or d["is_current"] else " OBSOLETE"
|
|
686
|
+
if d and not d["title"]: # BCP/STD/FYI alias row
|
|
687
|
+
d = dict(d, title=f"(series alias of {q.resolve(conn, p)})")
|
|
688
|
+
if direction == "out":
|
|
689
|
+
out.append(f" {p} {psecs or '(whole doc)'}"
|
|
690
|
+
f"{' [normative]' if e['norm'] else ''} — "
|
|
691
|
+
f"{(d['title'] if d else '') or ''}{cur}"
|
|
692
|
+
+ (f" ← from {ssecs}" if ssecs and not sec
|
|
693
|
+
else ""))
|
|
694
|
+
else:
|
|
695
|
+
out.append(f" {p} {psecs or ''} → {ssecs or '(whole doc)'}"
|
|
696
|
+
f" — {(d['title'] if d else '') or ''}{cur}")
|
|
697
|
+
if len(agg) > limit:
|
|
698
|
+
out.append(f" … {len(agg) - limit} more")
|
|
699
|
+
# whole-document citation graph mined from txt (all eras)
|
|
700
|
+
if not sec:
|
|
701
|
+
from . import graph as ref_graph
|
|
702
|
+
g = ref_graph.get_refs(conn, ref.served, limit=limit)
|
|
703
|
+
side = g["outbound"] if direction == "out" else g["inbound"]
|
|
704
|
+
rest = [x for x in side if x["doc_id"] not in agg]
|
|
705
|
+
if rest:
|
|
706
|
+
out.append(f"{'cites' if direction == 'out' else 'cited by'} "
|
|
707
|
+
f"(reference lists, {len(rest)} more):")
|
|
708
|
+
for x in rest[:limit]:
|
|
709
|
+
secs = (" §" + ", §".join(x["sections"][:5])
|
|
710
|
+
if x["sections"] else "")
|
|
711
|
+
out.append(f" {x['doc_id']}{secs} [{'/'.join(x['kinds'])}]"
|
|
712
|
+
f" — {x['title'] or ''}"
|
|
713
|
+
+ ("" if x["is_current"] else " OBSOLETE"))
|
|
714
|
+
if not out:
|
|
715
|
+
return (f"no section-level citations recorded for {scope}"
|
|
716
|
+
+ ("" if has_xml(conn, ref.served) else
|
|
717
|
+
" (try without a section for reference-list edges)"))
|
|
718
|
+
return "\n".join(out)
|
|
719
|
+
|
|
720
|
+
|
|
721
|
+
def _s_key(s: str):
|
|
722
|
+
return [(0, int(p), "") if p.isdigit() else (1, 0, p)
|
|
723
|
+
for p in re.split(r"[.\s]", s)]
|
|
724
|
+
|
|
725
|
+
|
|
726
|
+
# --------------------------------------------------------------------------
|
|
727
|
+
# browse (subject taxonomy) and errata, as compact text
|
|
728
|
+
|
|
729
|
+
def subjects_tree(conn: sqlite3.Connection, parent: str | None = None,
|
|
730
|
+
contains: str | None = None, limit: int = 120) -> str:
|
|
731
|
+
"""One level of the RFC Editor subject tree (default: the ~19 roots),
|
|
732
|
+
or every subject matching `contains` with its path. Each line carries
|
|
733
|
+
the slug to pass on and the document count."""
|
|
734
|
+
from . import subjects as subj_mod
|
|
735
|
+
if not subj_mod.subjects_ready(conn):
|
|
736
|
+
raise RefError("the subject tree is not in the library yet",
|
|
737
|
+
action="rfclib subjects sync")
|
|
738
|
+
kids = {r["parent"]: r["n"] for r in conn.execute(
|
|
739
|
+
"SELECT parent, COUNT(*) n FROM subjects GROUP BY parent")}
|
|
740
|
+
|
|
741
|
+
def line(r, ind=""):
|
|
742
|
+
k = kids.get(r["slug"], 0)
|
|
743
|
+
return (f"{ind}{r['slug']} — {r['name']} ({r['doc_count'] or 0} docs"
|
|
744
|
+
+ (f", {k} subtopics" if k else "") + ")")
|
|
745
|
+
|
|
746
|
+
if contains:
|
|
747
|
+
rows = subj_mod.list_subjects(conn, contains=contains, limit=limit)
|
|
748
|
+
if not rows:
|
|
749
|
+
raise RefError(f"no subject matches {contains!r}",
|
|
750
|
+
"browse() lists the top level")
|
|
751
|
+
par = {r["slug"]: r["parent"] for r in conn.execute(
|
|
752
|
+
"SELECT slug, parent FROM subjects")}
|
|
753
|
+
out = [f"{len(rows)} subject(s) matching {contains!r}:"]
|
|
754
|
+
for r in rows:
|
|
755
|
+
path, p = [], r["parent"]
|
|
756
|
+
while p and len(path) < 6:
|
|
757
|
+
path.append(p)
|
|
758
|
+
p = par.get(p)
|
|
759
|
+
out.append(line(r) + (f" [in {' > '.join(reversed(path))}]"
|
|
760
|
+
if path else ""))
|
|
761
|
+
return "\n".join(out)
|
|
762
|
+
if parent:
|
|
763
|
+
row = conn.execute("SELECT slug FROM subjects WHERE slug=? OR "
|
|
764
|
+
"lower(name)=lower(?)", (parent, parent)).fetchone()
|
|
765
|
+
if row is None:
|
|
766
|
+
raise RefError(f"unknown subject {parent!r}",
|
|
767
|
+
f"browse(contains={parent!r})")
|
|
768
|
+
parent = row["slug"]
|
|
769
|
+
rows = conn.execute(
|
|
770
|
+
"SELECT * FROM subjects WHERE " + ("parent=?" if parent else
|
|
771
|
+
"(parent IS NULL OR parent='')")
|
|
772
|
+
+ " ORDER BY ord, name", (parent,) if parent else ()).fetchall()
|
|
773
|
+
head = (f"subtopics of {parent}:" if parent else
|
|
774
|
+
f"RFC Editor subject tree, top level ({len(rows)} roots):")
|
|
775
|
+
tail = ("browse(subject='<slug>') lists documents; "
|
|
776
|
+
"browse(parent='<slug>') goes one level down; "
|
|
777
|
+
"browse(contains='http') searches names")
|
|
778
|
+
return "\n".join([head] + [line(r, " ") for r in rows[:limit]] + [tail])
|
|
779
|
+
|
|
780
|
+
|
|
781
|
+
def subject_docs(conn: sqlite3.Connection, subject: str, offset: int = 0,
|
|
782
|
+
limit: int = 30, include_obsolete: bool = False) -> str:
|
|
783
|
+
from . import subjects as subj_mod
|
|
784
|
+
try:
|
|
785
|
+
g = subj_mod.get_subject(conn, subject, include_obsolete=include_obsolete,
|
|
786
|
+
limit=limit, offset=offset)
|
|
787
|
+
except subj_mod.SubjectNotFound as e:
|
|
788
|
+
raise RefError(str(e), f"browse(contains={subject!r})") from None
|
|
789
|
+
s = g["subject"]
|
|
790
|
+
out = [f"{s['slug']} — {s['name']}: {g['docs_total']} document(s)"
|
|
791
|
+
+ (" incl. subtopics" if g.get("children") else "")
|
|
792
|
+
+ ("" if include_obsolete else ", current only")]
|
|
793
|
+
if s.get("description"):
|
|
794
|
+
out.append(s["description"])
|
|
795
|
+
if g.get("children"):
|
|
796
|
+
out.append("subtopics: " + ", ".join(
|
|
797
|
+
c["slug"] if isinstance(c, dict) else str(c)
|
|
798
|
+
for c in g["children"][:25]))
|
|
799
|
+
for d in g["docs"]:
|
|
800
|
+
out.append(f" {d['doc_id']} — {d['title']} | "
|
|
801
|
+
f"{d.get('current_status') or ''} | "
|
|
802
|
+
f"{(d.get('pub_date') or '')[:7]}")
|
|
803
|
+
if g.get("next_offset") is not None:
|
|
804
|
+
out.append(f"next: browse(subject='{s['slug']}', "
|
|
805
|
+
f"offset={g['next_offset']})")
|
|
806
|
+
return "\n".join(out)
|
|
807
|
+
|
|
808
|
+
|
|
809
|
+
def errata_text(conn: sqlite3.Connection, doc: str, status: str = "verified",
|
|
810
|
+
section: str | None = None, max_tokens: int = 2000) -> str:
|
|
811
|
+
"""Errata for a document as compact text: verified only by default,
|
|
812
|
+
each with section, type, and the corrected text."""
|
|
813
|
+
from . import errata as errata_mod
|
|
814
|
+
ref = parse_ref(conn, doc, need_text=False)
|
|
815
|
+
section = section or ref.sec or None # 'RFC791§3.1' narrows too
|
|
816
|
+
res = errata_mod.ensure(conn, ref.served)
|
|
817
|
+
rows = res["results"]
|
|
818
|
+
want = (status or "verified").lower()
|
|
819
|
+
sel = [e for e in rows if want == "all" or e["status"].lower() == want
|
|
820
|
+
or (want == "open" and e["status"] in ("Reported",
|
|
821
|
+
"Held for Document Update"))]
|
|
822
|
+
if section:
|
|
823
|
+
sec = _s.norm_sec(section)
|
|
824
|
+
sel = [e for e in sel if sec and sec in (e.get("section") or "")]
|
|
825
|
+
counts: dict[str, int] = {}
|
|
826
|
+
for e in rows:
|
|
827
|
+
counts[e["status"]] = counts.get(e["status"], 0) + 1
|
|
828
|
+
summary = ", ".join(f"{v} {k}" for k, v in sorted(counts.items())) \
|
|
829
|
+
or "none"
|
|
830
|
+
out = [f"errata for {ref.served}: {summary} "
|
|
831
|
+
f"(source {res.get('source')}, {(res.get('fetched_at') or '')[:10]})"]
|
|
832
|
+
if not sel:
|
|
833
|
+
out.append(f"(no {want} errata" + (f" on §{section}" if section
|
|
834
|
+
else "") + ")")
|
|
835
|
+
return "\n".join(out)
|
|
836
|
+
budget, size = max(400, max_tokens * CHARS_PER_TOKEN), 0
|
|
837
|
+
for e in sel:
|
|
838
|
+
def clip(t, n=700):
|
|
839
|
+
t = (t or "").strip()
|
|
840
|
+
return t if len(t) <= n else t[:n] + " …"
|
|
841
|
+
item = (f"\n[eid {e['eid']}] {e['status']} {e.get('type') or ''} — "
|
|
842
|
+
f"section: {(e.get('section') or '?').strip()[:60]}\n"
|
|
843
|
+
f" says: {clip(e.get('says'))}\n"
|
|
844
|
+
f" should say: {clip(e.get('should_say'))}"
|
|
845
|
+
+ (f"\n notes: {clip(e.get('notes'), 300)}"
|
|
846
|
+
if e.get("notes") else ""))
|
|
847
|
+
if size + len(item) > budget and size:
|
|
848
|
+
out.append(f"\n[{len(sel) - sel.index(e)} more; pass section= "
|
|
849
|
+
"or a larger max_tokens]")
|
|
850
|
+
break
|
|
851
|
+
out.append(item)
|
|
852
|
+
size += len(item)
|
|
853
|
+
out.append(f"\nhttps://www.rfc-editor.org/errata/{ref.served.lower()}")
|
|
854
|
+
return "\n".join(out)
|
|
855
|
+
|
|
856
|
+
|
|
857
|
+
# --------------------------------------------------------------------------
|
|
858
|
+
# search and info, as compact text
|
|
859
|
+
|
|
860
|
+
_MARK = re.compile(r">>>(.+?)<<<")
|
|
861
|
+
|
|
862
|
+
|
|
863
|
+
def _hit_ref(conn, h: dict) -> tuple[str, str]:
|
|
864
|
+
"""(paragraph-precise ref, section ref) for a section hit: the block of
|
|
865
|
+
the section that carries most of the snippet's highlighted terms."""
|
|
866
|
+
doc = h["doc_id"]
|
|
867
|
+
sec = (f"{doc}§{h['sec_num']}" if h.get("sec_num")
|
|
868
|
+
else f"{doc}:{h['start_line']}-{h['end_line']}")
|
|
869
|
+
terms = {t.lower() for t in _MARK.findall(h.get("snippet") or "")}
|
|
870
|
+
best, n_best = None, 0
|
|
871
|
+
if terms:
|
|
872
|
+
for b in conn.execute(
|
|
873
|
+
"SELECT pn, kind, sec_num, text FROM blocks WHERE doc_id=? "
|
|
874
|
+
"AND kind<>'heading' AND start_line BETWEEN ? AND ? "
|
|
875
|
+
"ORDER BY ord", (doc, h["start_line"], h["end_line"])):
|
|
876
|
+
low = b["text"].lower()
|
|
877
|
+
n = sum(1 for t in terms if t in low)
|
|
878
|
+
if n > n_best:
|
|
879
|
+
best, n_best = b, n
|
|
880
|
+
return (block_ref(doc, best) if best is not None else sec), sec
|
|
881
|
+
|
|
882
|
+
|
|
883
|
+
def _status_tag(row: dict) -> str:
|
|
884
|
+
st = row.get("current_status") or ""
|
|
885
|
+
cur = row.get("is_current", 1)
|
|
886
|
+
obs = row.get("obsoleted_by") or (row.get("relations") or {}).get(
|
|
887
|
+
"obsoleted_by", [])
|
|
888
|
+
tag = st + (", current" if cur else
|
|
889
|
+
", OBSOLETE" + (f" by {', '.join(obs)}" if obs else ""))
|
|
890
|
+
return tag
|
|
891
|
+
|
|
892
|
+
|
|
893
|
+
_STOP = {"a", "an", "and", "are", "as", "at", "be", "by", "does", "for",
|
|
894
|
+
"from", "how", "in", "is", "it", "of", "on", "or", "the", "to",
|
|
895
|
+
"what", "when", "which", "with", "both", "must", "should", "can"}
|
|
896
|
+
|
|
897
|
+
|
|
898
|
+
def _title_noise(query: str, h: dict) -> bool:
|
|
899
|
+
"""A hit backed only by a document-title match whose title covers less
|
|
900
|
+
than half of the query's content words ('Content-Centric Networking'
|
|
901
|
+
for 'Content-Length'): hybrid fusion surfaces these Abstracts, but they
|
|
902
|
+
are noise unless the document is plausibly what the query is about."""
|
|
903
|
+
if (h.get("sources") or "") != "title":
|
|
904
|
+
return False
|
|
905
|
+
words = [w for w in re.findall(r"[a-z0-9]+(?:\.[0-9]+)*", query.lower())
|
|
906
|
+
if w not in _STOP]
|
|
907
|
+
if not words:
|
|
908
|
+
return False
|
|
909
|
+
title = set(re.findall(r"[a-z0-9]+(?:\.[0-9]+)*",
|
|
910
|
+
(h.get("title") or "").lower()))
|
|
911
|
+
return sum(1 for w in words if w in title) * 2 <= len(words)
|
|
912
|
+
|
|
913
|
+
|
|
914
|
+
def search_text(conn: sqlite3.Connection, query: str, *,
|
|
915
|
+
scope: str = "sections", mode: str = "hybrid",
|
|
916
|
+
limit: int = 8, include_obsolete: bool = False,
|
|
917
|
+
subject: str | None = None, status: str | None = None,
|
|
918
|
+
stream: str | None = None, detail: str = "concise") -> str:
|
|
919
|
+
from . import library
|
|
920
|
+
from .index import embed as ix_embed, hybrid as ix_hybrid
|
|
921
|
+
_require_library(conn)
|
|
922
|
+
scope, mode = (scope or "sections").lower(), (mode or "hybrid").lower()
|
|
923
|
+
limit = max(1, min(int(limit), 30))
|
|
924
|
+
detailed = (detail or "").lower() == "detailed"
|
|
925
|
+
if scope in ("documents", "docs", "rfcs") or subject or status or stream:
|
|
926
|
+
from . import subjects as subj_mod
|
|
927
|
+
try:
|
|
928
|
+
hits = q.search(conn, query, limit=limit,
|
|
929
|
+
include_obsolete=include_obsolete, stream=stream,
|
|
930
|
+
status=status, subject=subject)
|
|
931
|
+
except subj_mod.SubjectNotFound as e:
|
|
932
|
+
raise RefError(str(e), f"browse(contains={subject!r})") from None
|
|
933
|
+
if not hits:
|
|
934
|
+
return (f"no documents match {query!r}"
|
|
935
|
+
+ (f" in subject {subject!r}" if subject else "")
|
|
936
|
+
+ ". Try scope='sections' or fewer words.")
|
|
937
|
+
out = [f"{len(hits)} document(s) for {query!r}:"]
|
|
938
|
+
for i, h in enumerate(hits, 1):
|
|
939
|
+
subs = ", ".join(s["name"] for s in h.get("subjects", [])[:3])
|
|
940
|
+
out.append(f"{i}. {h['doc_id']} — {h['title']} | "
|
|
941
|
+
f"{_status_tag(h)} | {(h.get('pub_date') or '')[:7]}"
|
|
942
|
+
+ (f" | {subs}" if subs and detailed else ""))
|
|
943
|
+
out.append("next: read('<doc>') for abstract + outline")
|
|
944
|
+
return "\n".join(out)
|
|
945
|
+
try:
|
|
946
|
+
if mode in ("exact", "bm25", "fts"):
|
|
947
|
+
hits = _s.search_chunks(conn, query, limit=limit,
|
|
948
|
+
include_obsolete=include_obsolete)
|
|
949
|
+
elif mode in ("semantic", "knn", "concept"):
|
|
950
|
+
hits = ix_embed.semantic_search(conn, query, limit=limit,
|
|
951
|
+
include_obsolete=include_obsolete)
|
|
952
|
+
else:
|
|
953
|
+
hits = ix_hybrid.hybrid_search(conn, query, limit=limit + 6,
|
|
954
|
+
include_obsolete=include_obsolete)
|
|
955
|
+
except Exception as e: # noqa: BLE001 - index missing or FTS syntax
|
|
956
|
+
raise RefError(f"search failed ({e})",
|
|
957
|
+
"mode='exact' takes FTS5 syntax: quoted phrases, "
|
|
958
|
+
"AND/OR, prefix*, NEAR(a b, 40)",
|
|
959
|
+
"rfclib index build (and rfclib index embed for "
|
|
960
|
+
"mode='semantic')") from None
|
|
961
|
+
hits = [h for h in hits if not _title_noise(query, h)][:limit]
|
|
962
|
+
if not hits:
|
|
963
|
+
note = library.coverage_note(conn)
|
|
964
|
+
return ((note + "\n") if note else "") + (
|
|
965
|
+
f"no sections match {query!r}. Try mode='semantic' for a "
|
|
966
|
+
"paraphrase, or scope='documents'.")
|
|
967
|
+
out = [f"{len(hits)} section hit(s) for {query!r} (mode={mode}):"]
|
|
968
|
+
note = library.coverage_note(conn)
|
|
969
|
+
if note:
|
|
970
|
+
out.insert(0, note)
|
|
971
|
+
for i, h in enumerate(hits, 1):
|
|
972
|
+
ref, sec = _hit_ref(conn, h)
|
|
973
|
+
snip = re.sub(r"\s+", " ", (h.get("snippet") or "")).strip()
|
|
974
|
+
title = h.get("sec_title") or ""
|
|
975
|
+
line = (f"{i}. {ref} \"{title}\" — {h.get('title') or ''} | "
|
|
976
|
+
f"{_status_tag(h)}")
|
|
977
|
+
if detailed:
|
|
978
|
+
line += (f" | L{h['start_line']}-{h['end_line']}"
|
|
979
|
+
+ (f" | {h['sources']}" if h.get("sources") else "")
|
|
980
|
+
+ (f" | section {sec}" if sec != ref else ""))
|
|
981
|
+
out.append(line)
|
|
982
|
+
if snip:
|
|
983
|
+
out.append(" " + snip[:220])
|
|
984
|
+
out.append("next: read('<ref>') — each page ends with the ref to "
|
|
985
|
+
"continue from")
|
|
986
|
+
return "\n".join(out)
|
|
987
|
+
|
|
988
|
+
|
|
989
|
+
def info_text(conn: sqlite3.Connection, doc: str) -> str:
|
|
990
|
+
"""Bibliographic record + status graph + aliases + errata summary."""
|
|
991
|
+
_require_library(conn)
|
|
992
|
+
try:
|
|
993
|
+
did = q.normalize_doc_id(doc)
|
|
994
|
+
meta = q.get_metadata(conn, did)
|
|
995
|
+
except (ValueError, q.NotFound) as e:
|
|
996
|
+
raise RefError(f"unknown document {doc!r}: {e}") from None
|
|
997
|
+
rel = meta["relations"]
|
|
998
|
+
served = q.resolve(conn, did)
|
|
999
|
+
out = [f"{_cite.label(did)} — {meta.get('title') or '(series alias)'}"]
|
|
1000
|
+
if meta.get("alias_of"):
|
|
1001
|
+
out.append("is: " + "; ".join(
|
|
1002
|
+
f"{a['doc_id']} \"{a.get('title')}\" ({_status_tag(a)})"
|
|
1003
|
+
for a in meta["alias_of"]))
|
|
1004
|
+
fields = [
|
|
1005
|
+
("status", f"{meta.get('current_status') or ''}"
|
|
1006
|
+
+ (f" (published as {meta['status']})"
|
|
1007
|
+
if meta.get("status") and meta.get("status")
|
|
1008
|
+
!= meta.get("current_status") else "")),
|
|
1009
|
+
("state", "current" if meta["is_current"] else "OBSOLETE"),
|
|
1010
|
+
("published", (meta.get("pub_date") or "")[:7]),
|
|
1011
|
+
("authors", ", ".join(a if isinstance(a, str) else (a.get("name") or "")
|
|
1012
|
+
for a in meta.get("authors") or [])),
|
|
1013
|
+
("stream", " / ".join(x for x in (meta.get("stream"), meta.get("wg"),
|
|
1014
|
+
meta.get("area")) if x)),
|
|
1015
|
+
("doi", meta.get("doi") or ""),
|
|
1016
|
+
]
|
|
1017
|
+
for k in ("obsoleted_by", "obsoletes", "updated_by", "updates", "alias"):
|
|
1018
|
+
if rel.get(k):
|
|
1019
|
+
fields.append((k.replace("_", " "), ", ".join(rel[k])))
|
|
1020
|
+
subs = ", ".join(s["name"] for s in meta.get("subjects", []))
|
|
1021
|
+
if subs:
|
|
1022
|
+
fields.append(("subjects", subs))
|
|
1023
|
+
fields.append(("errata", _cite.errata_note(conn, served)
|
|
1024
|
+
.removeprefix("errata: ")))
|
|
1025
|
+
fields.append(("url", q._url_of(meta)))
|
|
1026
|
+
out += [f"{k}: {v}" for k, v in fields if v]
|
|
1027
|
+
ab = re.sub(r"\s+", " ", meta.get("abstract") or "").strip()
|
|
1028
|
+
if ab:
|
|
1029
|
+
out.append("abstract: " + (ab[:700] + "…" if len(ab) > 700 else ab))
|
|
1030
|
+
if not meta["is_current"] and rel.get("obsoleted_by"):
|
|
1031
|
+
now = current_successors(conn, did)
|
|
1032
|
+
if now:
|
|
1033
|
+
out.append(f"→ current replacement: {', '.join(now)}"
|
|
1034
|
+
+ (f" (via {', '.join(rel['obsoleted_by'])})"
|
|
1035
|
+
if set(now) != set(rel["obsoleted_by"]) else "")
|
|
1036
|
+
+ f"; read('{now[0]}')")
|
|
1037
|
+
return "\n".join(out)
|
|
1038
|
+
|
|
1039
|
+
|
|
1040
|
+
def current_successors(conn: sqlite3.Connection, doc: str) -> list[str]:
|
|
1041
|
+
"""Follow obsoleted_by until current documents: RFC2616 -> RFC7230..7235
|
|
1042
|
+
(themselves obsolete) -> RFC9110, RFC9111, RFC9112."""
|
|
1043
|
+
seen, frontier, out = {doc}, [doc], []
|
|
1044
|
+
while frontier:
|
|
1045
|
+
nxt = []
|
|
1046
|
+
for d in frontier:
|
|
1047
|
+
for r in conn.execute(
|
|
1048
|
+
"SELECT DISTINCT r.src AS s, x.is_current AS cur FROM "
|
|
1049
|
+
"doc_relations r JOIN docs x ON x.doc_id = r.src WHERE "
|
|
1050
|
+
"r.dst=? AND r.kind='obsoletes' UNION SELECT DISTINCT "
|
|
1051
|
+
"r.dst, x.is_current FROM doc_relations r JOIN docs x ON "
|
|
1052
|
+
"x.doc_id = r.dst WHERE r.src=? AND r.kind='obsoleted_by'",
|
|
1053
|
+
(d, d)):
|
|
1054
|
+
if r[0] in seen:
|
|
1055
|
+
continue
|
|
1056
|
+
seen.add(r[0])
|
|
1057
|
+
(out if r[1] else nxt).append(r[0])
|
|
1058
|
+
frontier = nxt
|
|
1059
|
+
return sorted(set(out), key=lambda x: (x[:3], int(x[3:]) if x[3:].isdigit() else 0))
|
|
1060
|
+
|
|
1061
|
+
|
|
1062
|
+
def recent_text(conn: sqlite3.Connection, limit: int = 15,
|
|
1063
|
+
series: str = "RFC") -> str:
|
|
1064
|
+
rows = q.list_recent(conn, n=limit, series=series)
|
|
1065
|
+
return "\n".join([f"{len(rows)} most recent {series} documents:"] + [
|
|
1066
|
+
f" {r['doc_id']} — {r['title']} | {r.get('current_status') or ''}"
|
|
1067
|
+
f" | {(r.get('pub_date') or '')[:7]}" for r in rows])
|