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,19 @@
|
|
|
1
|
+
"""The RFC series as a primarysource collection. Importing this needs nothing beyond the core."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from primarysource.model import Unit
|
|
6
|
+
from primarysource.refs import RefGrammar
|
|
7
|
+
from primarysource.registry import Collection
|
|
8
|
+
|
|
9
|
+
# Tool names stay bare (search, read, cite, ...): rfclib's published interface. A prefix is the owner's call
|
|
10
|
+
# (PLAN.md §7, "Tool prefix").
|
|
11
|
+
COLLECTION = Collection(
|
|
12
|
+
name="rfc",
|
|
13
|
+
title="RFC series (RFC, BCP, STD, FYI) from the RFC Editor",
|
|
14
|
+
prefix="",
|
|
15
|
+
unit=Unit.PARAGRAPH,
|
|
16
|
+
grammar=RefGrammar(r"(?:RFC|BCP|STD|FYI)\d+"),
|
|
17
|
+
server="primarysource.rfc.mcp.server:mcp",
|
|
18
|
+
cli="primarysource.rfc.cli:main",
|
|
19
|
+
)
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""Paths, network policy and official source constants.
|
|
2
|
+
|
|
3
|
+
All URLs point at RFC Editor first-party infrastructure (rfc-editor.org),
|
|
4
|
+
the authoritative publisher/archive of the RFC Series for all five streams.
|
|
5
|
+
See RFC 9280 (RFC Editor Model v3), RFC 4714, RFC 7841.
|
|
6
|
+
|
|
7
|
+
Where the library lives: $RFCLIB_HOME if set, else the platform's per-user
|
|
8
|
+
data directory (platformdirs: %LOCALAPPDATA%\\rfclib on Windows, not
|
|
9
|
+
roaming, since the library is gigabytes; $XDG_DATA_HOME/rfclib or
|
|
10
|
+
~/.local/share/rfclib on Linux; ~/Library/Application Support/rfclib on
|
|
11
|
+
macOS). Never the working directory: MCP clients start servers from
|
|
12
|
+
wherever they like, so a cwd-relative home would silently point every
|
|
13
|
+
session at a different, empty library.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import os
|
|
19
|
+
from dataclasses import dataclass
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
from primarysource.errors import LibError
|
|
23
|
+
|
|
24
|
+
from . import __version__
|
|
25
|
+
|
|
26
|
+
BASE_URL = "https://www.rfc-editor.org"
|
|
27
|
+
MANIFEST_URL = f"{BASE_URL}/rfc-index.xml"
|
|
28
|
+
MANIFEST_XSD_URL = f"{BASE_URL}/rfc-index.xsd"
|
|
29
|
+
RSS_URL = f"{BASE_URL}/rfcrss.xml"
|
|
30
|
+
ATOM_URL = f"{BASE_URL}/rfcatom.xml"
|
|
31
|
+
RSYNC_HOST = "rsync.rfc-editor.org"
|
|
32
|
+
RSYNC_DOC_URL = f"{BASE_URL}/series/rfc-download/"
|
|
33
|
+
|
|
34
|
+
USER_AGENT = f"rfclib/{__version__} (local RFC library sync; polite bulk fetch)"
|
|
35
|
+
|
|
36
|
+
NETWORK_MODES = ("auto", "on-demand", "offline")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _default_home() -> Path:
|
|
40
|
+
# RFCLIB_HOME, else $PRIMARYSOURCE_HOME/rfc, else an existing per-user
|
|
41
|
+
# rfclib library, else the per-user primarysource/rfc
|
|
42
|
+
# (primarysource.home.collection_home).
|
|
43
|
+
from primarysource.home import collection_home
|
|
44
|
+
return collection_home("rfc", legacy_app="rfclib").expanduser()
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def legacy_home() -> Path | None:
|
|
48
|
+
"""A pre-platformdirs library in ./data (the old default), if one is
|
|
49
|
+
sitting in the working directory and RFCLIB_HOME is not set: the CLI
|
|
50
|
+
points at it instead of silently starting an empty library."""
|
|
51
|
+
if os.environ.get("RFCLIB_HOME"):
|
|
52
|
+
return None
|
|
53
|
+
old = Path.cwd() / "data"
|
|
54
|
+
if (old / "rfclib.db").is_file() and old.resolve() != \
|
|
55
|
+
_default_home().resolve():
|
|
56
|
+
return old
|
|
57
|
+
return None
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class NetworkDisabled(LibError, RuntimeError):
|
|
61
|
+
"""A fetch was needed but RFCLIB_NETWORK=offline (or read-only mode)
|
|
62
|
+
forbids it. `action` says what a human runs to get the document in."""
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def read_only() -> bool:
|
|
66
|
+
"""RFCLIB_READONLY=1 (or `rfclib mcp --read-only`): this process must not
|
|
67
|
+
write the library. The MCP server then opens the database read-only and
|
|
68
|
+
never fetches, so a shared HTTP server stays the only writer."""
|
|
69
|
+
return any(
|
|
70
|
+
(os.environ.get(var) or "").strip().lower() in ("1", "true", "yes", "on")
|
|
71
|
+
for var in ("RFCLIB_READONLY", "PRIMARYSOURCE_READONLY"))
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def network_mode() -> str:
|
|
75
|
+
"""RFCLIB_NETWORK: auto (default: fetch documents on demand, refresh the
|
|
76
|
+
catalog when stale) | on-demand (fetch only when a document is asked
|
|
77
|
+
for) | offline (never touch the network; answer from the cache).
|
|
78
|
+
A read-only process is always offline: a fetch would have to be stored."""
|
|
79
|
+
if read_only():
|
|
80
|
+
return "offline"
|
|
81
|
+
mode = (os.environ.get("RFCLIB_NETWORK") or "auto").strip().lower()
|
|
82
|
+
return mode if mode in NETWORK_MODES else "auto"
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def require_network(what: str) -> None:
|
|
86
|
+
if read_only():
|
|
87
|
+
raise NetworkDisabled(
|
|
88
|
+
f"{what} is not in this library and this server is read-only "
|
|
89
|
+
"(RFCLIB_READONLY)", "ask the shared rfclib server for it",
|
|
90
|
+
"rfclib sync (without RFCLIB_READONLY)")
|
|
91
|
+
if network_mode() == "offline":
|
|
92
|
+
raise NetworkDisabled(
|
|
93
|
+
f"{what} is not available locally and RFCLIB_NETWORK=offline "
|
|
94
|
+
"forbids fetching it", "",
|
|
95
|
+
"rfclib sync while online, or unset RFCLIB_NETWORK")
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@dataclass(frozen=True)
|
|
99
|
+
class Paths:
|
|
100
|
+
"""Layout of the local library. Override the root with RFCLIB_HOME.
|
|
101
|
+
|
|
102
|
+
home/
|
|
103
|
+
rfclib.db SQLite index (metadata, relations, file inventory)
|
|
104
|
+
meta/ rfc-index.xml manifest copy, subject page cache
|
|
105
|
+
mirror/ document files, rfc-editor layout (rfc/, bcp/, ...)
|
|
106
|
+
models/ embedding model, loaded offline
|
|
107
|
+
"""
|
|
108
|
+
|
|
109
|
+
home: Path
|
|
110
|
+
|
|
111
|
+
@classmethod
|
|
112
|
+
def default(cls) -> "Paths":
|
|
113
|
+
return cls(_default_home())
|
|
114
|
+
|
|
115
|
+
@property
|
|
116
|
+
def db(self) -> Path:
|
|
117
|
+
return self.home / "rfclib.db"
|
|
118
|
+
|
|
119
|
+
@property
|
|
120
|
+
def meta(self) -> Path:
|
|
121
|
+
return self.home / "meta"
|
|
122
|
+
|
|
123
|
+
@property
|
|
124
|
+
def manifest_xml(self) -> Path:
|
|
125
|
+
return self.meta / "rfc-index.xml"
|
|
126
|
+
|
|
127
|
+
@property
|
|
128
|
+
def mirror(self) -> Path:
|
|
129
|
+
return self.home / "mirror"
|
|
130
|
+
|
|
131
|
+
@property
|
|
132
|
+
def models(self) -> Path:
|
|
133
|
+
return self.home / "models"
|
|
134
|
+
|
|
135
|
+
def ensure(self) -> "Paths":
|
|
136
|
+
for p in (self.home, self.meta, self.mirror):
|
|
137
|
+
p.mkdir(parents=True, exist_ok=True)
|
|
138
|
+
return self
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
"""Verified-errata overlay from the RFC Editor errata service.
|
|
2
|
+
|
|
3
|
+
Two sources. The primary one is the RFC Editor's published bulk API,
|
|
4
|
+
www.rfc-editor.org/api/v1/errata.json (every erratum, ~12 MB, one request;
|
|
5
|
+
robots.txt allows /api/): `sync_bulk` loads it so every document's errata
|
|
6
|
+
status is authoritative offline, including "none". The fallback is the
|
|
7
|
+
per-document errata.rfc-editor.org search page, fetched on demand only (that
|
|
8
|
+
host forbids crawling), parsed from its records presentation.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import html as htmllib
|
|
14
|
+
import re
|
|
15
|
+
import sqlite3
|
|
16
|
+
import time
|
|
17
|
+
from datetime import datetime, timezone
|
|
18
|
+
|
|
19
|
+
from .config import USER_AGENT, network_mode, require_network
|
|
20
|
+
from .sync.state import _now, get_meta, set_meta
|
|
21
|
+
|
|
22
|
+
ERRATA_URL = "https://errata.rfc-editor.org/search/?rfc_number={num}"
|
|
23
|
+
BULK_URL = "https://www.rfc-editor.org/api/v1/errata.json"
|
|
24
|
+
BULK_META = "errata_bulk_synced_at"
|
|
25
|
+
|
|
26
|
+
_RECORD_SPLIT = re.compile(r'Errata-ID:\s*<a href="/eid(\d+)/?"')
|
|
27
|
+
_TAG_RE = re.compile(r"<[^>]+>")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _text(fragment: str) -> str:
|
|
31
|
+
return htmllib.unescape(_TAG_RE.sub("", fragment)).strip()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _field(block: str, label: str) -> str | None:
|
|
35
|
+
"""Value of a <dt>label</dt><dd>value</dd> pair inside the metadata dl."""
|
|
36
|
+
m = re.search(
|
|
37
|
+
rf"<dt[^>]*>\s*{re.escape(label)}\s*</dt>\s*<dd[^>]*>(.*?)</dd>",
|
|
38
|
+
block, re.S)
|
|
39
|
+
return _text(m.group(1)) if m else None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def parse_errata(page: str) -> list[dict]:
|
|
43
|
+
"""Parse the errata service HTML (Full records view) into dicts."""
|
|
44
|
+
parts = _RECORD_SPLIT.split(page)
|
|
45
|
+
# parts[0] = preamble; then alternating eid, block
|
|
46
|
+
out: list[dict] = []
|
|
47
|
+
for eid, block in zip(parts[1::2], parts[2::2]):
|
|
48
|
+
# Cut anything after the last record (page footer).
|
|
49
|
+
block = block.split("Report New Erratum")[0]
|
|
50
|
+
entry: dict = {"eid": int(eid),
|
|
51
|
+
"status": _field(block, "Status:") or "",
|
|
52
|
+
"type": _field(block, "Type:") or "",
|
|
53
|
+
"reported_by": _field(block, "Reported By:"),
|
|
54
|
+
"reported_date": _field(block, "Date Reported:"),
|
|
55
|
+
"section": None, "says": None,
|
|
56
|
+
"should_say": None, "notes": None,
|
|
57
|
+
"handler": None, "handler_date": None}
|
|
58
|
+
if (v := _field(block, "Verified by:")):
|
|
59
|
+
entry["handler"] = v
|
|
60
|
+
entry["handler_date"] = _field(block, "Date Verified:")
|
|
61
|
+
elif (v := _field(block, "Rejected by:")):
|
|
62
|
+
entry["handler"] = v
|
|
63
|
+
entry["handler_date"] = _field(block, "Date Rejected:")
|
|
64
|
+
|
|
65
|
+
# "Section X says:" paragraph followed by a <pre> quote
|
|
66
|
+
m = re.search(r"<p>\s*(?:Section\s+([^<]*?))?\s*says:\s*</p>\s*"
|
|
67
|
+
r"<pre[^>]*>(.*?)</pre>", block, re.S)
|
|
68
|
+
if m:
|
|
69
|
+
entry["section"] = (m.group(1) or "").strip().rstrip(":") or None
|
|
70
|
+
entry["says"] = _text(m.group(2))
|
|
71
|
+
m = re.search(r"<p>\s*It should say:\s*</p>\s*<pre[^>]*>(.*?)</pre>",
|
|
72
|
+
block, re.S)
|
|
73
|
+
if m:
|
|
74
|
+
entry["should_say"] = _text(m.group(1))
|
|
75
|
+
m = re.search(r"<p>\s*Notes:\s*</p>\s*<pre[^>]*>(.*?)</pre>",
|
|
76
|
+
block, re.S)
|
|
77
|
+
if m:
|
|
78
|
+
entry["notes"] = _text(m.group(1))
|
|
79
|
+
out.append(entry)
|
|
80
|
+
return out
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def fetch(rfc_number: int) -> str:
|
|
84
|
+
"""One polite request to the errata service; caller throttles."""
|
|
85
|
+
import httpx # lazy: serving from an existing library never needs it
|
|
86
|
+
with httpx.Client(headers={"User-Agent": USER_AGENT},
|
|
87
|
+
follow_redirects=True,
|
|
88
|
+
timeout=httpx.Timeout(30.0)) as client:
|
|
89
|
+
r = client.get(ERRATA_URL.format(num=rfc_number))
|
|
90
|
+
r.raise_for_status()
|
|
91
|
+
return r.text
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def store(conn: sqlite3.Connection, doc_id: str,
|
|
95
|
+
entries: list[dict]) -> int:
|
|
96
|
+
conn.execute("DELETE FROM errata WHERE doc_id=?", (doc_id,))
|
|
97
|
+
rows = [(e["eid"], doc_id, e["status"], e.get("type"), e.get("section"),
|
|
98
|
+
e.get("says"), e.get("should_say"), e.get("notes"),
|
|
99
|
+
e.get("reported_by"), e.get("reported_date"),
|
|
100
|
+
e.get("handler"), e.get("handler_date"), _now())
|
|
101
|
+
for e in entries]
|
|
102
|
+
conn.executemany(
|
|
103
|
+
"""INSERT OR REPLACE INTO errata(eid, doc_id, status, type, section,
|
|
104
|
+
says, should_say, notes, reported_by, reported_date,
|
|
105
|
+
handler, handler_date, fetched_at)
|
|
106
|
+
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)""", rows)
|
|
107
|
+
conn.commit()
|
|
108
|
+
return len(rows)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def sync_bulk(conn: sqlite3.Connection, data: list[dict] | None = None) -> dict:
|
|
112
|
+
"""Replace the errata table with the RFC Editor's bulk feed."""
|
|
113
|
+
if data is None:
|
|
114
|
+
require_network("the errata feed")
|
|
115
|
+
import httpx # lazy: serving from an existing library never needs it
|
|
116
|
+
with httpx.Client(headers={"User-Agent": USER_AGENT},
|
|
117
|
+
follow_redirects=True,
|
|
118
|
+
timeout=httpx.Timeout(120.0)) as client:
|
|
119
|
+
r = client.get(BULK_URL)
|
|
120
|
+
r.raise_for_status()
|
|
121
|
+
data = r.json()
|
|
122
|
+
now = _now()
|
|
123
|
+
rows = []
|
|
124
|
+
for e in data:
|
|
125
|
+
doc = (e.get("doc-id") or "").upper().replace(" ", "")
|
|
126
|
+
eid = str(e.get("errata_id") or "")
|
|
127
|
+
if not doc or not eid.isdigit():
|
|
128
|
+
continue
|
|
129
|
+
rows.append((int(eid), doc, e.get("errata_status_code") or "",
|
|
130
|
+
e.get("errata_type_code"), e.get("section"),
|
|
131
|
+
e.get("orig_text"), e.get("correct_text"),
|
|
132
|
+
e.get("notes"), e.get("submitter_name"),
|
|
133
|
+
e.get("submit_date"), e.get("verifier_name"),
|
|
134
|
+
e.get("update_date"), now))
|
|
135
|
+
conn.execute("DELETE FROM errata")
|
|
136
|
+
conn.executemany(
|
|
137
|
+
"""INSERT OR REPLACE INTO errata(eid, doc_id, status, type, section,
|
|
138
|
+
says, should_say, notes, reported_by, reported_date,
|
|
139
|
+
handler, handler_date, fetched_at)
|
|
140
|
+
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)""", rows)
|
|
141
|
+
conn.commit()
|
|
142
|
+
set_meta(conn, BULK_META, now)
|
|
143
|
+
by = {}
|
|
144
|
+
for r in conn.execute("SELECT status, COUNT(*) c FROM errata "
|
|
145
|
+
"GROUP BY status"):
|
|
146
|
+
by[r["status"]] = r["c"]
|
|
147
|
+
return {"errata": len(rows), "documents": conn.execute(
|
|
148
|
+
"SELECT COUNT(DISTINCT doc_id) FROM errata").fetchone()[0],
|
|
149
|
+
"by_status": by, "synced_at": now}
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def bulk_synced_at(conn: sqlite3.Connection) -> str | None:
|
|
153
|
+
return get_meta(conn, BULK_META)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def cached(conn: sqlite3.Connection, doc_id: str,
|
|
157
|
+
status: str | None = None) -> list[dict]:
|
|
158
|
+
q = "SELECT * FROM errata WHERE doc_id=?"
|
|
159
|
+
args: list = [doc_id]
|
|
160
|
+
if status:
|
|
161
|
+
q += " AND status=?"
|
|
162
|
+
args.append(status)
|
|
163
|
+
q += " ORDER BY eid"
|
|
164
|
+
return [dict(r) for r in conn.execute(q, args)]
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def refreshed_at(conn: sqlite3.Connection, doc_id: str) -> str | None:
|
|
168
|
+
row = conn.execute(
|
|
169
|
+
"SELECT MIN(fetched_at) t FROM errata WHERE doc_id=?",
|
|
170
|
+
(doc_id,)).fetchone()
|
|
171
|
+
return row["t"] if row and row["t"] else None
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def ensure(conn: sqlite3.Connection, doc_id: str, refresh: bool = False,
|
|
175
|
+
max_age_days: float = 7.0) -> dict:
|
|
176
|
+
"""Cached errata for doc_id, fetching from the service when absent
|
|
177
|
+
(or when refresh=True / cache older than max_age_days)."""
|
|
178
|
+
if not doc_id.startswith("RFC"):
|
|
179
|
+
return {"doc_id": doc_id, "results": cached(conn, doc_id),
|
|
180
|
+
"source": "cache",
|
|
181
|
+
"note": "errata exist for RFC-series documents only"}
|
|
182
|
+
# a bulk sync is authoritative for every document, including "none"
|
|
183
|
+
fetched_at = refreshed_at(conn, doc_id) or bulk_synced_at(conn)
|
|
184
|
+
fresh = False
|
|
185
|
+
if fetched_at:
|
|
186
|
+
try:
|
|
187
|
+
t = datetime.strptime(fetched_at, "%Y-%m-%dT%H:%M:%SZ")\
|
|
188
|
+
.replace(tzinfo=timezone.utc)
|
|
189
|
+
fresh = ((datetime.now(timezone.utc) - t).total_seconds()
|
|
190
|
+
< max_age_days * 86400)
|
|
191
|
+
except ValueError:
|
|
192
|
+
fresh = False
|
|
193
|
+
if fresh and not refresh:
|
|
194
|
+
return {"doc_id": doc_id, "results": cached(conn, doc_id),
|
|
195
|
+
"source": "cache", "fetched_at": fetched_at}
|
|
196
|
+
if network_mode() == "offline":
|
|
197
|
+
return {"doc_id": doc_id, "results": cached(conn, doc_id),
|
|
198
|
+
"source": "cache (offline)", "fetched_at": fetched_at}
|
|
199
|
+
time.sleep(0.25) # politeness floor on the upstream service
|
|
200
|
+
page = fetch(int(doc_id[3:]))
|
|
201
|
+
store(conn, doc_id, parse_errata(page))
|
|
202
|
+
return {"doc_id": doc_id, "results": cached(conn, doc_id),
|
|
203
|
+
"source": "live", "fetched_at": refreshed_at(conn, doc_id)}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Filesystem helpers that behave the same on Windows, macOS and Linux.
|
|
2
|
+
|
|
3
|
+
atomic_write_bytes: write to a temp file in the destination directory, then
|
|
4
|
+
os.replace() it into place, so a reader (another MCP server process, the
|
|
5
|
+
reader UI) never sees a half-written RFC and a crash leaves no torn file.
|
|
6
|
+
On Windows os.replace fails while another process holds the target open
|
|
7
|
+
(antivirus, search indexers, a concurrent reader), so it retries briefly.
|
|
8
|
+
|
|
9
|
+
bulk_lock: one long-running writer at a time per library (init, sync,
|
|
10
|
+
index build). Short writes such as on-demand indexing of a single RFC rely
|
|
11
|
+
on SQLite's own locking (busy_timeout) instead.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import contextlib
|
|
17
|
+
import os
|
|
18
|
+
import time
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
STALE_LOCK_SECONDS = 6 * 3600
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def atomic_write_bytes(dest: Path, data: bytes, retries: int = 10) -> None:
|
|
25
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
26
|
+
tmp = dest.with_name(f".{dest.name}.{os.getpid()}.tmp")
|
|
27
|
+
with open(tmp, "wb") as fh:
|
|
28
|
+
fh.write(data)
|
|
29
|
+
fh.flush()
|
|
30
|
+
os.fsync(fh.fileno())
|
|
31
|
+
for attempt in range(retries):
|
|
32
|
+
try:
|
|
33
|
+
os.replace(tmp, dest)
|
|
34
|
+
return
|
|
35
|
+
except PermissionError:
|
|
36
|
+
if attempt == retries - 1:
|
|
37
|
+
with contextlib.suppress(OSError):
|
|
38
|
+
tmp.unlink()
|
|
39
|
+
raise
|
|
40
|
+
time.sleep(0.05 * (attempt + 1))
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class LockHeld(RuntimeError):
|
|
44
|
+
pass
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@contextlib.contextmanager
|
|
48
|
+
def bulk_lock(home: Path, name: str = "write"):
|
|
49
|
+
"""Exclusive lock file <home>/.<name>.lock (O_EXCL create, works on every
|
|
50
|
+
OS and on network filesystems). A lock older than STALE_LOCK_SECONDS is
|
|
51
|
+
taken over: its holder crashed."""
|
|
52
|
+
home.mkdir(parents=True, exist_ok=True)
|
|
53
|
+
lock = home / f".{name}.lock"
|
|
54
|
+
for _ in range(2):
|
|
55
|
+
try:
|
|
56
|
+
fd = os.open(lock, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
|
|
57
|
+
break
|
|
58
|
+
except FileExistsError:
|
|
59
|
+
try:
|
|
60
|
+
age = time.time() - lock.stat().st_mtime
|
|
61
|
+
except OSError:
|
|
62
|
+
continue
|
|
63
|
+
if age > STALE_LOCK_SECONDS:
|
|
64
|
+
with contextlib.suppress(OSError):
|
|
65
|
+
lock.unlink()
|
|
66
|
+
continue
|
|
67
|
+
holder = ""
|
|
68
|
+
with contextlib.suppress(OSError):
|
|
69
|
+
holder = lock.read_text("utf-8").strip()
|
|
70
|
+
raise LockHeld(f"another rfclib process is updating this library "
|
|
71
|
+
f"({holder or lock}); wait for it to finish") \
|
|
72
|
+
from None
|
|
73
|
+
else:
|
|
74
|
+
raise LockHeld(f"could not take {lock}")
|
|
75
|
+
try:
|
|
76
|
+
os.write(fd, f"pid {os.getpid()} since {time.strftime('%H:%M:%S')}"
|
|
77
|
+
.encode())
|
|
78
|
+
os.close(fd)
|
|
79
|
+
yield lock
|
|
80
|
+
finally:
|
|
81
|
+
with contextlib.suppress(OSError):
|
|
82
|
+
lock.unlink()
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
"""Cross-reference graph mined from the local RFC .txt corpus.
|
|
2
|
+
|
|
3
|
+
RFCs cite each other with bracketed tags ([RFC9000]) inside numbered
|
|
4
|
+
References sections (normative vs informative by subsection), and point
|
|
5
|
+
at specific sections with "Section 7.4.1 of [RFC9110]" / "[RFC9110],
|
|
6
|
+
Section 4.2" phrasing. Mining those patterns from text we already
|
|
7
|
+
store yields the full document-level citation graph plus section-level
|
|
8
|
+
anchors, with zero extra downloads (no XML mirror needed).
|
|
9
|
+
|
|
10
|
+
Build is incremental: ref_src keeps each source txt's sha256 so
|
|
11
|
+
re-running only re-mines changed documents.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import re
|
|
17
|
+
import sqlite3
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
from .sync.downloader import sha256_file
|
|
21
|
+
from .sync.state import file_path, mirror_of
|
|
22
|
+
|
|
23
|
+
_SERIES = r"(?:RFC|BCP|STD|FYI)"
|
|
24
|
+
|
|
25
|
+
# [RFC9000], [BCP 14], [STD 1] ... (spaced or not)
|
|
26
|
+
_REF_TAG = re.compile(rf"\[({_SERIES})\s?(\d{{1,5}})\]")
|
|
27
|
+
|
|
28
|
+
# Body header of the references area, e.g. "19. References",
|
|
29
|
+
# " 19.1. Normative References". TOC lines are excluded because
|
|
30
|
+
# they end with dot-leaders + page number, not with "References".
|
|
31
|
+
_HDR_REF = re.compile(
|
|
32
|
+
r"^\s*(\d+(?:\.\d+)*)?\.?\s{1,3}(Normative|Informative)?\s*"
|
|
33
|
+
r"References:?\s*$")
|
|
34
|
+
|
|
35
|
+
# Any numbered body header (used to leave the references area).
|
|
36
|
+
_HDR_NUM = re.compile(r"^\s*(\d+(?:\.\d+)*)\.?\s{1,3}[A-Z]")
|
|
37
|
+
|
|
38
|
+
# Section-anchored citations, the common phrasings:
|
|
39
|
+
# "Section 7.4.1 of [RFC9110]" "Sections 4.1 ... of RFC 9001"
|
|
40
|
+
# "[RFC9110], Section 4.2" "[RFC9110] Section 4.2"
|
|
41
|
+
# "RFC 9001, Section 5.4" "Appendix C of [RFC6962]"
|
|
42
|
+
_SEC_OF = re.compile(
|
|
43
|
+
rf"\b[Ss]ections?\s+((?:\d+|[A-Z])[\w.\-]{{0,12}})\s+of\s+\[?\s*"
|
|
44
|
+
rf"({_SERIES})\s?(\d{{1,5}})\s*\]?")
|
|
45
|
+
_SEC_TAG = re.compile(
|
|
46
|
+
rf"\[({_SERIES})\s?(\d{{1,5}})\](?:,|\s+)?[Ss]ections?\s+"
|
|
47
|
+
r"((?:\d+|[A-Z])[\w.\-]{0,12})")
|
|
48
|
+
_SEC_PLAIN = re.compile(
|
|
49
|
+
rf"\b({_SERIES})\s?(\d{{1,5}})\s*[,\u2014]+\s+[Ss]ections?\s+"
|
|
50
|
+
r"((?:\d+|[A-Z])[\w.\-]{0,12})")
|
|
51
|
+
_SEC_APPX = re.compile(
|
|
52
|
+
rf"\bAppendix\s+([A-Z](?:\.\d+)?)\s+of\s+\[?\s*({_SERIES})\s?"
|
|
53
|
+
r"(\d{1,5})\s*\]?")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _clean_sec(sec: str) -> str:
|
|
57
|
+
return sec.strip().rstrip(".,:;")[:16]
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def extract_refs(text: str, known: set[str]) -> list[tuple[str, str, str]]:
|
|
61
|
+
"""Mine (dst, kind, sec) edges from one RFC's txt.
|
|
62
|
+
|
|
63
|
+
kind is normative|informative|reference for tags inside the
|
|
64
|
+
References section, mention for section-anchored citations found
|
|
65
|
+
anywhere in the document. dst is only emitted when the target id
|
|
66
|
+
exists in `known`.
|
|
67
|
+
"""
|
|
68
|
+
edges: set[tuple[str, str, str]] = set()
|
|
69
|
+
zone: str | None = None # None|reference|normative|informative
|
|
70
|
+
zone_prefix: str | None = None # e.g. '19' while inside section 19(.x)
|
|
71
|
+
|
|
72
|
+
for line in text.splitlines():
|
|
73
|
+
m = _HDR_REF.match(line)
|
|
74
|
+
if m:
|
|
75
|
+
num, sub = m.group(1), m.group(2)
|
|
76
|
+
zone = ("normative" if sub == "Normative" else
|
|
77
|
+
"informative" if sub == "Informative" else "reference")
|
|
78
|
+
zone_prefix = num
|
|
79
|
+
continue
|
|
80
|
+
h = _HDR_NUM.match(line)
|
|
81
|
+
if h and zone is not None:
|
|
82
|
+
num = h.group(1)
|
|
83
|
+
if not (zone_prefix and (num == zone_prefix
|
|
84
|
+
or num.startswith(zone_prefix + "."))):
|
|
85
|
+
zone = None # left the references section
|
|
86
|
+
continue
|
|
87
|
+
|
|
88
|
+
# TOC lines (dot leaders) mirror headings + can contain tags;
|
|
89
|
+
# they never carry real citations, skip them entirely.
|
|
90
|
+
if ". ." in line:
|
|
91
|
+
continue
|
|
92
|
+
|
|
93
|
+
if zone is not None:
|
|
94
|
+
for series, num in _REF_TAG.findall(line):
|
|
95
|
+
dst = series + num
|
|
96
|
+
if dst in known:
|
|
97
|
+
edges.add((dst, zone, ""))
|
|
98
|
+
|
|
99
|
+
for sec, series, num in _SEC_OF.findall(line):
|
|
100
|
+
dst = series + num
|
|
101
|
+
if dst in known:
|
|
102
|
+
edges.add((dst, "mention", _clean_sec(sec)))
|
|
103
|
+
for sec, series, num in _SEC_APPX.findall(line):
|
|
104
|
+
dst = series + num
|
|
105
|
+
if dst in known:
|
|
106
|
+
edges.add((dst, "mention", _clean_sec(sec)))
|
|
107
|
+
for series, num, sec in _SEC_TAG.findall(line):
|
|
108
|
+
dst = series + num
|
|
109
|
+
if dst in known:
|
|
110
|
+
edges.add((dst, "mention", _clean_sec(sec)))
|
|
111
|
+
for series, num, sec in _SEC_PLAIN.findall(line):
|
|
112
|
+
dst = series + num
|
|
113
|
+
if dst in known:
|
|
114
|
+
edges.add((dst, "mention", _clean_sec(sec)))
|
|
115
|
+
|
|
116
|
+
return sorted(edges)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def build(conn: sqlite3.Connection, doc_ids: list[str] | None = None,
|
|
120
|
+
force: bool = False, progress=None) -> dict:
|
|
121
|
+
"""(Re-)mine ref edges for docs with a local txt; sha-incremental."""
|
|
122
|
+
known = {r[0] for r in conn.execute("SELECT doc_id FROM docs")}
|
|
123
|
+
src_sha = {r["doc_id"]: r["sha256"] for r in
|
|
124
|
+
conn.execute("SELECT doc_id, sha256 FROM ref_src")}
|
|
125
|
+
rows = conn.execute("SELECT doc_id, path FROM files WHERE fmt='txt'")\
|
|
126
|
+
.fetchall()
|
|
127
|
+
if doc_ids:
|
|
128
|
+
want = set(doc_ids)
|
|
129
|
+
rows = [r for r in rows if r["doc_id"] in want]
|
|
130
|
+
built = skipped = total_edges = 0
|
|
131
|
+
mirror = mirror_of(conn)
|
|
132
|
+
for i, r in enumerate(rows, 1):
|
|
133
|
+
path = file_path(conn, r["path"], mirror)
|
|
134
|
+
if not path.is_file():
|
|
135
|
+
continue
|
|
136
|
+
sha = sha256_file(path)
|
|
137
|
+
if not force and src_sha.get(r["doc_id"]) == sha:
|
|
138
|
+
skipped += 1
|
|
139
|
+
continue
|
|
140
|
+
text = path.read_text(encoding="utf-8", errors="replace")
|
|
141
|
+
edges = extract_refs(text, known)
|
|
142
|
+
conn.execute("DELETE FROM ref_edges WHERE src=?", (r["doc_id"],))
|
|
143
|
+
conn.executemany(
|
|
144
|
+
"INSERT OR IGNORE INTO ref_edges(src,dst,kind,sec) VALUES(?,?,?,?)",
|
|
145
|
+
[(r["doc_id"], d, k, s) for d, k, s in edges])
|
|
146
|
+
conn.execute(
|
|
147
|
+
"INSERT INTO ref_src(doc_id,sha256) VALUES(?,?) "
|
|
148
|
+
"ON CONFLICT(doc_id) DO UPDATE SET sha256=excluded.sha256",
|
|
149
|
+
(r["doc_id"], sha))
|
|
150
|
+
built += 1
|
|
151
|
+
total_edges += len(edges)
|
|
152
|
+
if progress:
|
|
153
|
+
progress(i, len(rows), r["doc_id"], len(edges))
|
|
154
|
+
if i % 250 == 0:
|
|
155
|
+
conn.commit()
|
|
156
|
+
conn.commit()
|
|
157
|
+
return {"built": built, "skipped_unchanged": skipped,
|
|
158
|
+
"new_edges": total_edges,
|
|
159
|
+
"total_edges": conn.execute(
|
|
160
|
+
"SELECT COUNT(*) FROM ref_edges").fetchone()[0]}
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
_RANK = {"normative": 0, "informative": 1, "reference": 2, "mention": 3}
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _num(doc_id: str) -> int:
|
|
167
|
+
m = re.match(r"\D*(\d+)", doc_id)
|
|
168
|
+
return int(m.group(1)) if m else 0
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _sec_key(s: str):
|
|
172
|
+
return [int(p) if p.isdigit() else p
|
|
173
|
+
for p in re.split(r"[.\-]", s)]
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def get_refs(conn: sqlite3.Connection, doc_id: str,
|
|
177
|
+
limit: int = 100) -> dict:
|
|
178
|
+
"""Merged citation view: outbound (this doc cites) and inbound."""
|
|
179
|
+
def side(col_own: str, col_peer: str) -> tuple[list[dict], int]:
|
|
180
|
+
agg: dict[str, dict] = {}
|
|
181
|
+
for r in conn.execute(
|
|
182
|
+
f"SELECT {col_peer} peer, kind, sec FROM ref_edges "
|
|
183
|
+
f"WHERE {col_own}=?", (doc_id,)):
|
|
184
|
+
e = agg.setdefault(r["peer"], {"doc_id": r["peer"],
|
|
185
|
+
"kinds": set(), "sections": set()})
|
|
186
|
+
e["kinds"].add(r["kind"])
|
|
187
|
+
if r["sec"]:
|
|
188
|
+
e["sections"].add(r["sec"])
|
|
189
|
+
titles = {}
|
|
190
|
+
if agg:
|
|
191
|
+
titles = {t["doc_id"]: (t["title"], bool(t["is_current"]))
|
|
192
|
+
for t in conn.execute(
|
|
193
|
+
"SELECT doc_id, title, is_current FROM docs "
|
|
194
|
+
"WHERE doc_id IN (%s)"
|
|
195
|
+
% ",".join("?" * len(agg)), list(agg))}
|
|
196
|
+
out = []
|
|
197
|
+
for peer, e in agg.items():
|
|
198
|
+
title, current = titles.get(peer, (None, True))
|
|
199
|
+
kinds = sorted(e["kinds"], key=lambda k: _RANK.get(k, 9))
|
|
200
|
+
out.append({"doc_id": peer, "title": title,
|
|
201
|
+
"is_current": current, "kinds": kinds,
|
|
202
|
+
"sections": sorted(e["sections"], key=_sec_key)[:12]})
|
|
203
|
+
out.sort(key=lambda x: (_RANK.get(x["kinds"][0], 9),
|
|
204
|
+
-_num(x["doc_id"])))
|
|
205
|
+
return out[:limit], len(out)
|
|
206
|
+
|
|
207
|
+
outbound, n_out = side("src", "dst")
|
|
208
|
+
inbound, n_in = side("dst", "src")
|
|
209
|
+
return {"doc_id": doc_id,
|
|
210
|
+
"outbound": outbound, "outbound_count": n_out,
|
|
211
|
+
"inbound": inbound, "inbound_count": n_in,
|
|
212
|
+
"built": conn.execute(
|
|
213
|
+
"SELECT COUNT(*) FROM ref_src").fetchone()[0] > 0}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Chunking and retrieval subpackage: section chunker, FTS5, vectors."""
|