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,137 @@
|
|
|
1
|
+
"""The Broadband Forum catalogue: data-model XML pinned to a commit, and Technical Reports pinned by sha256.
|
|
2
|
+
|
|
3
|
+
Two kinds of source, two licences, two placements (`terms`):
|
|
4
|
+
- `models`: XML files of https://github.com/BroadbandForum/cwmp-data-models at one pinned commit. Each file
|
|
5
|
+
carries a BSD-3-Clause-style notice (redistribution with notice permitted), so placement may be an image layer.
|
|
6
|
+
- `reports`: Technical Report PDFs (TR-069 Amendment 6 Corrigendum 1 so far). The Forum's terms of use allow
|
|
7
|
+
use for implementing and distribution only as a whole copy with the notice, and forbid modification or
|
|
8
|
+
derivative works, so they are fetched by the user into a volume.
|
|
9
|
+
|
|
10
|
+
Metadata only, never text. `catalogue.json` is package data.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import re
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from importlib import resources
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
from primarysource.errors import LibError
|
|
22
|
+
|
|
23
|
+
DOC_ID = re.compile(r"[a-z][a-z0-9]*(?:-[a-z0-9]+)*")
|
|
24
|
+
COMMIT = re.compile(r"[0-9a-f]{40}")
|
|
25
|
+
SHA256 = re.compile(r"[0-9a-f]{64}")
|
|
26
|
+
HOSTS = ("raw.githubusercontent.com", "www.broadband-forum.org")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True, slots=True)
|
|
30
|
+
class Terms:
|
|
31
|
+
licence: str
|
|
32
|
+
placement: str
|
|
33
|
+
terms_url: str
|
|
34
|
+
terms_read: str
|
|
35
|
+
summary: str
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True, slots=True)
|
|
39
|
+
class Model:
|
|
40
|
+
"""One data-model XML file at the pinned commit."""
|
|
41
|
+
|
|
42
|
+
id: str
|
|
43
|
+
path: str
|
|
44
|
+
title: str
|
|
45
|
+
size: int
|
|
46
|
+
sha256: str
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass(frozen=True, slots=True)
|
|
50
|
+
class Report:
|
|
51
|
+
"""One Technical Report PDF."""
|
|
52
|
+
|
|
53
|
+
id: str
|
|
54
|
+
designation: str
|
|
55
|
+
title: str
|
|
56
|
+
issue: str
|
|
57
|
+
page_url: str
|
|
58
|
+
source_url: str
|
|
59
|
+
size: int
|
|
60
|
+
sha256: str
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass(frozen=True, slots=True)
|
|
64
|
+
class Catalogue:
|
|
65
|
+
repo: str
|
|
66
|
+
commit: str
|
|
67
|
+
raw_base: str
|
|
68
|
+
model_terms: Terms
|
|
69
|
+
report_terms: Terms
|
|
70
|
+
models: tuple[Model, ...]
|
|
71
|
+
reports: tuple[Report, ...]
|
|
72
|
+
|
|
73
|
+
def model(self, doc_id: str) -> Model:
|
|
74
|
+
for found in self.models:
|
|
75
|
+
if found.id == doc_id:
|
|
76
|
+
return found
|
|
77
|
+
raise LibError(f"no BBF data model {doc_id!r} in the catalogue", "bbf_docs lists what is catalogued")
|
|
78
|
+
|
|
79
|
+
def report(self, doc_id: str) -> Report:
|
|
80
|
+
for found in self.reports:
|
|
81
|
+
if found.id == doc_id:
|
|
82
|
+
return found
|
|
83
|
+
raise LibError(f"no BBF report {doc_id!r} in the catalogue", "bbf_docs lists what is catalogued")
|
|
84
|
+
|
|
85
|
+
def model_url(self, model: Model) -> str:
|
|
86
|
+
return f"{self.raw_base}{self.commit}/{model.path}"
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _terms(raw: dict[str, Any]) -> Terms:
|
|
90
|
+
terms = Terms(raw["licence"], raw["placement"], raw["terms_url"], raw["terms_read"], raw["summary"])
|
|
91
|
+
if terms.placement not in ("layer", "volume"):
|
|
92
|
+
raise ValueError("terms.placement must be 'layer' or 'volume'")
|
|
93
|
+
return terms
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def parse_catalogue(raw: dict[str, Any]) -> Catalogue:
|
|
97
|
+
"""Validate a catalogue document. Raises `ValueError`: a malformed catalogue is a packaging bug."""
|
|
98
|
+
if not COMMIT.fullmatch(raw["commit"]):
|
|
99
|
+
raise ValueError("catalogue commit must be 40 lowercase hex digits")
|
|
100
|
+
models = tuple(Model(m["id"], m["path"], m["title"], int(m["size"]), m["sha256"]) for m in raw["models"])
|
|
101
|
+
reports = tuple(
|
|
102
|
+
Report(
|
|
103
|
+
r["id"],
|
|
104
|
+
r["designation"],
|
|
105
|
+
r["title"],
|
|
106
|
+
r["issue"],
|
|
107
|
+
r["page_url"],
|
|
108
|
+
r["source_url"],
|
|
109
|
+
int(r["size"]),
|
|
110
|
+
r["sha256"],
|
|
111
|
+
)
|
|
112
|
+
for r in raw["reports"]
|
|
113
|
+
)
|
|
114
|
+
entries: list[Model | Report] = [*models, *reports]
|
|
115
|
+
for entry in entries:
|
|
116
|
+
if not DOC_ID.fullmatch(entry.id) or not SHA256.fullmatch(entry.sha256):
|
|
117
|
+
raise ValueError(f"catalogue entry {entry.id!r} is malformed")
|
|
118
|
+
for model in models:
|
|
119
|
+
if model.path != f"{model.id}.xml":
|
|
120
|
+
raise ValueError(f"catalogue entry {model.id!r}: path must be <id>.xml")
|
|
121
|
+
ids = [e.id for e in entries]
|
|
122
|
+
if len(set(ids)) != len(ids):
|
|
123
|
+
raise ValueError("duplicate ids in the catalogue")
|
|
124
|
+
return Catalogue(
|
|
125
|
+
raw["repo"],
|
|
126
|
+
raw["commit"],
|
|
127
|
+
raw["raw_base"],
|
|
128
|
+
_terms(raw["terms"]["models"]),
|
|
129
|
+
_terms(raw["terms"]["reports"]),
|
|
130
|
+
models,
|
|
131
|
+
reports,
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def catalogue() -> Catalogue:
|
|
136
|
+
text = resources.files("primarysource.bbf").joinpath("catalogue.json").read_text(encoding="utf-8")
|
|
137
|
+
return parse_catalogue(json.loads(text))
|
primarysource/bbf/cli.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""`primarysource bbf ...`: the collection's own commands.
|
|
2
|
+
|
|
3
|
+
primarysource bbf import-models [ID ...]
|
|
4
|
+
primarysource bbf import-report <ID> [--file PDF]
|
|
5
|
+
primarysource bbf docs [FILTER]
|
|
6
|
+
primarysource bbf outline <doc> [--under PATH] [--limit N] [--start N]
|
|
7
|
+
primarysource bbf read <ref> [--max-tokens N]
|
|
8
|
+
primarysource bbf search <query> [--doc ID] [--limit N] [--start N]
|
|
9
|
+
primarysource bbf cite <ref>
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import argparse
|
|
15
|
+
import sys
|
|
16
|
+
from collections.abc import Sequence
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
from primarysource.bbf import importer, library
|
|
20
|
+
from primarysource.errors import LibError
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _parser() -> argparse.ArgumentParser:
|
|
24
|
+
parser = argparse.ArgumentParser(prog="primarysource bbf", description="Broadband Forum data models and reports")
|
|
25
|
+
commands = parser.add_subparsers(dest="command", required=True)
|
|
26
|
+
models = commands.add_parser("import-models", help="fetch and verify data-model XML (one request per second)")
|
|
27
|
+
models.add_argument("ids", nargs="*", help="file stems to import (default: every catalogued file)")
|
|
28
|
+
report = commands.add_parser(
|
|
29
|
+
"import-report", help="fetch (or read a file), verify and extract a Technical Report"
|
|
30
|
+
)
|
|
31
|
+
report.add_argument("id", help="e.g. tr-069-1-6-1")
|
|
32
|
+
report.add_argument("--file", type=Path, help="the PDF you downloaded yourself, instead of fetching it")
|
|
33
|
+
commands.add_parser("docs", help="the catalogue and what is imported").add_argument(
|
|
34
|
+
"filter", nargs="?", default=""
|
|
35
|
+
)
|
|
36
|
+
outline = commands.add_parser("outline", help="clauses of a report, or nodes of a data model")
|
|
37
|
+
outline.add_argument("doc")
|
|
38
|
+
outline.add_argument("--under", default="")
|
|
39
|
+
outline.add_argument("--limit", type=int, default=200)
|
|
40
|
+
outline.add_argument("--start", type=int, default=0)
|
|
41
|
+
read = commands.add_parser("read", help="read by ref")
|
|
42
|
+
read.add_argument("ref")
|
|
43
|
+
read.add_argument("--max-tokens", type=int, default=2000)
|
|
44
|
+
search = commands.add_parser("search", help="find units containing words")
|
|
45
|
+
search.add_argument("query")
|
|
46
|
+
search.add_argument("--doc")
|
|
47
|
+
search.add_argument("--limit", type=int, default=10)
|
|
48
|
+
search.add_argument("--start", type=int, default=0)
|
|
49
|
+
commands.add_parser("cite", help="the citation for a ref").add_argument("ref")
|
|
50
|
+
return parser
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _out(text: str) -> int:
|
|
54
|
+
sys.stdout.write(text if text.endswith("\n") else text + "\n")
|
|
55
|
+
return 0
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def main(argv: Sequence[str]) -> int:
|
|
59
|
+
args = _parser().parse_args(list(argv))
|
|
60
|
+
home = library.default_home()
|
|
61
|
+
try:
|
|
62
|
+
if args.command == "import-models":
|
|
63
|
+
result = importer.import_models(home, args.ids)
|
|
64
|
+
return _out(f"imported {len(result.ids)} data-model files, {result.requests} request(s), into {home}")
|
|
65
|
+
if args.command == "import-report":
|
|
66
|
+
result = importer.import_report(home, args.id, file=args.file)
|
|
67
|
+
return _out(f"imported {args.id}, {result.requests} request(s), into {home}")
|
|
68
|
+
if args.command == "docs":
|
|
69
|
+
return _out(library.docs(args.filter))
|
|
70
|
+
if args.command == "outline":
|
|
71
|
+
return _out(library.outline(args.doc, under=args.under, limit=args.limit, start=args.start))
|
|
72
|
+
if args.command == "read":
|
|
73
|
+
return _out(library.read(args.ref, max_tokens=args.max_tokens))
|
|
74
|
+
if args.command == "search":
|
|
75
|
+
return _out(library.search(args.query, doc=args.doc, limit=args.limit, start=args.start))
|
|
76
|
+
return _out(library.cite(args.ref))
|
|
77
|
+
except LibError as exc:
|
|
78
|
+
print(exc.as_text(), file=sys.stderr)
|
|
79
|
+
return 1
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
"""Read a Broadband Forum data-model XML file (CWMP and USP `dm:document`) into addressable nodes.
|
|
2
|
+
|
|
3
|
+
A node is an object, a parameter, a command, an event, a profile, a data type or a model. Its address (the part
|
|
4
|
+
of a ref after `§`) is the full path: `Device.IP.Interface.{i}` for an object (written without its trailing dot;
|
|
5
|
+
`Device.IP.Interface.{i}.` also resolves), `Device.IP.Interface.{i}.Enable` for a parameter,
|
|
6
|
+
`Device.Reboot()` for a command, `Device.Boot!` for an event, `Baseline:1` for a profile, `IPAddress` for a data
|
|
7
|
+
type, `Root:2.22` for a model. A command's arguments are `Device.Reboot().Input.Delay` and
|
|
8
|
+
`Device.Reboot().Output.X`.
|
|
9
|
+
|
|
10
|
+
`¶n` counts the paragraphs of a node's description, in order. The description is kept as the file gives it: BBF
|
|
11
|
+
template macros (`{{param}}`, `{{enum}}`, `{{numentries}}`, `{{object}}`) are NOT expanded; they are the
|
|
12
|
+
publisher's own markup and expanding them would be this code's interpretation, not the standard's text.
|
|
13
|
+
A profile's units are its description paragraphs and then one line per object or parameter it requires.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import re
|
|
19
|
+
import xml.etree.ElementTree as ET # noqa: S405 - elements come from the hardened parser in `_xml`
|
|
20
|
+
from dataclasses import dataclass, field
|
|
21
|
+
|
|
22
|
+
from primarysource.bbf import _xml
|
|
23
|
+
from primarysource.errors import LibError
|
|
24
|
+
|
|
25
|
+
KINDS = ("model", "object", "parameter", "command", "event", "profile", "datatype")
|
|
26
|
+
MAX_NODES = 400_000
|
|
27
|
+
MAX_ENUM = 200
|
|
28
|
+
_PARAGRAPH_BREAK = re.compile(r"\n[ \t]*\n")
|
|
29
|
+
_TITLE = re.compile(rb"<!--\s*\n?\s*([^\n]+)")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True, slots=True)
|
|
33
|
+
class Node:
|
|
34
|
+
"""One addressable item. `props` is the ordered attribute summary shown in the header line."""
|
|
35
|
+
|
|
36
|
+
kind: str
|
|
37
|
+
id: str
|
|
38
|
+
props: tuple[tuple[str, str], ...] = ()
|
|
39
|
+
paragraphs: tuple[str, ...] = ()
|
|
40
|
+
component: str = ""
|
|
41
|
+
|
|
42
|
+
def header(self) -> str:
|
|
43
|
+
shown = " ".join(f"{k}={v}" for k, v in self.props if v != "")
|
|
44
|
+
text = f"{self.kind} {self.id}" + (f" [{shown}]" if shown else "")
|
|
45
|
+
return text + (f" (component {self.component})" if self.component else "")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass(frozen=True, slots=True)
|
|
49
|
+
class DataModel:
|
|
50
|
+
"""A parsed file. `nodes` are in document order; `imports` are the files it imports."""
|
|
51
|
+
|
|
52
|
+
title: str
|
|
53
|
+
spec: str
|
|
54
|
+
file: str
|
|
55
|
+
imports: tuple[str, ...]
|
|
56
|
+
nodes: tuple[Node, ...]
|
|
57
|
+
index: dict[str, int] = field(default_factory=dict)
|
|
58
|
+
|
|
59
|
+
def find(self, wanted: str) -> tuple[Node, str]:
|
|
60
|
+
"""The node `wanted` names and a warning ("" if none): exact id, then without a trailing dot, then with
|
|
61
|
+
case ignored. Raises `LibError` with close matches when nothing fits."""
|
|
62
|
+
text = wanted.strip()
|
|
63
|
+
for candidate in (text, text.rstrip(".") if text.endswith(".") else text):
|
|
64
|
+
if candidate in self.index:
|
|
65
|
+
return self._answer(candidate)
|
|
66
|
+
low = text.rstrip(".").casefold()
|
|
67
|
+
for key in self.index:
|
|
68
|
+
if key.casefold() == low:
|
|
69
|
+
return self._answer(key)
|
|
70
|
+
import difflib
|
|
71
|
+
|
|
72
|
+
close = difflib.get_close_matches(text.rstrip("."), list(self.index), n=3, cutoff=0.6)
|
|
73
|
+
hint = f"did you mean: {', '.join(close)}" if close else "bbf_outline lists the nodes of a data model"
|
|
74
|
+
raise LibError(f"no node {text!r} in {self.file or 'this file'}", hint)
|
|
75
|
+
|
|
76
|
+
def _answer(self, key: str) -> tuple[Node, str]:
|
|
77
|
+
same = [n for n in self.nodes if n.id == key]
|
|
78
|
+
warning = f"{len(same)} definitions of {key}; this is the first" if len(same) > 1 else ""
|
|
79
|
+
return self.nodes[self.index[key]], warning
|
|
80
|
+
|
|
81
|
+
def children(self, node: Node) -> tuple[Node, ...]:
|
|
82
|
+
"""The parameters, commands and events directly under an object."""
|
|
83
|
+
if node.kind != "object":
|
|
84
|
+
return ()
|
|
85
|
+
prefix = node.id + "."
|
|
86
|
+
out: list[Node] = []
|
|
87
|
+
for other in self.nodes:
|
|
88
|
+
if other.kind in ("parameter", "command", "event") and other.id.startswith(prefix):
|
|
89
|
+
rest = other.id[len(prefix) :]
|
|
90
|
+
if "." not in rest.replace("().", "()#"):
|
|
91
|
+
out.append(other)
|
|
92
|
+
return tuple(out)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _paragraphs(element: ET.Element | None) -> tuple[str, ...]:
|
|
96
|
+
"""The text of a `<description>`: blank-line-separated paragraphs, whitespace collapsed inside each."""
|
|
97
|
+
if element is None:
|
|
98
|
+
return ()
|
|
99
|
+
text = "".join(element.itertext()).strip("\n")
|
|
100
|
+
return tuple(" ".join(p.split()) for p in _PARAGRAPH_BREAK.split(text) if p.strip())
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _syntax(parameter: ET.Element) -> list[tuple[str, str]]:
|
|
104
|
+
"""The type and restrictions of a parameter as header props."""
|
|
105
|
+
syntax = parameter.find("syntax")
|
|
106
|
+
if syntax is None:
|
|
107
|
+
return []
|
|
108
|
+
props: list[tuple[str, str]] = []
|
|
109
|
+
is_list = syntax.find("list")
|
|
110
|
+
holder = is_list if is_list is not None else syntax # a list holds the element type, and its own bounds
|
|
111
|
+
type_el = next(
|
|
112
|
+
(c for c in holder if c.tag not in ("list", "default", "description", "hidden", "command", "size")), None
|
|
113
|
+
)
|
|
114
|
+
scope = type_el if type_el is not None else syntax
|
|
115
|
+
name = ""
|
|
116
|
+
if type_el is not None:
|
|
117
|
+
name = type_el.get("ref") or type_el.tag
|
|
118
|
+
extras = {
|
|
119
|
+
k: v
|
|
120
|
+
for k, v in type_el.attrib.items()
|
|
121
|
+
if k in ("refType", "targetParent", "targetType", "targetParentScope")
|
|
122
|
+
}
|
|
123
|
+
if extras:
|
|
124
|
+
name += "(" + ",".join(f"{k}={v}" for k, v in sorted(extras.items())) + ")"
|
|
125
|
+
bounds: list[str] = []
|
|
126
|
+
for restriction in [*scope, *(list(is_list.findall("size")) if is_list is not None else [])]:
|
|
127
|
+
if restriction.tag == "size":
|
|
128
|
+
bounds.append(f"size={restriction.get('minLength', '')}..{restriction.get('maxLength', '')}")
|
|
129
|
+
elif restriction.tag == "range":
|
|
130
|
+
bounds.append("range=" + restriction.get("minInclusive", "") + ".." + restriction.get("maxInclusive", ""))
|
|
131
|
+
elif restriction.tag == "pattern" and restriction.get("value"):
|
|
132
|
+
bounds.append(f"pattern={restriction.get('value')}")
|
|
133
|
+
elif restriction.tag == "units":
|
|
134
|
+
bounds.append(f"units={restriction.get('value', '')}")
|
|
135
|
+
if is_list is not None:
|
|
136
|
+
name = f"list of {name}" if name else "list"
|
|
137
|
+
bounds = [f"items={is_list.get('minItems', '')}..{is_list.get('maxItems', '')}", *bounds]
|
|
138
|
+
props.append(("type", name + (f"({', '.join(bounds)})" if bounds else "")))
|
|
139
|
+
values = [e.get("value", "") for e in scope.iter("enumeration")][:MAX_ENUM]
|
|
140
|
+
if values:
|
|
141
|
+
props.append(("enum", "|".join(values)))
|
|
142
|
+
default = syntax.find("default")
|
|
143
|
+
if default is not None and default.get("value") is not None:
|
|
144
|
+
props.append(("default", repr(default.get("value"))))
|
|
145
|
+
return props
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
_COMMON = (
|
|
149
|
+
"access",
|
|
150
|
+
"version",
|
|
151
|
+
"status",
|
|
152
|
+
"mandatory",
|
|
153
|
+
"activeNotify",
|
|
154
|
+
"forcedInform",
|
|
155
|
+
"async",
|
|
156
|
+
"minEntries",
|
|
157
|
+
"maxEntries",
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _props(element: ET.Element, extra: list[tuple[str, str]] | None = None) -> tuple[tuple[str, str], ...]:
|
|
162
|
+
found = [(k, element.get(k, "")) for k in _COMMON if element.get(k) is not None]
|
|
163
|
+
return tuple(found + (extra or []))
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def parse(data: bytes) -> DataModel:
|
|
167
|
+
"""Parse one file. Raises `LibError` if it is not a well-formed BBF data-model document."""
|
|
168
|
+
root = _xml.parse(data)
|
|
169
|
+
if root.tag != "document":
|
|
170
|
+
raise LibError(
|
|
171
|
+
f"the root element is <{root.tag}>, not a data-model document", "this is not a BBF data-model file"
|
|
172
|
+
)
|
|
173
|
+
title_match = _TITLE.search(data[:4096])
|
|
174
|
+
title = title_match.group(1).decode("utf-8", errors="replace").strip() if title_match else ""
|
|
175
|
+
nodes: list[Node] = []
|
|
176
|
+
|
|
177
|
+
def add(node: Node) -> None:
|
|
178
|
+
if len(nodes) >= MAX_NODES:
|
|
179
|
+
raise LibError("the data model has too many nodes", "")
|
|
180
|
+
nodes.append(node)
|
|
181
|
+
|
|
182
|
+
def walk_object(obj: ET.Element, component: str) -> None:
|
|
183
|
+
path = (obj.get("name") or obj.get("ref") or "").strip()
|
|
184
|
+
if not path:
|
|
185
|
+
return
|
|
186
|
+
oid = path.rstrip(".")
|
|
187
|
+
keys = [p.get("ref", "") for k in obj.findall("uniqueKey") for p in k.findall("parameter")]
|
|
188
|
+
extra = [("uniqueKey", ",".join(keys))] if keys else []
|
|
189
|
+
if obj.get("numEntriesParameter"):
|
|
190
|
+
extra.append(("numEntriesParameter", obj.get("numEntriesParameter", "")))
|
|
191
|
+
add(Node("object", oid, _props(obj, extra), _paragraphs(obj.find("description")), component))
|
|
192
|
+
for child in obj:
|
|
193
|
+
if child.tag == "parameter" and child.get("name"):
|
|
194
|
+
pid = f"{oid}.{child.get('name')}"
|
|
195
|
+
add(
|
|
196
|
+
Node(
|
|
197
|
+
"parameter",
|
|
198
|
+
pid,
|
|
199
|
+
_props(child, _syntax(child)),
|
|
200
|
+
_paragraphs(child.find("description")),
|
|
201
|
+
component,
|
|
202
|
+
)
|
|
203
|
+
)
|
|
204
|
+
elif child.tag in ("command", "event") and child.get("name"):
|
|
205
|
+
name = child.get("name", "")
|
|
206
|
+
cid = f"{oid}.{name}" if child.tag == "event" or name.endswith("()") else f"{oid}.{name}()"
|
|
207
|
+
add(Node(child.tag, cid, _props(child), _paragraphs(child.find("description")), component))
|
|
208
|
+
for part in ("input", "output"):
|
|
209
|
+
for arg in child.findall(f"{part}/parameter"):
|
|
210
|
+
if arg.get("name"):
|
|
211
|
+
aid = f"{cid}.{part.capitalize()}.{arg.get('name')}"
|
|
212
|
+
add(
|
|
213
|
+
Node(
|
|
214
|
+
"parameter",
|
|
215
|
+
aid,
|
|
216
|
+
_props(arg, _syntax(arg)),
|
|
217
|
+
_paragraphs(arg.find("description")),
|
|
218
|
+
component,
|
|
219
|
+
)
|
|
220
|
+
)
|
|
221
|
+
if child.tag == "event":
|
|
222
|
+
for arg in child.findall("parameter"):
|
|
223
|
+
if arg.get("name"):
|
|
224
|
+
add(
|
|
225
|
+
Node(
|
|
226
|
+
"parameter",
|
|
227
|
+
f"{cid}.{arg.get('name')}",
|
|
228
|
+
_props(arg, _syntax(arg)),
|
|
229
|
+
_paragraphs(arg.find("description")),
|
|
230
|
+
component,
|
|
231
|
+
)
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
def walk_profile(profile: ET.Element, component: str) -> None:
|
|
235
|
+
name = profile.get("name")
|
|
236
|
+
if not name:
|
|
237
|
+
return
|
|
238
|
+
lines: list[str] = []
|
|
239
|
+
for member in profile:
|
|
240
|
+
if member.tag == "object" and member.get("ref"):
|
|
241
|
+
lines.append(f"object {member.get('ref')} requires {member.get('requirement', '')}")
|
|
242
|
+
lines.extend(
|
|
243
|
+
f"parameter {member.get('ref')}{p.get('ref')} requires {p.get('requirement', '')}"
|
|
244
|
+
for p in member.findall("parameter")
|
|
245
|
+
if p.get("ref")
|
|
246
|
+
)
|
|
247
|
+
extra = [(k, profile.get(k, "")) for k in ("base", "extends") if profile.get(k)]
|
|
248
|
+
add(Node("profile", name, tuple(extra), (*_paragraphs(profile.find("description")), *lines), component))
|
|
249
|
+
|
|
250
|
+
def walk_container(container: ET.Element, component: str) -> None:
|
|
251
|
+
for child in container:
|
|
252
|
+
if child.tag == "object":
|
|
253
|
+
walk_object(child, component)
|
|
254
|
+
elif child.tag == "profile":
|
|
255
|
+
walk_profile(child, component)
|
|
256
|
+
|
|
257
|
+
imports: list[str] = []
|
|
258
|
+
for child in root:
|
|
259
|
+
if child.tag == "import" and child.get("file"):
|
|
260
|
+
imports.append(child.get("file", ""))
|
|
261
|
+
elif child.tag == "dataType" and child.get("name"):
|
|
262
|
+
add(
|
|
263
|
+
Node(
|
|
264
|
+
"datatype",
|
|
265
|
+
child.get("name", ""),
|
|
266
|
+
_props(child, _syntax(child)),
|
|
267
|
+
_paragraphs(child.find("description")),
|
|
268
|
+
)
|
|
269
|
+
)
|
|
270
|
+
elif child.tag == "component":
|
|
271
|
+
walk_container(child, child.get("name", ""))
|
|
272
|
+
elif child.tag == "model" and child.get("name"):
|
|
273
|
+
refs = [c.get("ref", "") for c in child.findall("component")]
|
|
274
|
+
extra = [("components", ",".join(refs))] if refs else []
|
|
275
|
+
extra += [(k, child.get(k, "")) for k in ("base", "isService") if child.get(k)]
|
|
276
|
+
add(Node("model", child.get("name", ""), tuple(extra), _paragraphs(child.find("description"))))
|
|
277
|
+
walk_container(child, "")
|
|
278
|
+
index: dict[str, int] = {}
|
|
279
|
+
for position, node in enumerate(nodes):
|
|
280
|
+
index.setdefault(node.id, position)
|
|
281
|
+
return DataModel(title, root.get("spec", ""), root.get("file", ""), tuple(imports), tuple(nodes), index)
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""Import Broadband Forum data models and Technical Reports into `$PRIMARYSOURCE_HOME/bbf/`.
|
|
2
|
+
|
|
3
|
+
- `import_models`: each XML file is fetched from `raw.githubusercontent.com/BroadbandForum/cwmp-data-models/<commit>/`
|
|
4
|
+
(one request per second), checked against its pinned sha256, parsed once with the hardened parser (a file that
|
|
5
|
+
does not parse is not installed) and written to `models/<id>.xml`.
|
|
6
|
+
- `import_report`: a Technical Report PDF is fetched from broadband-forum.org (or taken from a file the user
|
|
7
|
+
downloaded), checked against its sha256, stored as `reports/<id>.pdf`, and read once into `reports/<id>.json`
|
|
8
|
+
with the private PDF reader. Reports go on a volume: the Forum's terms do not allow redistribution of
|
|
9
|
+
derivatives, and this code only writes to the user's own home.
|
|
10
|
+
|
|
11
|
+
`install.json` is written last, after every file it names. Run as `primarysource bbf import-models [ID ...]` and
|
|
12
|
+
`primarysource bbf import-report <ID> [--file PDF]`.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
import os
|
|
19
|
+
from collections.abc import Sequence
|
|
20
|
+
from dataclasses import dataclass
|
|
21
|
+
from datetime import UTC, datetime
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Any
|
|
24
|
+
|
|
25
|
+
from primarysource.bbf import _xml, datamodel
|
|
26
|
+
from primarysource.bbf.catalogue import DOC_ID, HOSTS, Catalogue, Model
|
|
27
|
+
from primarysource.bbf.catalogue import catalogue as packaged_catalogue
|
|
28
|
+
from primarysource.c2sp._fetch import Fetcher, Transport, sha256, verify
|
|
29
|
+
from primarysource.ecma import _pdf
|
|
30
|
+
from primarysource.errors import LibError
|
|
31
|
+
from primarysource.home import atomic_write
|
|
32
|
+
|
|
33
|
+
INSTALL_RECORD = "install.json"
|
|
34
|
+
MAX_PDF = 64 * 1024 * 1024
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True, slots=True)
|
|
38
|
+
class ImportResult:
|
|
39
|
+
ids: tuple[str, ...]
|
|
40
|
+
requests: int
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def read_record(home: Path) -> dict[str, Any]:
|
|
44
|
+
"""The install record, or an empty one. Always has `models` and `reports` mappings."""
|
|
45
|
+
try:
|
|
46
|
+
loaded = json.loads((home / INSTALL_RECORD).read_text(encoding="utf-8"))
|
|
47
|
+
except (OSError, ValueError):
|
|
48
|
+
loaded = {}
|
|
49
|
+
if not isinstance(loaded, dict):
|
|
50
|
+
loaded = {}
|
|
51
|
+
for key in ("models", "reports"):
|
|
52
|
+
if not isinstance(loaded.get(key), dict):
|
|
53
|
+
loaded[key] = {}
|
|
54
|
+
return loaded
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _write_record(home: Path, record: dict[str, Any]) -> None:
|
|
58
|
+
record["collection"] = "bbf"
|
|
59
|
+
atomic_write(home / INSTALL_RECORD, (json.dumps(record, indent=2) + "\n").encode("utf-8"))
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def import_models(
|
|
63
|
+
home: Path,
|
|
64
|
+
only: Sequence[str] = (),
|
|
65
|
+
*,
|
|
66
|
+
cat: Catalogue | None = None,
|
|
67
|
+
transport: Transport | None = None,
|
|
68
|
+
fetcher: Fetcher | None = None,
|
|
69
|
+
) -> ImportResult:
|
|
70
|
+
"""Fetch, verify, parse and install the catalogued data-model files (all, or those named in `only`)."""
|
|
71
|
+
cat = cat or packaged_catalogue()
|
|
72
|
+
chosen = [cat.model(name) for name in only] if only else list(cat.models)
|
|
73
|
+
fetcher = fetcher or Fetcher(HOSTS, transport)
|
|
74
|
+
record = read_record(home)
|
|
75
|
+
done: list[str] = []
|
|
76
|
+
for model in chosen:
|
|
77
|
+
data = fetcher.get(cat.model_url(model), min(_xml.MAX_BYTES, max(model.size * 2, 4096)))
|
|
78
|
+
verify(data, model.sha256, model.id)
|
|
79
|
+
datamodel.parse(data)
|
|
80
|
+
atomic_write(home / "models" / model.path, data)
|
|
81
|
+
record["models"][model.id] = model.sha256
|
|
82
|
+
done.append(model.id)
|
|
83
|
+
record["commit"] = cat.commit
|
|
84
|
+
record["models_licence"] = cat.model_terms.licence
|
|
85
|
+
record["imported"] = datetime.now(UTC).isoformat(timespec="seconds")
|
|
86
|
+
_write_record(home, record)
|
|
87
|
+
return ImportResult(tuple(done), fetcher.requests)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def import_report(
|
|
91
|
+
home: Path,
|
|
92
|
+
report_id: str,
|
|
93
|
+
*,
|
|
94
|
+
file: Path | None = None,
|
|
95
|
+
cat: Catalogue | None = None,
|
|
96
|
+
transport: Transport | None = None,
|
|
97
|
+
fetcher: Fetcher | None = None,
|
|
98
|
+
) -> ImportResult:
|
|
99
|
+
"""Fetch (or read `file`), verify, store and extract one Technical Report PDF."""
|
|
100
|
+
cat = cat or packaged_catalogue()
|
|
101
|
+
report = cat.report(report_id)
|
|
102
|
+
requests = 0
|
|
103
|
+
if file is not None:
|
|
104
|
+
try:
|
|
105
|
+
data = file.read_bytes()
|
|
106
|
+
except OSError as exc:
|
|
107
|
+
raise LibError(f"cannot read {file}: {exc}", "pass the PDF you downloaded from the Forum") from exc
|
|
108
|
+
if len(data) > MAX_PDF:
|
|
109
|
+
raise LibError(f"{file} is larger than {MAX_PDF} bytes", "")
|
|
110
|
+
else:
|
|
111
|
+
fetcher = fetcher or Fetcher(HOSTS, transport)
|
|
112
|
+
data = fetcher.get(report.source_url, MAX_PDF)
|
|
113
|
+
requests = fetcher.requests
|
|
114
|
+
verify(data, report.sha256, report.designation)
|
|
115
|
+
if not data.startswith(b"%PDF-"):
|
|
116
|
+
raise LibError(f"{report.designation}: not a PDF", "the pinned file is a PDF; nothing was written")
|
|
117
|
+
folder = home / "reports"
|
|
118
|
+
folder.mkdir(parents=True, exist_ok=True)
|
|
119
|
+
part = folder / f"{report.id}.pdf.part"
|
|
120
|
+
part.write_bytes(data)
|
|
121
|
+
try:
|
|
122
|
+
extraction = _pdf.extract(data, part)
|
|
123
|
+
except BaseException:
|
|
124
|
+
part.unlink(missing_ok=True)
|
|
125
|
+
raise
|
|
126
|
+
os.replace(part, folder / f"{report.id}.pdf")
|
|
127
|
+
extraction.update(
|
|
128
|
+
{"title": report.title, "designation": report.designation, "edition": report.issue, "sha256": report.sha256}
|
|
129
|
+
)
|
|
130
|
+
atomic_write(folder / f"{report.id}.json", (json.dumps(extraction, ensure_ascii=False) + "\n").encode("utf-8"))
|
|
131
|
+
record = read_record(home)
|
|
132
|
+
record["reports"][report.id] = {
|
|
133
|
+
"sha256": report.sha256,
|
|
134
|
+
"extractor": extraction["extractor"],
|
|
135
|
+
"imported": datetime.now(UTC).isoformat(timespec="seconds"),
|
|
136
|
+
}
|
|
137
|
+
_write_record(home, record)
|
|
138
|
+
return ImportResult((report.id,), requests)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def pin_models(raw_base: str, commit: str, names: Sequence[str], fetcher: Fetcher) -> list[Model]:
|
|
142
|
+
"""Maintainer step: fetch `names` (file stems) at `commit` and return catalogue entries with size and sha256.
|
|
143
|
+
The result is what goes in `catalogue.json`; it is a review step, never run by the importer."""
|
|
144
|
+
pinned: list[Model] = []
|
|
145
|
+
for name in names:
|
|
146
|
+
if not DOC_ID.fullmatch(name):
|
|
147
|
+
raise LibError(f"not a data-model file stem: {name!r}", "")
|
|
148
|
+
data = fetcher.get(f"{raw_base}{commit}/{name}.xml", _xml.MAX_BYTES)
|
|
149
|
+
parsed = datamodel.parse(data)
|
|
150
|
+
pinned.append(Model(name, f"{name}.xml", parsed.title or name, len(data), sha256(data)))
|
|
151
|
+
return pinned
|