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,117 @@
|
|
|
1
|
+
"""The drafts the portfolio tracks: why each is tracked, the revision last read, and the pins. Metadata only.
|
|
2
|
+
|
|
3
|
+
`tracked.json` is package data. For each draft it holds what the importer was last run against: the latest
|
|
4
|
+
revision read, the datatracker state then, what replaced it, and the sha256 of the files fetched for each
|
|
5
|
+
revision read. Those digests are the pins: an import of a revision that has one refuses a different file. A
|
|
6
|
+
revision with no pin is trusted on first use and its digest recorded in the library's `meta.json`; add it here
|
|
7
|
+
after checking it (`primarysource draft import --print-pin`).
|
|
8
|
+
|
|
9
|
+
The snapshot says "as of `checked`". It is the catalogue's memory, not the datatracker's current word: `info`
|
|
10
|
+
prefers the library's own `status.json` and falls back to this when the draft was never fetched.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from importlib import resources
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
from primarysource.draft import names
|
|
21
|
+
from primarysource.errors import LibError
|
|
22
|
+
|
|
23
|
+
PLACEMENTS = ("layer", "volume", "none")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True, slots=True)
|
|
27
|
+
class Terms:
|
|
28
|
+
"""What the IETF Trust's terms allow, and so where drafts may be placed (docs/draft.md)."""
|
|
29
|
+
|
|
30
|
+
licence: str
|
|
31
|
+
placement: str
|
|
32
|
+
terms_url: str
|
|
33
|
+
terms_read: str
|
|
34
|
+
summary: str
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True, slots=True)
|
|
38
|
+
class Pin:
|
|
39
|
+
"""sha256 of the files fetched for one revision; None for a file that was not fetched (no XML)."""
|
|
40
|
+
|
|
41
|
+
txt: str | None
|
|
42
|
+
xml: str | None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass(frozen=True, slots=True)
|
|
46
|
+
class Tracked:
|
|
47
|
+
name: str
|
|
48
|
+
why: str
|
|
49
|
+
consumers: tuple[str, ...]
|
|
50
|
+
rev_read: str
|
|
51
|
+
state: str
|
|
52
|
+
replaced_by: tuple[str, ...]
|
|
53
|
+
became_rfc: str | None
|
|
54
|
+
expires: str
|
|
55
|
+
checked: str
|
|
56
|
+
pins: dict[str, Pin]
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _digest(value: Any, where: str) -> str | None:
|
|
60
|
+
if value is None:
|
|
61
|
+
return None
|
|
62
|
+
if not isinstance(value, str) or len(value) != 64 or value.strip("0123456789abcdef"):
|
|
63
|
+
raise ValueError(f"{where}: a sha256 must be 64 lowercase hex digits or null")
|
|
64
|
+
return value
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def parse(raw: str) -> tuple[Terms, dict[str, Tracked]]:
|
|
68
|
+
"""Validate the catalogue text. A malformed catalogue is a packaging bug, so it raises ValueError."""
|
|
69
|
+
data = json.loads(raw)
|
|
70
|
+
if data.get("schema") != 1:
|
|
71
|
+
raise ValueError("catalogue schema must be 1")
|
|
72
|
+
t = data["terms"]
|
|
73
|
+
if t["placement"] not in PLACEMENTS:
|
|
74
|
+
raise ValueError(f"placement {t['placement']!r} is not one of {PLACEMENTS}")
|
|
75
|
+
terms = Terms(t["licence"], t["placement"], t["terms_url"], t["terms_read"], t["summary"])
|
|
76
|
+
drafts: dict[str, Tracked] = {}
|
|
77
|
+
for entry in data["drafts"]:
|
|
78
|
+
name = names.parse_name(entry["name"])
|
|
79
|
+
if name in drafts:
|
|
80
|
+
raise ValueError(f"duplicate draft {name}")
|
|
81
|
+
pins: dict[str, Pin] = {}
|
|
82
|
+
for rev, files in entry["pins"].items():
|
|
83
|
+
names.valid_revision(rev)
|
|
84
|
+
pins[rev] = Pin(_digest(files.get("txt"), f"{name}-{rev}"), _digest(files.get("xml"), f"{name}-{rev}"))
|
|
85
|
+
if entry["rev_read"]:
|
|
86
|
+
names.valid_revision(entry["rev_read"])
|
|
87
|
+
drafts[name] = Tracked(
|
|
88
|
+
name,
|
|
89
|
+
entry["why"],
|
|
90
|
+
tuple(entry["consumers"]),
|
|
91
|
+
entry["rev_read"],
|
|
92
|
+
entry["state"],
|
|
93
|
+
tuple(entry["replaced_by"]),
|
|
94
|
+
entry["became_rfc"],
|
|
95
|
+
entry["expires"],
|
|
96
|
+
entry["checked"],
|
|
97
|
+
pins,
|
|
98
|
+
)
|
|
99
|
+
return terms, drafts
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
TERMS, TRACKED = parse(resources.files("primarysource.draft").joinpath("tracked.json").read_text(encoding="utf-8"))
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def pin_for(name: str, rev: str) -> Pin | None:
|
|
106
|
+
"""The pinned digests for one revision of a tracked draft, or None."""
|
|
107
|
+
tracked = TRACKED.get(name)
|
|
108
|
+
return tracked.pins.get(rev) if tracked else None
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def tracked(name: str) -> Tracked:
|
|
112
|
+
"""The catalogue entry, or a LibError that lists what is tracked."""
|
|
113
|
+
found = TRACKED.get(names.parse_name(name))
|
|
114
|
+
if found is None:
|
|
115
|
+
known = ", ".join(sorted(TRACKED)) or "none"
|
|
116
|
+
raise LibError(f"{name} is not in the tracked catalogue (tracked: {known})", "")
|
|
117
|
+
return found
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"""`primarysource draft ...`: the collection's own commands.
|
|
2
|
+
|
|
3
|
+
primarysource draft import <name>[-rev] ... fetch drafts from the IETF (one request a second); --tracked for all
|
|
4
|
+
primarysource draft refresh [<name> ...] fetch only the datatracker's current status; --tracked for all
|
|
5
|
+
primarysource draft list tracked and stored drafts, with state and replacement
|
|
6
|
+
primarysource draft info <name> standing, revisions, expiry, replaced-by, licence legend
|
|
7
|
+
primarysource draft outline <name>[-rev] a stored draft's sections
|
|
8
|
+
primarysource draft read <ref> [--max-tokens N]
|
|
9
|
+
primarysource draft search <words> [--name N] [--limit N]
|
|
10
|
+
primarysource draft cite <ref>
|
|
11
|
+
|
|
12
|
+
`import` and `refresh` are the only commands that use the network. They refuse when `PRIMARYSOURCE_READONLY` is
|
|
13
|
+
set. The others read the library and are what the MCP tools call.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import argparse
|
|
19
|
+
import json
|
|
20
|
+
import sys
|
|
21
|
+
from collections.abc import Callable, Sequence
|
|
22
|
+
|
|
23
|
+
from primarysource.draft import catalogue, importer, names, views
|
|
24
|
+
from primarysource.draft.net import Fetcher
|
|
25
|
+
from primarysource.draft.store import Store
|
|
26
|
+
from primarysource.errors import LibError
|
|
27
|
+
|
|
28
|
+
fetcher_factory: Callable[[], Fetcher] = Fetcher
|
|
29
|
+
"""How the importer gets its fetcher; tests replace it with one over a fake transport."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _parser() -> argparse.ArgumentParser:
|
|
33
|
+
parser = argparse.ArgumentParser(prog="primarysource draft", description="IETF Internet-Drafts")
|
|
34
|
+
commands = parser.add_subparsers(dest="command", required=True)
|
|
35
|
+
imp = commands.add_parser("import", help="fetch drafts from the IETF into the library")
|
|
36
|
+
imp.add_argument("drafts", nargs="*", help="draft-<words>[-NN]; without -NN the latest revision")
|
|
37
|
+
imp.add_argument("--tracked", action="store_true", help="every draft in the tracked catalogue (latest revision)")
|
|
38
|
+
imp.add_argument("--no-xml", action="store_true", help="do not fetch the author's .xml")
|
|
39
|
+
imp.add_argument("--sha256", help="expected sha256 of the .txt (one draft with an explicit revision only)")
|
|
40
|
+
imp.add_argument("--print-pin", action="store_true", help="print each revision's digests as tracked.json pins")
|
|
41
|
+
ref = commands.add_parser("refresh", help="fetch the datatracker's current status of drafts")
|
|
42
|
+
ref.add_argument("drafts", nargs="*")
|
|
43
|
+
ref.add_argument("--tracked", action="store_true", help="every draft in the tracked catalogue")
|
|
44
|
+
commands.add_parser("list", help="tracked and stored drafts")
|
|
45
|
+
commands.add_parser("info", help="standing of a draft").add_argument("name")
|
|
46
|
+
commands.add_parser("outline", help="a stored draft's sections").add_argument("name")
|
|
47
|
+
read = commands.add_parser("read", help="read by ref")
|
|
48
|
+
read.add_argument("ref")
|
|
49
|
+
read.add_argument("--max-tokens", type=int, default=2000)
|
|
50
|
+
search = commands.add_parser("search", help="search the stored drafts")
|
|
51
|
+
search.add_argument("query")
|
|
52
|
+
search.add_argument("--name")
|
|
53
|
+
search.add_argument("--limit", type=int, default=10)
|
|
54
|
+
commands.add_parser("cite", help="the citation for a ref").add_argument("ref")
|
|
55
|
+
return parser
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _emit(text: str) -> int:
|
|
59
|
+
out = sys.stdout
|
|
60
|
+
if hasattr(out, "reconfigure"):
|
|
61
|
+
out.reconfigure(encoding="utf-8") # refs carry §, and a Windows console pipe is not UTF-8 by default
|
|
62
|
+
out.write(text if text.endswith("\n") else text + "\n")
|
|
63
|
+
return 0
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _targets(args: argparse.Namespace) -> list[tuple[str, str | None]]:
|
|
67
|
+
found = [names.split_doc(d) for d in args.drafts]
|
|
68
|
+
if args.tracked:
|
|
69
|
+
found += [(n, None) for n in catalogue.TRACKED if n not in {f[0] for f in found}]
|
|
70
|
+
if not found:
|
|
71
|
+
raise LibError(
|
|
72
|
+
"name a draft, or pass --tracked", "e.g. primarysource draft import draft-ietf-sidrops-8210bis"
|
|
73
|
+
)
|
|
74
|
+
return found
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _import(args: argparse.Namespace, store: Store) -> int:
|
|
78
|
+
targets = _targets(args)
|
|
79
|
+
if args.sha256 and (len(targets) != 1 or targets[0][1] is None):
|
|
80
|
+
raise LibError(
|
|
81
|
+
"--sha256 needs exactly one draft with an explicit revision", "e.g. draft-x-y-03 --sha256 <hex>"
|
|
82
|
+
)
|
|
83
|
+
fetcher = fetcher_factory()
|
|
84
|
+
failed = 0
|
|
85
|
+
pins: dict[str, dict[str, dict[str, str]]] = {}
|
|
86
|
+
for name, rev in targets:
|
|
87
|
+
try:
|
|
88
|
+
report = importer.import_draft(
|
|
89
|
+
name, rev, fetcher=fetcher, store=store, want_xml=not args.no_xml, expected_sha256=args.sha256
|
|
90
|
+
)
|
|
91
|
+
except LibError as exc:
|
|
92
|
+
failed += 1
|
|
93
|
+
print(f"{name}: {exc.message}" + (f" ({exc.hint})" if exc.hint else ""), file=sys.stderr)
|
|
94
|
+
continue
|
|
95
|
+
verb = "imported" if report.new else "already stored"
|
|
96
|
+
pin = "pinned" if report.pinned else "not pinned (trusted on first use)"
|
|
97
|
+
print(
|
|
98
|
+
f"{report.name}-{report.rev}: {verb}, text {report.txt_bytes:,} bytes sha256 {report.txt_sha256}, "
|
|
99
|
+
f"xml {report.xml_state}, state {report.status.state}, {pin}, {report.requests} requests -> {report.path}"
|
|
100
|
+
)
|
|
101
|
+
if report.xml_sha256 or report.txt_sha256:
|
|
102
|
+
pins.setdefault(report.name, {})[report.rev] = {
|
|
103
|
+
"txt": report.txt_sha256,
|
|
104
|
+
**({"xml": report.xml_sha256} if report.xml_sha256 else {}),
|
|
105
|
+
}
|
|
106
|
+
if args.print_pin and pins:
|
|
107
|
+
print(json.dumps(pins, indent=1))
|
|
108
|
+
return 1 if failed else 0
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _refresh(args: argparse.Namespace, store: Store) -> int:
|
|
112
|
+
fetcher = fetcher_factory()
|
|
113
|
+
failed = 0
|
|
114
|
+
for name, _rev in _targets(args):
|
|
115
|
+
try:
|
|
116
|
+
status = importer.refresh(name, fetcher=fetcher, store=store)
|
|
117
|
+
except LibError as exc:
|
|
118
|
+
failed += 1
|
|
119
|
+
print(f"{name}: {exc.message}" + (f" ({exc.hint})" if exc.hint else ""), file=sys.stderr)
|
|
120
|
+
continue
|
|
121
|
+
extra = (f", replaced by {', '.join(status.replaced_by)}" if status.replaced_by else "") + (
|
|
122
|
+
f", became {status.became_rfc}" if status.became_rfc else ""
|
|
123
|
+
)
|
|
124
|
+
print(f"{name}: latest -{status.rev}, state {status.state}, expires {status.expires[:10] or '?'}{extra}")
|
|
125
|
+
return 1 if failed else 0
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def main(argv: Sequence[str]) -> int:
|
|
129
|
+
args = _parser().parse_args(list(argv))
|
|
130
|
+
store = Store()
|
|
131
|
+
try:
|
|
132
|
+
if args.command == "import":
|
|
133
|
+
return _import(args, store)
|
|
134
|
+
if args.command == "refresh":
|
|
135
|
+
return _refresh(args, store)
|
|
136
|
+
if args.command == "list":
|
|
137
|
+
return _emit(views.list_tracked(store))
|
|
138
|
+
if args.command == "info":
|
|
139
|
+
return _emit(views.info(store, args.name))
|
|
140
|
+
if args.command == "outline":
|
|
141
|
+
return _emit(views.outline(store, args.name))
|
|
142
|
+
if args.command == "read":
|
|
143
|
+
return _emit(views.read(store, args.ref, max_tokens=args.max_tokens))
|
|
144
|
+
if args.command == "search":
|
|
145
|
+
return _emit(views.search(store, args.query, limit=args.limit, name=args.name))
|
|
146
|
+
return _emit(views.cite(store, args.ref))
|
|
147
|
+
except LibError as exc:
|
|
148
|
+
print(exc.as_text(), file=sys.stderr)
|
|
149
|
+
return 1
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""IETF Internet-Drafts as a primarysource collection. Importing this needs nothing beyond the core."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from primarysource.draft.names import GRAMMAR
|
|
6
|
+
from primarysource.model import Unit
|
|
7
|
+
from primarysource.registry import Collection
|
|
8
|
+
|
|
9
|
+
COLLECTION = Collection(
|
|
10
|
+
name="draft",
|
|
11
|
+
title="IETF Internet-Drafts (from the datatracker and the IETF archive)",
|
|
12
|
+
prefix="draft_",
|
|
13
|
+
unit=Unit.PARAGRAPH,
|
|
14
|
+
grammar=GRAMMAR,
|
|
15
|
+
server="primarysource.draft.server:server",
|
|
16
|
+
cli="primarysource.draft.cli:main",
|
|
17
|
+
)
|
|
18
|
+
"""Refs: `draft-ietf-sidrops-8210bis-17§5.1¶2`. The `-17` is the revision; without it a ref resolves to the latest
|
|
19
|
+
stored revision and the result says so. `§` names a section as the draft prints its number (`5.1`, `A.2` for
|
|
20
|
+
Appendix A.2) or, for an unnumbered section, a title slug (`abstract`, `security-considerations`). `¶n` counts the
|
|
21
|
+
paragraph blocks of that section (paragraphs, lists, figures, tables), as the rfc collection does."""
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""Draft metadata from the IETF datatracker: three small JSON documents per draft, validated before use.
|
|
2
|
+
|
|
3
|
+
- `/doc/<name>/doc.json`, the datatracker's simplified document API (no key, no parameters): title, state,
|
|
4
|
+
latest revision, expiry, authors and the whole revision history with posting dates;
|
|
5
|
+
- `/api/v1/doc/relateddocument/` filtered by relationship, twice: which draft *replaces* this one, and which RFC
|
|
6
|
+
this draft *became* (the `replaces` and `became_rfc` relationship names).
|
|
7
|
+
|
|
8
|
+
Everything the server sends is data, never trusted: every field is type-checked and length-capped, a name
|
|
9
|
+
extracted from a resource URI must match the draft or RFC name grammar, and a response for a different draft
|
|
10
|
+
than the one asked about is refused.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import re
|
|
17
|
+
from dataclasses import replace
|
|
18
|
+
from datetime import UTC, datetime
|
|
19
|
+
from typing import Any
|
|
20
|
+
from urllib.parse import quote
|
|
21
|
+
|
|
22
|
+
from primarysource.draft import names
|
|
23
|
+
from primarysource.draft.net import ARCHIVE, DATATRACKER, Fetcher, Response
|
|
24
|
+
from primarysource.draft.status import Status
|
|
25
|
+
from primarysource.errors import LibError
|
|
26
|
+
|
|
27
|
+
_DRAFT_URI = re.compile(r"/api/v1/doc/document/(draft-[a-z0-9]+(?:-[a-z0-9]+)*)/")
|
|
28
|
+
_RFC_URI = re.compile(r"/api/v1/doc/document/rfc([0-9]{1,5})/")
|
|
29
|
+
_MAX_TEXT = 500
|
|
30
|
+
_MAX_REVISIONS = 100
|
|
31
|
+
_MAX_AUTHORS = 50
|
|
32
|
+
_MAX_LINKS = 20
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def doc_url(name: str) -> str:
|
|
36
|
+
return f"https://{DATATRACKER}/doc/{quote(names.parse_name(name), safe='')}/doc.json"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def replaced_by_url(name: str) -> str:
|
|
40
|
+
return (
|
|
41
|
+
f"https://{DATATRACKER}/api/v1/doc/relateddocument/?target__name={quote(names.parse_name(name), safe='')}"
|
|
42
|
+
"&relationship__slug=replaces&format=json"
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def became_rfc_url(name: str) -> str:
|
|
47
|
+
return (
|
|
48
|
+
f"https://{DATATRACKER}/api/v1/doc/relateddocument/?source__name={quote(names.parse_name(name), safe='')}"
|
|
49
|
+
"&relationship__slug=became_rfc&format=json"
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def archive_url(name: str, rev: str, suffix: str) -> str:
|
|
54
|
+
"""The IETF archive's copy of one revision: `suffix` is `txt` or `xml`."""
|
|
55
|
+
if suffix not in ("txt", "xml"):
|
|
56
|
+
raise ValueError(suffix)
|
|
57
|
+
return f"https://{ARCHIVE}/archive/id/{names.with_revision(name, rev)}.{suffix}"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def html_url(name: str, rev: str) -> str:
|
|
61
|
+
"""The datatracker's rendering of one revision, which the cite string points at."""
|
|
62
|
+
return f"https://{DATATRACKER}/doc/html/{names.with_revision(name, rev)}"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _json(reply: Response, what: str) -> dict[str, Any]:
|
|
66
|
+
try:
|
|
67
|
+
data = json.loads(reply.body.decode("utf-8"))
|
|
68
|
+
except (UnicodeDecodeError, ValueError) as exc:
|
|
69
|
+
raise LibError(f"the datatracker's answer for {what} is not JSON", "try again later") from exc
|
|
70
|
+
if not isinstance(data, dict):
|
|
71
|
+
raise LibError(f"the datatracker's answer for {what} is not a JSON object", "")
|
|
72
|
+
return data
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _text(data: dict[str, Any], key: str, required: bool = False) -> str:
|
|
76
|
+
value = data.get(key)
|
|
77
|
+
if value is None and not required:
|
|
78
|
+
return ""
|
|
79
|
+
if not isinstance(value, str):
|
|
80
|
+
raise LibError(f"the datatracker's {key!r} is not text", "")
|
|
81
|
+
return " ".join(value.split())[:_MAX_TEXT]
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def parse_doc(reply: Response, name: str, checked: datetime) -> Status:
|
|
85
|
+
"""`doc.json` -> a `Status` without the relations (`replaced_by`, `became_rfc`), which come separately."""
|
|
86
|
+
data = _json(reply, name)
|
|
87
|
+
if data.get("name") != name:
|
|
88
|
+
raise LibError(f"asked the datatracker for {name} and it answered for {str(data.get('name'))[:60]!r}", "")
|
|
89
|
+
rev = _text(data, "rev", required=True)
|
|
90
|
+
names.valid_revision(rev)
|
|
91
|
+
history: list[tuple[str, str]] = []
|
|
92
|
+
raw_history = data.get("rev_history")
|
|
93
|
+
if not isinstance(raw_history, list):
|
|
94
|
+
raise LibError("the datatracker's revision history is not a list", "")
|
|
95
|
+
for item in raw_history[:_MAX_REVISIONS]:
|
|
96
|
+
if isinstance(item, dict) and isinstance(item.get("rev"), str) and re.fullmatch(r"[0-9]{2}", item["rev"]):
|
|
97
|
+
history.append((item["rev"], _text(item, "published")))
|
|
98
|
+
history.sort()
|
|
99
|
+
authors: list[str] = []
|
|
100
|
+
for person in data.get("authors") or []:
|
|
101
|
+
if isinstance(person, dict) and isinstance(person.get("name"), str) and person["name"].strip():
|
|
102
|
+
authors.append(" ".join(person["name"].split())[:100])
|
|
103
|
+
group = data.get("group")
|
|
104
|
+
pages = data.get("pages")
|
|
105
|
+
return Status(
|
|
106
|
+
name=name,
|
|
107
|
+
title=_text(data, "title", required=True),
|
|
108
|
+
state=_text(data, "state", required=True),
|
|
109
|
+
rev=rev,
|
|
110
|
+
expires=_text(data, "expires"),
|
|
111
|
+
replaced_by=(),
|
|
112
|
+
became_rfc=None,
|
|
113
|
+
revisions=tuple(history),
|
|
114
|
+
authors=tuple(authors[:_MAX_AUTHORS]),
|
|
115
|
+
group=_text(group, "acronym") if isinstance(group, dict) else "",
|
|
116
|
+
stream=_text(data, "stream"),
|
|
117
|
+
pages=pages if isinstance(pages, int) and not isinstance(pages, bool) else None,
|
|
118
|
+
checked=checked.replace(microsecond=0).isoformat(),
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _related(reply: Response, field: str, pattern: re.Pattern[str]) -> list[str]:
|
|
123
|
+
data = _json(reply, "a relationship query")
|
|
124
|
+
objects = data.get("objects")
|
|
125
|
+
if not isinstance(objects, list):
|
|
126
|
+
raise LibError("the datatracker's relationship answer has no object list", "")
|
|
127
|
+
found: list[str] = []
|
|
128
|
+
for item in objects[:_MAX_LINKS]:
|
|
129
|
+
match = (
|
|
130
|
+
pattern.fullmatch(item.get(field, ""))
|
|
131
|
+
if isinstance(item, dict) and isinstance(item.get(field), str)
|
|
132
|
+
else None
|
|
133
|
+
)
|
|
134
|
+
if match:
|
|
135
|
+
found.append(match.group(1))
|
|
136
|
+
return found
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def fetch_status(fetcher: Fetcher, name: str, now: datetime | None = None) -> Status:
|
|
140
|
+
"""Three requests: the document, who replaces it, and whether it became an RFC. A 404 on the document means
|
|
141
|
+
the datatracker has never heard of the name."""
|
|
142
|
+
name = names.parse_name(name)
|
|
143
|
+
checked = now or datetime.now(UTC)
|
|
144
|
+
reply = fetcher.get(doc_url(name))
|
|
145
|
+
if reply.status == 404:
|
|
146
|
+
raise LibError(
|
|
147
|
+
f"the datatracker has no draft named {name}", "check the spelling, or search datatracker.ietf.org"
|
|
148
|
+
)
|
|
149
|
+
if reply.status != 200:
|
|
150
|
+
raise LibError(f"the datatracker answered {reply.status} for {name}", "try again later")
|
|
151
|
+
base = parse_doc(reply, name, checked)
|
|
152
|
+
replaced = fetcher.get(replaced_by_url(name))
|
|
153
|
+
became = fetcher.get(became_rfc_url(name))
|
|
154
|
+
if replaced.status != 200 or became.status != 200:
|
|
155
|
+
raise LibError(f"the datatracker's relationship queries for {name} failed", "try again later")
|
|
156
|
+
by = tuple(sorted(_related(replaced, "source", _DRAFT_URI)))
|
|
157
|
+
rfcs = _related(became, "target", _RFC_URI)
|
|
158
|
+
return replace(base, replaced_by=by, became_rfc=f"RFC{int(rfcs[0])}" if rfcs else None)
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""Import named drafts from the IETF into the library: `primarysource draft import <name>[-rev]`.
|
|
2
|
+
|
|
3
|
+
For one draft the importer makes at most five requests, one second apart (net.Fetcher), in this order:
|
|
4
|
+
|
|
5
|
+
1. `doc.json`, the replaced-by query and the became-RFC query (datatracker.fetch_status): the draft's state,
|
|
6
|
+
latest revision, expiry and revision history. The snapshot is written first, so a draft found to be dead is
|
|
7
|
+
recorded as dead even if the text fetch then fails;
|
|
8
|
+
2. the revision's `.txt` from the IETF archive: required, because it is what is segmented and cited;
|
|
9
|
+
3. the revision's `.xml`, the author's source: optional (many drafts and every pre-xml2rfc draft have none), and
|
|
10
|
+
skipped with `want_xml=False`.
|
|
11
|
+
|
|
12
|
+
Each file is verified before anything is written. A digest pinned in the catalogue (or given as
|
|
13
|
+
`expected_sha256`) must match; a file with no pin is trusted on first use and its digest recorded in the
|
|
14
|
+
revision's `meta.json`. A text that is not text (NUL bytes, absurd lines, an HTML error page behind a 200) is
|
|
15
|
+
refused. A revision already stored is not fetched again: the archive's revisions do not change.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import hashlib
|
|
21
|
+
from dataclasses import dataclass
|
|
22
|
+
from datetime import UTC, datetime
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
|
|
25
|
+
from primarysource.draft import catalogue, datatracker, licence, names, segment, xmlmeta
|
|
26
|
+
from primarysource.draft.net import Fetcher, Response
|
|
27
|
+
from primarysource.draft.status import Status
|
|
28
|
+
from primarysource.draft.store import RevMeta, Store, readonly
|
|
29
|
+
from primarysource.errors import LibError
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True, slots=True)
|
|
33
|
+
class Report:
|
|
34
|
+
"""What one import did. `new` is False when the revision was already stored (only the status was refreshed)."""
|
|
35
|
+
|
|
36
|
+
name: str
|
|
37
|
+
rev: str
|
|
38
|
+
path: Path
|
|
39
|
+
new: bool
|
|
40
|
+
txt_bytes: int
|
|
41
|
+
txt_sha256: str
|
|
42
|
+
xml_state: str
|
|
43
|
+
xml_sha256: str
|
|
44
|
+
pinned: bool
|
|
45
|
+
status: Status
|
|
46
|
+
requests: int
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def sha256(data: bytes) -> str:
|
|
50
|
+
return hashlib.sha256(data).hexdigest()
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _body(reply: Response, url: str, kind: str) -> bytes | None:
|
|
54
|
+
"""The body of a 200 for the right kind of file. None for a 404, and for an optional file (`xml`) that came
|
|
55
|
+
back as something else; a LibError for a `txt` that did, because the archive answers a missing file with an
|
|
56
|
+
HTML page and that is not the draft."""
|
|
57
|
+
if reply.status == 404:
|
|
58
|
+
return None
|
|
59
|
+
if reply.status != 200:
|
|
60
|
+
raise LibError(f"the IETF archive answered {reply.status} for {url}", "try again later")
|
|
61
|
+
kind_ok = "text/plain" if kind == "txt" else "xml"
|
|
62
|
+
if kind_ok not in reply.content_type.lower() or "html" in reply.content_type.lower() or not reply.body.strip():
|
|
63
|
+
if kind == "xml":
|
|
64
|
+
return None
|
|
65
|
+
raise LibError(
|
|
66
|
+
f"{url} came back as {reply.content_type or 'no content type'!r} or empty, not a text draft", ""
|
|
67
|
+
)
|
|
68
|
+
return reply.body
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _check(got: str, pinned: str | None, expected: str | None, what: str) -> bool:
|
|
72
|
+
"""Compare a digest with the explicit one (it wins) or the catalogue's; True if something pinned it."""
|
|
73
|
+
want = expected or pinned
|
|
74
|
+
if want is not None and got != want:
|
|
75
|
+
raise LibError(
|
|
76
|
+
f"{what} has sha256 {got}, not the {want} pinned for it: the archive's copy changed or the download "
|
|
77
|
+
"is damaged, and nothing was stored",
|
|
78
|
+
"try again; if it persists, the pin in tracked.json or --sha256 is what to re-check",
|
|
79
|
+
)
|
|
80
|
+
return want is not None
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def refresh(name: str, *, fetcher: Fetcher, store: Store, now: datetime | None = None) -> Status:
|
|
84
|
+
"""Fetch and store the datatracker's current word on `name` (three requests). Needs no stored revision."""
|
|
85
|
+
if readonly():
|
|
86
|
+
raise LibError("the library is read-only (PRIMARYSOURCE_READONLY): nothing is fetched or written", "")
|
|
87
|
+
status = datatracker.fetch_status(fetcher, names.parse_name(name), now)
|
|
88
|
+
store.write_status(status)
|
|
89
|
+
return status
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def import_draft(
|
|
93
|
+
name: str,
|
|
94
|
+
rev: str | None = None,
|
|
95
|
+
*,
|
|
96
|
+
fetcher: Fetcher,
|
|
97
|
+
store: Store,
|
|
98
|
+
want_xml: bool = True,
|
|
99
|
+
expected_sha256: str | None = None,
|
|
100
|
+
now: datetime | None = None,
|
|
101
|
+
) -> Report:
|
|
102
|
+
"""Import one revision of `name`: the latest the datatracker knows when `rev` is None."""
|
|
103
|
+
name = names.parse_name(name)
|
|
104
|
+
if rev is not None:
|
|
105
|
+
names.valid_revision(rev)
|
|
106
|
+
started = fetcher.requests
|
|
107
|
+
status = refresh(name, fetcher=fetcher, store=store, now=now)
|
|
108
|
+
known = [r for r, _when in status.revisions]
|
|
109
|
+
rev = rev or status.rev
|
|
110
|
+
if known and rev not in known:
|
|
111
|
+
raise LibError(f"{name} has no revision {rev} (the datatracker lists: {', '.join(known)})", "")
|
|
112
|
+
if store.has(name, rev):
|
|
113
|
+
meta = store.meta(name, rev)
|
|
114
|
+
return Report(
|
|
115
|
+
name, rev, store.path(name, rev), False, meta.txt_bytes, meta.txt_sha256, meta.xml_state, meta.xml_sha256,
|
|
116
|
+
catalogue.pin_for(name, rev) is not None, status, fetcher.requests - started,
|
|
117
|
+
) # fmt: skip
|
|
118
|
+
pin = catalogue.pin_for(name, rev)
|
|
119
|
+
txt_url = datatracker.archive_url(name, rev, "txt")
|
|
120
|
+
txt = _body(fetcher.get(txt_url), txt_url, "txt")
|
|
121
|
+
if txt is None:
|
|
122
|
+
raise LibError(
|
|
123
|
+
f"the IETF archive has no text for {name}-{rev}",
|
|
124
|
+
"a draft submitted as XML only is not supported yet (docs/draft.md)",
|
|
125
|
+
)
|
|
126
|
+
pinned = _check(sha256(txt), pin.txt if pin else None, expected_sha256, f"{name}-{rev}.txt")
|
|
127
|
+
text, encoding = segment.decode(txt)
|
|
128
|
+
found = segment.segment(text)
|
|
129
|
+
xml: bytes | None = None
|
|
130
|
+
info = xmlmeta.XmlInfo("skipped")
|
|
131
|
+
if want_xml:
|
|
132
|
+
xml_url = datatracker.archive_url(name, rev, "xml")
|
|
133
|
+
xml = _body(fetcher.get(xml_url), xml_url, "xml")
|
|
134
|
+
if xml is None:
|
|
135
|
+
info = xmlmeta.XmlInfo("absent")
|
|
136
|
+
else:
|
|
137
|
+
_check(sha256(xml), pin.xml if pin else None, None, f"{name}-{rev}.xml")
|
|
138
|
+
info = xmlmeta.inspect(xml)
|
|
139
|
+
if info.state == xmlmeta.REFUSED:
|
|
140
|
+
xml = None # hostile or unusual source: recorded as refused, not stored
|
|
141
|
+
legend = licence.detect(text, info.ipr)
|
|
142
|
+
meta = RevMeta(
|
|
143
|
+
name=name,
|
|
144
|
+
rev=rev,
|
|
145
|
+
title=status.title,
|
|
146
|
+
published=status.published(rev),
|
|
147
|
+
authors=status.authors,
|
|
148
|
+
txt_sha256=sha256(txt),
|
|
149
|
+
txt_bytes=len(txt),
|
|
150
|
+
encoding=encoding,
|
|
151
|
+
xml_state=info.state,
|
|
152
|
+
xml_sha256=sha256(xml) if xml is not None else "",
|
|
153
|
+
xml_bytes=len(xml) if xml is not None else 0,
|
|
154
|
+
restriction=legend.restriction,
|
|
155
|
+
copyright_year=legend.year,
|
|
156
|
+
ipr=legend.ipr,
|
|
157
|
+
imported=(now or datetime.now(UTC)).replace(microsecond=0).isoformat(),
|
|
158
|
+
)
|
|
159
|
+
path = store.install(meta, txt, xml, found)
|
|
160
|
+
return Report(
|
|
161
|
+
name, rev, path, True, len(txt), meta.txt_sha256, info.state, meta.xml_sha256, pinned, status,
|
|
162
|
+
fetcher.requests - started,
|
|
163
|
+
) # fmt: skip
|