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 @@
|
|
|
1
|
+
"""Ingest side of usblib: fetch the publications listed in data/sources.toml and index them."""
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""Download the files named in the USB-IF catalogue, unpack ZIP packages, and record what was fetched.
|
|
2
|
+
|
|
3
|
+
Input: data/usb-catalog.json (from discover_usb). Output, under the data directory:
|
|
4
|
+
raw/<slug>/<file> each downloaded file as published
|
|
5
|
+
raw/<slug>/pdf/<name> PDFs taken from ZIP packages, with the ZIP kept beside them
|
|
6
|
+
acquired.json per file: url, path, size, sha256, and for ZIPs the member list
|
|
7
|
+
|
|
8
|
+
Only PDFs and ZIPs are kept. Each download goes to a .part file and is renamed only when complete.
|
|
9
|
+
Extraction is safe: members with absolute paths or ".." are refused, and only PDF members are written.
|
|
10
|
+
Requests are throttled to one per second. Pass --dry-run to print the plan and the total size first.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from primarysource.usb.paths import PACKAGE_DATA
|
|
16
|
+
|
|
17
|
+
import argparse
|
|
18
|
+
import hashlib
|
|
19
|
+
import json
|
|
20
|
+
import sys
|
|
21
|
+
import time
|
|
22
|
+
import urllib.error
|
|
23
|
+
import urllib.request
|
|
24
|
+
import zipfile
|
|
25
|
+
from collections.abc import Sequence
|
|
26
|
+
from pathlib import Path, PurePosixPath
|
|
27
|
+
|
|
28
|
+
USER_AGENT = "usblib-acquire/0.1 (documentation corpus)"
|
|
29
|
+
DELAY = 1.0
|
|
30
|
+
CHUNK = 1 << 20
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _size(url: str) -> int | None:
|
|
34
|
+
req = urllib.request.Request(url, method="HEAD", headers={"User-Agent": USER_AGENT})
|
|
35
|
+
try:
|
|
36
|
+
with urllib.request.urlopen(req, timeout=60) as resp:
|
|
37
|
+
length = resp.headers.get("Content-Length")
|
|
38
|
+
return int(length) if length else None
|
|
39
|
+
except urllib.error.URLError:
|
|
40
|
+
return None
|
|
41
|
+
finally:
|
|
42
|
+
time.sleep(DELAY)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _download(url: str, dest: Path) -> tuple[int, str]:
|
|
46
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
47
|
+
part = dest.with_suffix(dest.suffix + ".part")
|
|
48
|
+
digest = hashlib.sha256()
|
|
49
|
+
size = 0
|
|
50
|
+
req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
|
|
51
|
+
with urllib.request.urlopen(req, timeout=300) as resp, part.open("wb") as out:
|
|
52
|
+
while chunk := resp.read(CHUNK):
|
|
53
|
+
out.write(chunk)
|
|
54
|
+
digest.update(chunk)
|
|
55
|
+
size += len(chunk)
|
|
56
|
+
part.replace(dest)
|
|
57
|
+
time.sleep(DELAY)
|
|
58
|
+
return size, digest.hexdigest()
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _safe_member(name: str) -> bool:
|
|
62
|
+
p = PurePosixPath(name)
|
|
63
|
+
return not p.is_absolute() and ".." not in p.parts and name.lower().endswith(".pdf")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _extract_pdfs(archive: Path, out_dir: Path) -> list[str]:
|
|
67
|
+
written: list[str] = []
|
|
68
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
69
|
+
with zipfile.ZipFile(archive) as zf:
|
|
70
|
+
for info in zf.infolist():
|
|
71
|
+
if info.is_dir() or not _safe_member(info.filename):
|
|
72
|
+
continue
|
|
73
|
+
target = out_dir / PurePosixPath(info.filename).name
|
|
74
|
+
with zf.open(info) as src, target.open("wb") as dst:
|
|
75
|
+
dst.write(src.read())
|
|
76
|
+
written.append(target.name)
|
|
77
|
+
return written
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def plan(catalog: dict, only: set[str] | None) -> list[tuple[str, str, str]]:
|
|
81
|
+
"""(slug, url, filename) for every PDF or ZIP the catalogue names."""
|
|
82
|
+
items = []
|
|
83
|
+
for entry in catalog["entries"]:
|
|
84
|
+
if only and entry["slug"] not in only:
|
|
85
|
+
continue
|
|
86
|
+
for url in entry["files"]:
|
|
87
|
+
name = PurePosixPath(url).name
|
|
88
|
+
items.append((entry["slug"], url, name))
|
|
89
|
+
return items
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
93
|
+
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
|
94
|
+
parser.add_argument("--catalog", type=Path, default=PACKAGE_DATA / "usb-catalog.json")
|
|
95
|
+
parser.add_argument("--data-dir", type=Path, default=Path("data"))
|
|
96
|
+
parser.add_argument("--only", nargs="*", default=None, help="catalogue slugs to fetch (default: all)")
|
|
97
|
+
parser.add_argument("--dry-run", action="store_true")
|
|
98
|
+
args = parser.parse_args(argv)
|
|
99
|
+
catalog = json.loads(args.catalog.read_text(encoding="utf-8"))
|
|
100
|
+
items = plan(catalog, set(args.only) if args.only else None)
|
|
101
|
+
sizes = {url: _size(url) for _, url, _ in items} if args.dry_run else {}
|
|
102
|
+
if args.dry_run:
|
|
103
|
+
total = sum(s or 0 for s in sizes.values())
|
|
104
|
+
for slug, url, name in items:
|
|
105
|
+
print(f"{slug:<40} {sizes.get(url) or '?':>12} {name}")
|
|
106
|
+
print(f"{len(items)} files, {total / 1e9:.2f} GB known", file=sys.stderr)
|
|
107
|
+
return 0
|
|
108
|
+
manifest_path = args.data_dir / "acquired.json"
|
|
109
|
+
manifest = json.loads(manifest_path.read_text(encoding="utf-8")) if manifest_path.exists() else {"schema": 1, "files": []}
|
|
110
|
+
done = {f["url"] for f in manifest["files"]}
|
|
111
|
+
for slug, url, name in items:
|
|
112
|
+
if url in done:
|
|
113
|
+
continue
|
|
114
|
+
dest = args.data_dir / "raw" / slug / name
|
|
115
|
+
try:
|
|
116
|
+
size, sha = _download(url, dest)
|
|
117
|
+
except (urllib.error.URLError, OSError) as e:
|
|
118
|
+
print(f"FAILED {url}: {e}", file=sys.stderr)
|
|
119
|
+
continue
|
|
120
|
+
record = {"slug": slug, "url": url, "path": str(dest.relative_to(args.data_dir)), "size": size, "sha256": sha}
|
|
121
|
+
if dest.suffix.lower() == ".zip":
|
|
122
|
+
record["pdfs"] = _extract_pdfs(dest, dest.parent / "pdf")
|
|
123
|
+
manifest["files"].append(record)
|
|
124
|
+
manifest_path.write_text(json.dumps(manifest, indent=2), encoding="utf-8")
|
|
125
|
+
print(f"ok {slug} {name} {size}", file=sys.stderr)
|
|
126
|
+
return 0
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
if __name__ == "__main__":
|
|
130
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Build the catalogue of USB-IF document-library entries and the files each one links to.
|
|
2
|
+
|
|
3
|
+
Walks the paged listing at https://www.usb.org/documents, opens each document page, and records the
|
|
4
|
+
PDF and ZIP links it carries. It writes data/usb-catalog.json. It downloads no specifications.
|
|
5
|
+
Requests are throttled to one per second with a generic User-Agent.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from primarysource.usb.paths import PACKAGE_DATA
|
|
11
|
+
|
|
12
|
+
import argparse
|
|
13
|
+
import json
|
|
14
|
+
import re
|
|
15
|
+
import sys
|
|
16
|
+
import time
|
|
17
|
+
import urllib.error
|
|
18
|
+
import urllib.request
|
|
19
|
+
from collections.abc import Sequence
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
BASE = "https://www.usb.org"
|
|
23
|
+
LISTING = BASE + "/documents?page={page}"
|
|
24
|
+
SLUG = re.compile(r"/document-library/([a-z0-9-]+)")
|
|
25
|
+
FILE = re.compile(r'href="(https?://[^"]+?\.(?:pdf|zip))"|href="(/sites/default/files/[^"]+?\.(?:pdf|zip))"', re.I)
|
|
26
|
+
TITLE = re.compile(r"<h1[^>]*>(.*?)</h1>", re.S)
|
|
27
|
+
USER_AGENT = "usblib-discover/0.1 (documentation catalogue)"
|
|
28
|
+
DELAY = 1.0
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def fetch_text(url: str) -> str | None:
|
|
32
|
+
req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
|
|
33
|
+
try:
|
|
34
|
+
with urllib.request.urlopen(req, timeout=60) as resp:
|
|
35
|
+
return resp.read().decode("utf-8", errors="replace")
|
|
36
|
+
except urllib.error.HTTPError as e:
|
|
37
|
+
print(f" {url}: HTTP {e.code}", file=sys.stderr)
|
|
38
|
+
return None
|
|
39
|
+
finally:
|
|
40
|
+
time.sleep(DELAY)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def listing_slugs(max_pages: int) -> list[str]:
|
|
44
|
+
seen: list[str] = []
|
|
45
|
+
for page in range(max_pages):
|
|
46
|
+
html = fetch_text(LISTING.format(page=page))
|
|
47
|
+
if html is None:
|
|
48
|
+
break
|
|
49
|
+
slugs = [s for s in dict.fromkeys(SLUG.findall(html)) if s not in seen]
|
|
50
|
+
if not slugs:
|
|
51
|
+
break
|
|
52
|
+
seen.extend(slugs)
|
|
53
|
+
print(f"listing page {page}: {len(slugs)} new, {len(seen)} total", file=sys.stderr)
|
|
54
|
+
return seen
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def entry_files(slug: str) -> tuple[str, list[str]]:
|
|
58
|
+
html = fetch_text(f"{BASE}/document-library/{slug}") or ""
|
|
59
|
+
title_m = TITLE.search(html)
|
|
60
|
+
title = re.sub(r"<[^>]+>", "", title_m.group(1)).strip() if title_m else slug
|
|
61
|
+
urls: list[str] = []
|
|
62
|
+
for a, b in FILE.findall(html):
|
|
63
|
+
url = a or (BASE + b)
|
|
64
|
+
if url not in urls:
|
|
65
|
+
urls.append(url)
|
|
66
|
+
return title, urls
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
70
|
+
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
|
71
|
+
parser.add_argument("--out", type=Path, default=PACKAGE_DATA / "usb-catalog.json")
|
|
72
|
+
parser.add_argument("--max-pages", type=int, default=40)
|
|
73
|
+
args = parser.parse_args(argv)
|
|
74
|
+
slugs = listing_slugs(args.max_pages)
|
|
75
|
+
entries = []
|
|
76
|
+
for slug in slugs:
|
|
77
|
+
title, files = entry_files(slug)
|
|
78
|
+
entries.append({"slug": slug, "title": title, "files": files})
|
|
79
|
+
print(f"{slug}: {len(files)} file(s)", file=sys.stderr)
|
|
80
|
+
args.out.parent.mkdir(parents=True, exist_ok=True)
|
|
81
|
+
args.out.write_text(json.dumps({"schema": 1, "source": LISTING.format(page="N"), "entries": entries}, indent=2), encoding="utf-8")
|
|
82
|
+
with_files = sum(1 for e in entries if e["files"])
|
|
83
|
+
print(f"wrote {args.out}: {len(entries)} entries, {with_files} with files", file=sys.stderr)
|
|
84
|
+
return 0
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
if __name__ == "__main__":
|
|
88
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Text extraction with ``pdftotext -layout``. The section splitter is primarysource.pdfcorpus.split."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import shutil
|
|
6
|
+
import subprocess
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
MISSING_MESSAGE = (
|
|
11
|
+
"pdftotext was not found on PATH. Install poppler-utils (on MSYS2: pacman -S mingw-w64-x86_64-poppler) "
|
|
12
|
+
"and make sure pdftotext is on PATH."
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ExtractError(RuntimeError):
|
|
17
|
+
"""Raised when text extraction cannot run or fails."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def find_pdftotext() -> str:
|
|
21
|
+
"""Return the path of pdftotext, or raise ExtractError saying how to get it."""
|
|
22
|
+
found = shutil.which("pdftotext")
|
|
23
|
+
if found is None:
|
|
24
|
+
raise ExtractError(MISSING_MESSAGE)
|
|
25
|
+
return found
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def extract_text(pdf: Path, *, timeout: float = 300.0) -> str:
|
|
29
|
+
"""Run ``pdftotext -layout`` on ``pdf`` and return the text (pages separated by form feeds)."""
|
|
30
|
+
exe = find_pdftotext()
|
|
31
|
+
try:
|
|
32
|
+
result = subprocess.run( # noqa: S603
|
|
33
|
+
[exe, "-layout", "-enc", "UTF-8", str(pdf), "-"],
|
|
34
|
+
capture_output=True,
|
|
35
|
+
timeout=timeout,
|
|
36
|
+
check=False,
|
|
37
|
+
)
|
|
38
|
+
except subprocess.TimeoutExpired as error:
|
|
39
|
+
raise ExtractError(f"pdftotext timed out after {timeout:.0f}s on {pdf}") from error
|
|
40
|
+
if result.returncode != 0:
|
|
41
|
+
detail = result.stderr.decode("utf-8", "replace").strip()
|
|
42
|
+
raise ExtractError(f"pdftotext failed on {pdf} (exit {result.returncode}): {detail}")
|
|
43
|
+
return result.stdout.decode("utf-8", "replace")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# The splitter is shared (primarysource.pdfcorpus.split); it is re-exported here for the importers that have
|
|
47
|
+
# always found it in this module.
|
|
48
|
+
from primarysource.pdfcorpus.split import split_sections as split_sections # noqa: E402
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
"""``usblib-fetch``: download the publications in data/sources.toml, extract their text and write the index.
|
|
2
|
+
|
|
3
|
+
This is the only module that touches the network, and only for URLs listed in sources.toml.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from primarysource.usb.paths import data_dir as default_data_dir, sources_file
|
|
9
|
+
|
|
10
|
+
import argparse
|
|
11
|
+
import os
|
|
12
|
+
import sys
|
|
13
|
+
import urllib.error
|
|
14
|
+
import urllib.request
|
|
15
|
+
from collections.abc import Sequence
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
from primarysource.usb.ingest.extract import (
|
|
19
|
+
ExtractError,
|
|
20
|
+
extract_text,
|
|
21
|
+
find_pdftotext,
|
|
22
|
+
split_sections,
|
|
23
|
+
)
|
|
24
|
+
from primarysource.usb.ingest.index import read_index, sha256_bytes, sha256_file, write_index
|
|
25
|
+
from primarysource.usb.ingest.sources import Source, SourcesError, load_sources
|
|
26
|
+
from primarysource.usb.models import Document
|
|
27
|
+
from primarysource.usb.spec import SPEC
|
|
28
|
+
from primarysource.pdfcorpus.fts import build_after_fetch
|
|
29
|
+
|
|
30
|
+
USER_AGENT = "usblib-fetch/0.1 (corpus fetcher; one request per listed URL)"
|
|
31
|
+
TIMEOUT_SECONDS = 120
|
|
32
|
+
MAX_BYTES = 200 * 1024 * 1024
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class FetchError(RuntimeError):
|
|
36
|
+
"""Raised when one document cannot be fetched or verified."""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def download(url: str, *, timeout: float = TIMEOUT_SECONDS) -> bytes:
|
|
40
|
+
"""GET ``url`` and return the body, which must look like a PDF."""
|
|
41
|
+
request = urllib.request.Request(url, headers={"User-Agent": USER_AGENT}) # noqa: S310
|
|
42
|
+
try:
|
|
43
|
+
with urllib.request.urlopen(request, timeout=timeout) as response: # noqa: S310
|
|
44
|
+
body: bytes = response.read(MAX_BYTES + 1)
|
|
45
|
+
except (urllib.error.URLError, TimeoutError, OSError) as error:
|
|
46
|
+
raise FetchError(f"download failed for {url}: {error}") from error
|
|
47
|
+
if len(body) > MAX_BYTES:
|
|
48
|
+
raise FetchError(f"{url}: larger than {MAX_BYTES} bytes, refusing")
|
|
49
|
+
if not body.startswith(b"%PDF-"):
|
|
50
|
+
raise FetchError(f"{url}: response is not a PDF")
|
|
51
|
+
return body
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def check_digest(doc_id: str, actual: str, expected: str) -> None:
|
|
55
|
+
"""Raise FetchError when a recorded digest exists and differs from ``actual``."""
|
|
56
|
+
if expected and actual != expected:
|
|
57
|
+
raise FetchError(
|
|
58
|
+
f"{doc_id}: sha256 {actual} does not match the recorded {expected}"
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _store(pdf: Path, data: bytes) -> None:
|
|
63
|
+
temp = pdf.with_name(pdf.name + ".part")
|
|
64
|
+
temp.write_bytes(data)
|
|
65
|
+
os.replace(temp, pdf)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def obtain_pdf(
|
|
69
|
+
source: Source, corpus: Path, recorded: str, from_file: Path | None
|
|
70
|
+
) -> str:
|
|
71
|
+
"""Make ``corpus/<doc_id>.pdf`` exist with a matching digest and return that digest.
|
|
72
|
+
|
|
73
|
+
``recorded`` is the digest from sources.toml, or failing that the one recorded by an earlier run.
|
|
74
|
+
A file already in place whose digest matches (or for which none is recorded) is kept. A mismatching
|
|
75
|
+
file is replaced by a fresh copy, which is itself verified before it is kept.
|
|
76
|
+
"""
|
|
77
|
+
pdf = corpus / f"{source.doc_id}.pdf"
|
|
78
|
+
if from_file is not None:
|
|
79
|
+
data = from_file.read_bytes()
|
|
80
|
+
if not data.startswith(b"%PDF-"):
|
|
81
|
+
raise FetchError(f"{source.doc_id}: {from_file} is not a PDF")
|
|
82
|
+
digest = sha256_bytes(data)
|
|
83
|
+
check_digest(source.doc_id, digest, source.sha256)
|
|
84
|
+
if not (pdf.exists() and sha256_file(pdf) == digest):
|
|
85
|
+
_store(pdf, data)
|
|
86
|
+
return digest
|
|
87
|
+
if pdf.exists():
|
|
88
|
+
digest = sha256_file(pdf)
|
|
89
|
+
if not recorded or digest == recorded:
|
|
90
|
+
return digest
|
|
91
|
+
data = download(source.url)
|
|
92
|
+
digest = sha256_bytes(data)
|
|
93
|
+
check_digest(source.doc_id, digest, recorded)
|
|
94
|
+
_store(pdf, data)
|
|
95
|
+
return digest
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def build_document(source: Source, corpus: Path, digest: str) -> Document:
|
|
99
|
+
"""Extract text for ``source`` into the corpus and return its index entry."""
|
|
100
|
+
text = extract_text(corpus / f"{source.doc_id}.pdf")
|
|
101
|
+
text_name = f"{source.doc_id}.txt"
|
|
102
|
+
(corpus / text_name).write_text(text, encoding="utf-8", newline="")
|
|
103
|
+
return Document(
|
|
104
|
+
doc_id=source.doc_id,
|
|
105
|
+
number=source.number,
|
|
106
|
+
title=source.title,
|
|
107
|
+
date=source.date,
|
|
108
|
+
status=source.status,
|
|
109
|
+
url=source.url,
|
|
110
|
+
sha256=digest,
|
|
111
|
+
text_file=text_name,
|
|
112
|
+
sections=split_sections(text),
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def parse_from_files(values: Sequence[str]) -> dict[str, Path]:
|
|
117
|
+
"""Parse ``DOC_ID=PATH`` arguments."""
|
|
118
|
+
result: dict[str, Path] = {}
|
|
119
|
+
for value in values:
|
|
120
|
+
doc_id, sep, path = value.partition("=")
|
|
121
|
+
if not sep or not doc_id or not path:
|
|
122
|
+
raise SourcesError(f"--from-file expects DOC_ID=PATH, got {value!r}")
|
|
123
|
+
result[doc_id] = Path(path)
|
|
124
|
+
return result
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def run(data_dir: Path, from_files: dict[str, Path], only: Sequence[str]) -> int:
|
|
128
|
+
"""Fetch, verify, extract and index. Returns the process exit code."""
|
|
129
|
+
sources = load_sources(sources_file(data_dir))
|
|
130
|
+
known = {s.doc_id for s in sources}
|
|
131
|
+
unknown = sorted((set(from_files) | set(only)) - known)
|
|
132
|
+
if unknown:
|
|
133
|
+
raise SourcesError(f"not in sources.toml: {', '.join(unknown)}")
|
|
134
|
+
find_pdftotext()
|
|
135
|
+
corpus = data_dir / "corpus"
|
|
136
|
+
corpus.mkdir(parents=True, exist_ok=True)
|
|
137
|
+
index_path = corpus / "index.json"
|
|
138
|
+
previous = {d.doc_id: d for d in read_index(index_path)}
|
|
139
|
+
wanted = set(only) | set(from_files) if (only or from_files) else known
|
|
140
|
+
documents: list[Document] = []
|
|
141
|
+
failures = 0
|
|
142
|
+
for source in sources:
|
|
143
|
+
old = previous.get(source.doc_id)
|
|
144
|
+
if source.doc_id not in wanted:
|
|
145
|
+
if old is not None and (corpus / old.text_file).exists():
|
|
146
|
+
documents.append(old)
|
|
147
|
+
continue
|
|
148
|
+
recorded = source.sha256 or (old.sha256 if old else "")
|
|
149
|
+
try:
|
|
150
|
+
digest = obtain_pdf(source, corpus, recorded, from_files.get(source.doc_id))
|
|
151
|
+
document = build_document(source, corpus, digest)
|
|
152
|
+
except (FetchError, ExtractError, OSError) as error:
|
|
153
|
+
print(f"{source.doc_id}: {error}", file=sys.stderr)
|
|
154
|
+
failures += 1
|
|
155
|
+
continue
|
|
156
|
+
note = "" if source.sha256 else " (digest recorded in index.json)"
|
|
157
|
+
print(
|
|
158
|
+
f"{source.doc_id}: {len(document.sections)} sections, sha256 {digest[:12]}{note}"
|
|
159
|
+
)
|
|
160
|
+
documents.append(document)
|
|
161
|
+
write_index(index_path, documents)
|
|
162
|
+
print(f"wrote {index_path} with {len(documents)} document(s)")
|
|
163
|
+
if not build_after_fetch(SPEC, corpus):
|
|
164
|
+
failures += 1
|
|
165
|
+
return 1 if failures else 0
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
169
|
+
parser = argparse.ArgumentParser(prog="usblib-fetch", description=__doc__)
|
|
170
|
+
parser.add_argument(
|
|
171
|
+
"--data-dir",
|
|
172
|
+
type=Path,
|
|
173
|
+
default=default_data_dir(),
|
|
174
|
+
help="directory holding sources.toml and corpus/ (default: $USBLIB_DATA, else the collection's home)",
|
|
175
|
+
)
|
|
176
|
+
parser.add_argument(
|
|
177
|
+
"--from-file",
|
|
178
|
+
action="append",
|
|
179
|
+
default=[],
|
|
180
|
+
metavar="DOC_ID=PATH",
|
|
181
|
+
help="use a PDF you already have for DOC_ID instead of downloading it (repeatable)",
|
|
182
|
+
)
|
|
183
|
+
parser.add_argument(
|
|
184
|
+
"doc_ids",
|
|
185
|
+
nargs="*",
|
|
186
|
+
help="only these documents (default: all, or those named by --from-file)",
|
|
187
|
+
)
|
|
188
|
+
args = parser.parse_args(argv)
|
|
189
|
+
try:
|
|
190
|
+
return run(args.data_dir, parse_from_files(args.from_file), args.doc_ids)
|
|
191
|
+
except (SourcesError, ExtractError) as error:
|
|
192
|
+
print(f"usblib-fetch: {error}", file=sys.stderr)
|
|
193
|
+
return 2
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
if __name__ == "__main__":
|
|
197
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""The usb corpus index: the shared one (primarysource.pdfcorpus.index)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from primarysource.pdfcorpus.index import (
|
|
6
|
+
SCHEMA,
|
|
7
|
+
document_from_dict,
|
|
8
|
+
read_index,
|
|
9
|
+
sha256_bytes,
|
|
10
|
+
sha256_file,
|
|
11
|
+
write_index,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
__all__ = ["SCHEMA", "document_from_dict", "read_index", "sha256_bytes", "sha256_file", "write_index"]
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""Parser for data/sources.toml, the list of publications ``usblib-fetch`` may download."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
import tomllib
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
_DOC_ID = re.compile(r"^[a-z0-9][a-z0-9._-]*$")
|
|
11
|
+
_DATE = re.compile(r"^\d{4}-(0[1-9]|1[0-2])$")
|
|
12
|
+
_SHA256 = re.compile(r"^[0-9a-f]{64}$")
|
|
13
|
+
_REQUIRED = ("doc_id", "url", "number", "title", "date", "status")
|
|
14
|
+
_KNOWN = (*_REQUIRED, "sha256")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class SourcesError(ValueError):
|
|
18
|
+
"""Raised when sources.toml is missing, malformed, or inconsistent."""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True, slots=True)
|
|
22
|
+
class Source:
|
|
23
|
+
"""One entry of sources.toml. ``sha256`` is empty until recorded."""
|
|
24
|
+
|
|
25
|
+
doc_id: str
|
|
26
|
+
url: str
|
|
27
|
+
number: str
|
|
28
|
+
title: str
|
|
29
|
+
date: str
|
|
30
|
+
status: str
|
|
31
|
+
sha256: str = ""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def parse_sources(text: str) -> tuple[Source, ...]:
|
|
35
|
+
"""Parse the text of sources.toml: an array of ``[[source]]`` tables."""
|
|
36
|
+
try:
|
|
37
|
+
data = tomllib.loads(text)
|
|
38
|
+
except tomllib.TOMLDecodeError as error:
|
|
39
|
+
raise SourcesError(f"sources.toml is not valid TOML: {error}") from error
|
|
40
|
+
entries = data.get("source")
|
|
41
|
+
if not isinstance(entries, list) or not entries:
|
|
42
|
+
raise SourcesError("sources.toml has no [[source]] entries")
|
|
43
|
+
sources: list[Source] = []
|
|
44
|
+
seen: set[str] = set()
|
|
45
|
+
for position, entry in enumerate(entries, start=1):
|
|
46
|
+
label = f"source #{position}"
|
|
47
|
+
if not isinstance(entry, dict):
|
|
48
|
+
raise SourcesError(f"{label}: not a table")
|
|
49
|
+
missing = [
|
|
50
|
+
key
|
|
51
|
+
for key in _REQUIRED
|
|
52
|
+
if not isinstance(entry.get(key), str) or not entry[key].strip()
|
|
53
|
+
]
|
|
54
|
+
if missing:
|
|
55
|
+
raise SourcesError(
|
|
56
|
+
f"{label}: missing or empty field(s): {', '.join(missing)}"
|
|
57
|
+
)
|
|
58
|
+
unknown = sorted(set(entry) - set(_KNOWN))
|
|
59
|
+
if unknown:
|
|
60
|
+
raise SourcesError(f"{label}: unknown field(s): {', '.join(unknown)}")
|
|
61
|
+
doc_id = entry["doc_id"]
|
|
62
|
+
label = f"source {doc_id!r}"
|
|
63
|
+
if not _DOC_ID.match(doc_id):
|
|
64
|
+
raise SourcesError(f"{label}: doc_id must be a lowercase file-safe slug")
|
|
65
|
+
if doc_id in seen:
|
|
66
|
+
raise SourcesError(f"{label}: duplicate doc_id")
|
|
67
|
+
seen.add(doc_id)
|
|
68
|
+
if not entry["url"].startswith("https://"):
|
|
69
|
+
raise SourcesError(f"{label}: url must be https")
|
|
70
|
+
if not _DATE.match(entry["date"]):
|
|
71
|
+
raise SourcesError(f"{label}: date must be YYYY-MM, got {entry['date']!r}")
|
|
72
|
+
digest = entry.get("sha256", "")
|
|
73
|
+
if digest and not _SHA256.match(digest):
|
|
74
|
+
raise SourcesError(f"{label}: sha256 must be 64 lowercase hex digits")
|
|
75
|
+
sources.append(Source(**{key: entry[key] for key in _KNOWN if key in entry}))
|
|
76
|
+
return tuple(sources)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def load_sources(path: Path) -> tuple[Source, ...]:
|
|
80
|
+
"""Read and parse ``path``."""
|
|
81
|
+
try:
|
|
82
|
+
text = path.read_text(encoding="utf-8")
|
|
83
|
+
except OSError as error:
|
|
84
|
+
raise SourcesError(f"cannot read {path}: {error}") from error
|
|
85
|
+
return parse_sources(text)
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Where the usb collection keeps its files (primarysource PLAN.md §5.8).
|
|
2
|
+
|
|
3
|
+
The data directory holds `sources.toml` overrides and the fetched `corpus/`: `$USBLIB_DATA` if set (the old
|
|
4
|
+
setting), else `primarysource.home.collection_home("usb")`, which is `$PRIMARYSOURCE_HOME/usb` or the per-user
|
|
5
|
+
`primarysource/usb`. Never the working directory and never beside the installed package. The tracked metadata
|
|
6
|
+
(`sources.toml`, catalogues, digests) ships in the package under `data/`.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from primarysource.home import collection_home
|
|
15
|
+
|
|
16
|
+
PACKAGE_DATA = Path(__file__).resolve().parent / "data"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def data_dir() -> Path:
|
|
20
|
+
env = os.environ.get("USBLIB_DATA")
|
|
21
|
+
return Path(env) if env else collection_home("usb", legacy_app="usblib")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def corpus_dir() -> Path:
|
|
25
|
+
"""`$USBLIB_CORPUS` if set, else `corpus/` in the data directory."""
|
|
26
|
+
env = os.environ.get("USBLIB_CORPUS")
|
|
27
|
+
return Path(env) if env else data_dir() / "corpus"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def sources_file(data: Path, name: str = "sources.toml") -> Path:
|
|
31
|
+
"""The data directory's own `sources.toml` if it has one, else the one shipped in the package."""
|
|
32
|
+
local = data / name
|
|
33
|
+
return local if local.is_file() else PACKAGE_DATA / name
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Load the usb corpus: the shared loader (primarysource.pdfcorpus.corpus) with the usb spec."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from primarysource.usb.spec import SPEC
|
|
8
|
+
from primarysource.pdfcorpus import corpus as _shared
|
|
9
|
+
from primarysource.pdfcorpus.corpus import SCHEMA_VERSION, CorpusError
|
|
10
|
+
|
|
11
|
+
ENV_VAR = SPEC.env_var
|
|
12
|
+
FETCH_COMMAND = SPEC.fetch_command
|
|
13
|
+
__all__ = ["ENV_VAR", "FETCH_COMMAND", "SCHEMA_VERSION", "Corpus", "CorpusError", "default_corpus_dir", "load_corpus"]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Corpus(_shared.Corpus):
|
|
17
|
+
"""A usb corpus: the shared one with this collection's hooks as defaults."""
|
|
18
|
+
|
|
19
|
+
def __init__(self, root: Path, documents: tuple[_shared.Document, ...], defaults: dict[str, str] | None = None) -> None:
|
|
20
|
+
super().__init__(
|
|
21
|
+
root,
|
|
22
|
+
documents,
|
|
23
|
+
defaults or {},
|
|
24
|
+
SPEC.alias,
|
|
25
|
+
fetch_command=SPEC.fetch_command,
|
|
26
|
+
index_command=SPEC.index_command,
|
|
27
|
+
prefix=f"{SPEC.name}_",
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def default_corpus_dir() -> Path:
|
|
32
|
+
"""``$USBLIB_CORPUS`` if set, else ``corpus/`` in the collection's data directory."""
|
|
33
|
+
return SPEC.corpus_dir()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def load_corpus(root: Path | None = None) -> _shared.Corpus:
|
|
37
|
+
"""Read and validate the index; raise ``CorpusError`` whose ``action`` is the fetch command."""
|
|
38
|
+
return _shared.load_corpus(SPEC, root)
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""MCP server for the local USB-IF specifications: the shared server (primarysource.pdfcorpus.server) with the
|
|
2
|
+
usb spec. `primarysource mcp --collection usb` serves `mcp`."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from primarysource.usb.spec import SPEC
|
|
7
|
+
from primarysource.pdfcorpus.server import CorpusServer
|
|
8
|
+
|
|
9
|
+
SERVER = CorpusServer(SPEC)
|
|
10
|
+
mcp = SERVER.mcp
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def main() -> None:
|
|
14
|
+
"""Serve over stdio (`primarysource mcp --collection usb` is the usual way)."""
|
|
15
|
+
mcp.run(transport="stdio")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
if __name__ == "__main__":
|
|
19
|
+
main()
|