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
primarysource/itu/cli.py
ADDED
|
@@ -0,0 +1,445 @@
|
|
|
1
|
+
"""`primarysource itu` (formerly `itulib`) - fetch, index, then ask.
|
|
2
|
+
|
|
3
|
+
`fetch` and `sync` are the only commands that touch the network, and they write outside the checkout by
|
|
4
|
+
default. Everything that reads refuses to answer when the corpus or index is absent, naming the command that
|
|
5
|
+
fixes it; an empty result here is always a real empty, never a swallowed failure.
|
|
6
|
+
|
|
7
|
+
References use one grammar everywhere: `X.690§11.6¶2` (`¶n` is the nth sentence of the extracted clause), or
|
|
8
|
+
the looser `X.690 11.6` and `11.6` (which means X.690). `X.690§11.6¶5-` is the clause from its 5th sentence on,
|
|
9
|
+
which is what a paged read says to continue with.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import argparse
|
|
15
|
+
import sys
|
|
16
|
+
from collections.abc import Callable
|
|
17
|
+
|
|
18
|
+
from primarysource.errors import LibError
|
|
19
|
+
|
|
20
|
+
from . import __version__, corpus, index, sources, text
|
|
21
|
+
from .errata import errata_text
|
|
22
|
+
|
|
23
|
+
DEFAULT = "X.690"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _resolve(ref: str) -> tuple[sources.Edition, str, int | None]:
|
|
27
|
+
rec, number, para = text.parse_ref(ref)
|
|
28
|
+
return sources.resolve(rec or DEFAULT), number, para
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _join(parts: list[str]) -> str:
|
|
32
|
+
return " ".join(parts)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def cmd_info(_args: argparse.Namespace) -> int:
|
|
36
|
+
for item in (*sources.IN_FORCE, *sources.SUPERSEDED):
|
|
37
|
+
state = "fetched" if corpus.has(item) else "-"
|
|
38
|
+
print(f"{item.key:18} {item.status:11} {item.octets:>10,} B {state:8} {item.title}")
|
|
39
|
+
for amendment in sources.AMENDMENTS:
|
|
40
|
+
fetched = "fetched" if amendment.artifact and corpus.has(amendment) else "-"
|
|
41
|
+
print(f"{amendment.key:18} {amendment.status:11} {'':>12} {fetched:8} {amendment.label}")
|
|
42
|
+
print(f"corpus home: {corpus.home()}")
|
|
43
|
+
return 0
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def cmd_fetch(args: argparse.Namespace) -> int:
|
|
47
|
+
document = sources.resolve_document(args.target)
|
|
48
|
+
path = corpus.fetch(document, refresh=args.refresh)
|
|
49
|
+
print(f"fetched {document.key} -> {path} ({path.stat().st_size:,} B)")
|
|
50
|
+
return 0
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def cmd_sync(args: argparse.Namespace) -> int:
|
|
54
|
+
"""Fetch and index each in-force edition. One failure is reported and does not stop the rest, and the exit
|
|
55
|
+
status is non-zero if any failed, because a sync that quietly skips reports the same green as one that ran."""
|
|
56
|
+
everything = (*sources.IN_FORCE, *sources.SUPERSEDED)
|
|
57
|
+
targets = (
|
|
58
|
+
[sources.resolve(name) for name in args.targets]
|
|
59
|
+
if args.targets
|
|
60
|
+
else list(everything if args.all else sources.IN_FORCE)
|
|
61
|
+
)
|
|
62
|
+
failed: list[str] = []
|
|
63
|
+
for edition in targets:
|
|
64
|
+
try:
|
|
65
|
+
corpus.fetch(edition)
|
|
66
|
+
count = index.build(edition)
|
|
67
|
+
print(f"{edition.key:16} ok {count:>5} clauses")
|
|
68
|
+
except LibError as error:
|
|
69
|
+
failed.append(edition.key)
|
|
70
|
+
print(f"{edition.key:16} FAILED {error.as_text()}", file=sys.stderr)
|
|
71
|
+
except (RuntimeError, OSError) as error:
|
|
72
|
+
failed.append(edition.key)
|
|
73
|
+
print(f"{edition.key:16} FAILED {error}", file=sys.stderr)
|
|
74
|
+
return 3 if failed else 0
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def cmd_index(args: argparse.Namespace) -> int:
|
|
78
|
+
edition = sources.resolve(args.edition)
|
|
79
|
+
print(f"{edition.key}: {index.build(edition)} clauses indexed")
|
|
80
|
+
return 0
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def cmd_search(args: argparse.Namespace) -> int:
|
|
84
|
+
edition = sources.resolve(args.edition)
|
|
85
|
+
query = _join(args.query)
|
|
86
|
+
found = index.search(edition, query, args.limit, args.start)
|
|
87
|
+
if found.relaxed:
|
|
88
|
+
print("note: no clause contains every term; these contain some of them\n", file=sys.stderr)
|
|
89
|
+
if not found.hits:
|
|
90
|
+
print(f"{edition.key}: no clause matches {query!r} (this is a real empty: the index is loaded)")
|
|
91
|
+
return 1
|
|
92
|
+
for hit in found.hits:
|
|
93
|
+
print(f"{index.citation(edition, hit.number)} {hit.title}\n {hit.excerpt}\n")
|
|
94
|
+
if found.next_start is not None:
|
|
95
|
+
print(f"[more: continue with --start {found.next_start}]")
|
|
96
|
+
return 0
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def cmd_read(args: argparse.Namespace) -> int:
|
|
100
|
+
ref = _join(args.ref)
|
|
101
|
+
document = next((a for a in sources.AMENDMENTS if a.key == args.ref[0]), None)
|
|
102
|
+
if document is not None:
|
|
103
|
+
print(document.label, "", errata_text(document), sep="\n")
|
|
104
|
+
return 0
|
|
105
|
+
target = text.parse_target(ref)
|
|
106
|
+
edition, number, para = sources.resolve(target.rec or DEFAULT), target.number, target.para
|
|
107
|
+
start = para if target.onward and para is not None else args.start
|
|
108
|
+
if target.onward:
|
|
109
|
+
para = None
|
|
110
|
+
if args.view == "normative":
|
|
111
|
+
found = index.normative_statements(edition, number)
|
|
112
|
+
if found is None:
|
|
113
|
+
print(f"{edition.key}: no clause §{number}", file=sys.stderr)
|
|
114
|
+
return 1
|
|
115
|
+
for n, sentence in found:
|
|
116
|
+
print(f"{index.citation(edition, number, n)}\n {sentence}")
|
|
117
|
+
return 0
|
|
118
|
+
if args.view == "refs":
|
|
119
|
+
return _print_links(edition, number)
|
|
120
|
+
if para is not None:
|
|
121
|
+
one = index.sentence(edition, number, para)
|
|
122
|
+
if one is None:
|
|
123
|
+
print(f"{edition.key}: §{number} has no ¶{para}", file=sys.stderr)
|
|
124
|
+
return 1
|
|
125
|
+
print(f"{index.citation(edition, number, para)}\n\n{one}")
|
|
126
|
+
return 0
|
|
127
|
+
page = index.page(edition, number, start=start, max_tokens=args.max_tokens)
|
|
128
|
+
if page is None or page.total == 0:
|
|
129
|
+
kids = index.children(edition, number)
|
|
130
|
+
if kids:
|
|
131
|
+
print(f"{index.citation(edition, number)}: heading only; subclauses:")
|
|
132
|
+
for child, title in kids:
|
|
133
|
+
print(f" {child:10} {title}")
|
|
134
|
+
return 0
|
|
135
|
+
if page is None:
|
|
136
|
+
print(f"{edition.key}: no clause §{number}", file=sys.stderr)
|
|
137
|
+
return 1
|
|
138
|
+
print(f"{index.citation(edition, number)} {page.title}: heading with no text and no indexed subclauses")
|
|
139
|
+
return 0
|
|
140
|
+
print(f"{index.citation(edition, page.number)} {page.title}\n")
|
|
141
|
+
print(page.text)
|
|
142
|
+
if page.next is not None:
|
|
143
|
+
doc = edition.rec if sources.resolve(edition.rec) is edition else edition.key
|
|
144
|
+
print(
|
|
145
|
+
f"\n[¶{page.first}-{page.last} of {page.total}; continue with: read '{doc}§{page.number}¶{page.next}-']"
|
|
146
|
+
)
|
|
147
|
+
extra = index.fragments(edition, number) - 1
|
|
148
|
+
if extra:
|
|
149
|
+
print(
|
|
150
|
+
f"\nwarning: {extra} other extracted fragment(s) share the number {number}; this is the first. "
|
|
151
|
+
"Check the PDF before relying on it.",
|
|
152
|
+
file=sys.stderr,
|
|
153
|
+
)
|
|
154
|
+
return 0
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def cmd_cite(args: argparse.Namespace) -> int:
|
|
158
|
+
edition, number, para = _resolve(_join(args.ref))
|
|
159
|
+
if index.read(edition, number) is None:
|
|
160
|
+
print(f"{edition.key}: no clause §{number}; refusing to cite what is not there", file=sys.stderr)
|
|
161
|
+
return 1
|
|
162
|
+
print(index.citation(edition, number, para))
|
|
163
|
+
return 0
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _print_links(edition: sources.Edition, number: str) -> int:
|
|
167
|
+
found = index.links(edition, number)
|
|
168
|
+
if found is None:
|
|
169
|
+
print(f"{edition.key}: no clause §{number}", file=sys.stderr)
|
|
170
|
+
return 1
|
|
171
|
+
print(index.citation(edition, number))
|
|
172
|
+
for label, values in (
|
|
173
|
+
("points at", found.clauses),
|
|
174
|
+
("unresolved", found.unresolved),
|
|
175
|
+
("recommendations", found.recommendations),
|
|
176
|
+
("standards", found.standards),
|
|
177
|
+
("cited by", found.cited_by),
|
|
178
|
+
):
|
|
179
|
+
print(f" {label:16} {', '.join(values) if values else '-'}")
|
|
180
|
+
return 0
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def cmd_compare(args: argparse.Namespace) -> int:
|
|
184
|
+
first, second = sources.resolve(args.edition_a), sources.resolve(args.edition_b)
|
|
185
|
+
_rec, number, _para = text.parse_ref(args.number)
|
|
186
|
+
result = index.compare(first, second, number)
|
|
187
|
+
print(f"{index.citation(first, number)} -> {index.citation(second, number)}")
|
|
188
|
+
if not (result.in_a and result.in_b):
|
|
189
|
+
side = first.key if not result.in_a else second.key
|
|
190
|
+
print(f"§{number} does not exist in {side}; the rule may have moved rather than gone.")
|
|
191
|
+
return 1
|
|
192
|
+
if result.same:
|
|
193
|
+
print("identical (ignoring whitespace and case)")
|
|
194
|
+
return 0
|
|
195
|
+
for para, sentence in result.removed:
|
|
196
|
+
print(f"- [¶{para}] {sentence}")
|
|
197
|
+
for para, sentence in result.added:
|
|
198
|
+
print(f"+ [¶{para}] {sentence}")
|
|
199
|
+
return 0
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def cmd_catalog_sync(args: argparse.Namespace) -> int:
|
|
203
|
+
"""Read series listings (about one request each), then, with --details, each recommendation's pages."""
|
|
204
|
+
from . import catalog
|
|
205
|
+
|
|
206
|
+
letters = list(sources.SERIES) if args.series == ["all"] else [s.upper() for s in args.series]
|
|
207
|
+
fetcher = catalog.Fetcher(catalog.catalog_dir() / "cache")
|
|
208
|
+
for letter in letters:
|
|
209
|
+
count = catalog.sync_listing(letter, fetcher, refresh=args.refresh)
|
|
210
|
+
print(f"series {letter}: {count} recommendations listed")
|
|
211
|
+
if args.details:
|
|
212
|
+
done, pages = catalog.sync_details(
|
|
213
|
+
letter,
|
|
214
|
+
fetcher,
|
|
215
|
+
limit=args.limit,
|
|
216
|
+
only=args.rec or None,
|
|
217
|
+
include_withdrawn=args.include_withdrawn,
|
|
218
|
+
refresh=args.refresh,
|
|
219
|
+
reparse=args.reparse,
|
|
220
|
+
progress=lambda message: print(" " + message, flush=True),
|
|
221
|
+
)
|
|
222
|
+
print(f"series {letter}: {done} recommendations detailed, {pages} component pages read")
|
|
223
|
+
print(f"{fetcher.requests} request(s) made to itu.int")
|
|
224
|
+
return 0
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def cmd_catalog_list(args: argparse.Namespace) -> int:
|
|
228
|
+
from . import catalog
|
|
229
|
+
|
|
230
|
+
recs = catalog.load(args.series.upper())
|
|
231
|
+
found = catalog.rows(recs, text=args.text, fmt=args.format, free_only=args.free)
|
|
232
|
+
for row in found[: args.limit]:
|
|
233
|
+
print(f"{row.rec:10} {row.kind:11} {row.label:42} {row.summary():34} {row.title}")
|
|
234
|
+
shown = min(len(found), args.limit)
|
|
235
|
+
detailed = sum(1 for entry in recs.values() if entry.get("components") is not None)
|
|
236
|
+
print(
|
|
237
|
+
f"\n{shown} of {len(found)} shown; {detailed} of {len(recs)} recommendations in series {args.series.upper()}"
|
|
238
|
+
f" have details (sync with --details for the rest). * = not freely available."
|
|
239
|
+
)
|
|
240
|
+
return 0 if found else 1
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def cmd_catalog_show(args: argparse.Namespace) -> int:
|
|
244
|
+
from . import catalog
|
|
245
|
+
|
|
246
|
+
rec = args.rec
|
|
247
|
+
entry = catalog.load(rec[0].upper()).get(rec)
|
|
248
|
+
if entry is None:
|
|
249
|
+
print(f"{rec}: not in the catalogue. Run: primarysource itu catalog sync {rec[0].upper()}", file=sys.stderr)
|
|
250
|
+
return 1
|
|
251
|
+
print(f"{rec} [{entry.get('status')}] {entry.get('title')}")
|
|
252
|
+
if entry.get("note"):
|
|
253
|
+
print(f" note: {entry['note']}")
|
|
254
|
+
components = entry.get("components")
|
|
255
|
+
if components is None:
|
|
256
|
+
print(f" no details yet. Run: primarysource itu catalog sync {rec[0].upper()} --details")
|
|
257
|
+
return 0
|
|
258
|
+
for component in components: # type: ignore[attr-defined]
|
|
259
|
+
assets = component.get("assets") or []
|
|
260
|
+
formats = " ".join(f"{a['fmt']}{'' if a['access'] == 'free' else '*'}" for a in assets)
|
|
261
|
+
head = f"{component['section']:10} {component['kind']:11} {component['label']:42}"
|
|
262
|
+
print(f" {head} {component['status']:24} {formats}")
|
|
263
|
+
return 0
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def cmd_links(args: argparse.Namespace) -> int:
|
|
267
|
+
edition, number, _para = _resolve(_join(args.ref))
|
|
268
|
+
return _print_links(edition, number)
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def cmd_grep(args: argparse.Namespace) -> int:
|
|
272
|
+
edition = sources.resolve(args.edition)
|
|
273
|
+
hits, more = index.grep(edition, args.pattern, start=args.start, max_tokens=args.max_tokens)
|
|
274
|
+
for hit in hits:
|
|
275
|
+
print(f"{index.citation(edition, hit.number)}\n {hit.excerpt}\n")
|
|
276
|
+
if more is not None:
|
|
277
|
+
print(f"[cut by --max-tokens; continue with --start {more}, or narrow the pattern]", file=sys.stderr)
|
|
278
|
+
return 0 if hits else 1
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def cmd_browse(args: argparse.Namespace) -> int:
|
|
282
|
+
if not args.path:
|
|
283
|
+
registered = {item.rec.split(".")[0] for item in sources.IN_FORCE}
|
|
284
|
+
for letter, title in sources.SERIES.items():
|
|
285
|
+
print(f"{letter} {'*' if letter in registered else ' '} {title}")
|
|
286
|
+
print("\n* = has registered recommendations")
|
|
287
|
+
return 0
|
|
288
|
+
if len(args.path) == 1:
|
|
289
|
+
for item in sources.IN_FORCE:
|
|
290
|
+
if item.rec.startswith(args.path.upper() + "."):
|
|
291
|
+
print(f"{item.rec:8} {'fetched' if corpus.has(item) else '-':8} {item.title}")
|
|
292
|
+
return 0
|
|
293
|
+
for number, title in index.top_level(sources.resolve(args.path), args.depth):
|
|
294
|
+
print(f"{number:10} {title}")
|
|
295
|
+
return 0
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def cmd_errata(args: argparse.Namespace) -> int:
|
|
299
|
+
items = [a for a in sources.AMENDMENTS if a.rec == args.rec]
|
|
300
|
+
if not items:
|
|
301
|
+
print(f"{args.rec}: nothing registered", file=sys.stderr)
|
|
302
|
+
return 1
|
|
303
|
+
for item in items:
|
|
304
|
+
state = "fetched" if item.artifact and corpus.has(item) else ("not fetched" if item.artifact else "no id")
|
|
305
|
+
print(f"{item.key} [{item.status}; {state}]\n {item.label}")
|
|
306
|
+
return 0
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def cmd_mcp(_args: argparse.Namespace) -> int:
|
|
310
|
+
from . import mcp
|
|
311
|
+
|
|
312
|
+
try:
|
|
313
|
+
mcp.require_extra()
|
|
314
|
+
except ImportError as error:
|
|
315
|
+
print(error, file=sys.stderr)
|
|
316
|
+
return 2
|
|
317
|
+
mcp.main()
|
|
318
|
+
return 0
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
322
|
+
parser = argparse.ArgumentParser(
|
|
323
|
+
prog="primarysource itu", description=__doc__, formatter_class=argparse.RawTextHelpFormatter
|
|
324
|
+
)
|
|
325
|
+
parser.add_argument("--version", action="version", version=f"primarysource itu {__version__}")
|
|
326
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
327
|
+
|
|
328
|
+
def add(name: str, handler: Callable[[argparse.Namespace], int], help_text: str) -> argparse.ArgumentParser:
|
|
329
|
+
command = sub.add_parser(name, help=help_text, description=help_text)
|
|
330
|
+
command.set_defaults(handler=handler)
|
|
331
|
+
return command
|
|
332
|
+
|
|
333
|
+
add("info", cmd_info, "registered editions, amendments and what is fetched")
|
|
334
|
+
|
|
335
|
+
fetch = add("fetch", cmd_fetch, "download one edition or erratum into ITULIB_HOME and verify its digest")
|
|
336
|
+
fetch.add_argument("target", nargs="?", default=DEFAULT, help="edition or erratum key (default X.690)")
|
|
337
|
+
fetch.add_argument("--refresh", action="store_true", help="download again even if present")
|
|
338
|
+
|
|
339
|
+
sync = add("sync", cmd_sync, "fetch and index every in-force edition (or those named)")
|
|
340
|
+
sync.add_argument("targets", nargs="*", help="editions to sync (default: every in-force edition)")
|
|
341
|
+
sync.add_argument("--all", action="store_true", help="include superseded editions that have a digest")
|
|
342
|
+
|
|
343
|
+
idx = add("index", cmd_index, "build the clause index for a fetched edition")
|
|
344
|
+
idx.add_argument("edition", nargs="?", default=DEFAULT)
|
|
345
|
+
|
|
346
|
+
search = add("search", cmd_search, "full-text clause search")
|
|
347
|
+
search.add_argument("edition")
|
|
348
|
+
search.add_argument("query", nargs="+")
|
|
349
|
+
search.add_argument("--limit", type=int, default=8)
|
|
350
|
+
search.add_argument("--start", type=int, default=0, help="0-based offset of the first hit (paging)")
|
|
351
|
+
|
|
352
|
+
read = add("read", cmd_read, "print a clause (or one sentence, or an erratum) with its citation")
|
|
353
|
+
read.add_argument("ref", nargs="+", help="X.690§11.6, X.690 11.6¶2, X.690§11.6¶5-, 11.6, or an erratum key")
|
|
354
|
+
read.add_argument("--start", type=int, default=1, help="first sentence to print (paging)")
|
|
355
|
+
read.add_argument("--max-tokens", type=int, default=2000)
|
|
356
|
+
read.add_argument("--view", choices=["text", "normative", "refs"], default="text")
|
|
357
|
+
|
|
358
|
+
cite = add("cite", cmd_cite, "the citation string for a clause or sentence")
|
|
359
|
+
cite.add_argument("ref", nargs="+")
|
|
360
|
+
|
|
361
|
+
compare = add("compare", cmd_compare, "what changed in a clause between two editions")
|
|
362
|
+
compare.add_argument("number")
|
|
363
|
+
compare.add_argument("edition_a", nargs="?", default="X.690#2008-11")
|
|
364
|
+
compare.add_argument("edition_b", nargs="?", default="X.690#2021-02")
|
|
365
|
+
|
|
366
|
+
links = add("links", cmd_links, "what a clause points at, and what points at it")
|
|
367
|
+
links.add_argument("ref", nargs="+")
|
|
368
|
+
|
|
369
|
+
grep = add("grep", cmd_grep, "regular expression over clause bodies")
|
|
370
|
+
grep.add_argument("edition")
|
|
371
|
+
grep.add_argument("pattern")
|
|
372
|
+
grep.add_argument("--max-tokens", type=int, default=1500)
|
|
373
|
+
grep.add_argument("--start", type=int, default=0, help="0-based offset of the first matching clause")
|
|
374
|
+
|
|
375
|
+
browse = add("browse", cmd_browse, "ITU-T series, a series' recommendations, or a recommendation's clauses")
|
|
376
|
+
browse.add_argument("path", nargs="?", default="")
|
|
377
|
+
browse.add_argument("--depth", type=int, default=2)
|
|
378
|
+
|
|
379
|
+
errata = add("errata", cmd_errata, "errata, corrigenda and amendments registered for a recommendation")
|
|
380
|
+
errata.add_argument("rec", nargs="?", default=DEFAULT)
|
|
381
|
+
|
|
382
|
+
catalog = add("catalog", lambda _args: 0, "the ITU-T catalogue: what exists and in which formats (metadata only)")
|
|
383
|
+
catalog_sub = catalog.add_subparsers(dest="catalog_command", required=True)
|
|
384
|
+
|
|
385
|
+
def add_catalog(
|
|
386
|
+
name: str, handler: Callable[[argparse.Namespace], int], help_text: str
|
|
387
|
+
) -> argparse.ArgumentParser:
|
|
388
|
+
command = catalog_sub.add_parser(name, help=help_text, description=help_text)
|
|
389
|
+
command.set_defaults(handler=handler)
|
|
390
|
+
return command
|
|
391
|
+
|
|
392
|
+
csync = add_catalog(
|
|
393
|
+
"sync", cmd_catalog_sync, "read series listings from itu.int (1 request/second, cached under ITULIB_HOME)"
|
|
394
|
+
)
|
|
395
|
+
csync.add_argument("series", nargs="*", default=["X"], help="series letters, or 'all' (default: X)")
|
|
396
|
+
csync.add_argument("--details", action="store_true", help="also read each recommendation's pages (resumable)")
|
|
397
|
+
csync.add_argument("--limit", type=int, help="stop after this many recommendations (with --details)")
|
|
398
|
+
csync.add_argument(
|
|
399
|
+
"--rec", nargs="*", help="only these recommendations, for example X.509 X.690 (with --details)"
|
|
400
|
+
)
|
|
401
|
+
csync.add_argument("--include-withdrawn", action="store_true")
|
|
402
|
+
csync.add_argument("--refresh", action="store_true", help="ignore the page cache and fetch again")
|
|
403
|
+
csync.add_argument("--reparse", action="store_true", help="rebuild details from cached pages (no requests)")
|
|
404
|
+
|
|
405
|
+
clist = add_catalog("list", cmd_catalog_list, "in-force editions with their available formats")
|
|
406
|
+
clist.add_argument("--series", default="X")
|
|
407
|
+
clist.add_argument("--text", default="", help="substring of number or title")
|
|
408
|
+
clist.add_argument("--format", default="", help="only editions offering this format id (PDF, EPB, MSW, TOC-HTM)")
|
|
409
|
+
clist.add_argument("--free", action="store_true", help="count only freely available assets for --format")
|
|
410
|
+
clist.add_argument("--limit", type=int, default=50)
|
|
411
|
+
|
|
412
|
+
cshow = add_catalog("show", cmd_catalog_show, "every component of one recommendation, with formats")
|
|
413
|
+
cshow.add_argument("rec", help="for example X.509")
|
|
414
|
+
|
|
415
|
+
add("mcp", cmd_mcp, "serve the MCP tools over stdio (needs the mcp extra)")
|
|
416
|
+
return parser
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def main(argv: list[str] | None = None) -> int:
|
|
420
|
+
# The corpus is full of characters cp1252 has no room for: X.680 §47.3 contains U+2212 MINUS SIGN, and
|
|
421
|
+
# printing that clause killed the command with UnicodeEncodeError on a redirected Windows console. A tool
|
|
422
|
+
# whose whole job is to hand back clause text cannot be allowed to die on a minus sign, so stdout is
|
|
423
|
+
# UTF-8 with replacement rather than whatever the console's code page happens to be.
|
|
424
|
+
sys.stdout.reconfigure(encoding="utf-8", errors="replace") # type: ignore[union-attr]
|
|
425
|
+
args = build_parser().parse_args(argv)
|
|
426
|
+
# A missing corpus is a known state with a known remedy, so it is reported as one rather than as a
|
|
427
|
+
# traceback: a stack ending in CorpusMissing reads like a crash, an `action:` line reads as instructions.
|
|
428
|
+
try:
|
|
429
|
+
return int(args.handler(args))
|
|
430
|
+
except LibError as error:
|
|
431
|
+
print(error.as_text(), file=sys.stderr)
|
|
432
|
+
return 3
|
|
433
|
+
except KeyError as error:
|
|
434
|
+
print(error.args[0], file=sys.stderr)
|
|
435
|
+
return 2
|
|
436
|
+
except ValueError as error:
|
|
437
|
+
print(error, file=sys.stderr)
|
|
438
|
+
return 2
|
|
439
|
+
except RuntimeError as error:
|
|
440
|
+
print(str(error), file=sys.stderr)
|
|
441
|
+
return 3
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
if __name__ == "__main__":
|
|
445
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""ITU-T recommendations as a primarysource collection. Importing this needs nothing beyond the core."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from primarysource.model import Unit
|
|
6
|
+
from primarysource.refs import RefGrammar
|
|
7
|
+
from primarysource.registry import Collection
|
|
8
|
+
|
|
9
|
+
# itulib's refs: X.690§11.6¶2, with an edition key after the number (X.690#2008-11§8.1). ¶n counts sentences of
|
|
10
|
+
# the extracted clause. The tools already carry the itu_ prefix.
|
|
11
|
+
COLLECTION = Collection(
|
|
12
|
+
name="itu",
|
|
13
|
+
title="ITU-T recommendations (the ASN.1 family X.680-X.697), fetched by the user",
|
|
14
|
+
prefix="itu_",
|
|
15
|
+
unit=Unit.SENTENCE,
|
|
16
|
+
grammar=RefGrammar(r"[A-Z]\.\d+(?:\.\d+)?(?:#[\d-]+)?"),
|
|
17
|
+
server="primarysource.itu.mcp:build",
|
|
18
|
+
cli="primarysource.itu.cli:main",
|
|
19
|
+
identify="primarysource.itu.identify:identify",
|
|
20
|
+
)
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""The corpus lives on the user's machine, and this repository never carries it.
|
|
2
|
+
|
|
3
|
+
What decides the design is the notice printed on page iii of the recommendation itself: ITU reserves all
|
|
4
|
+
rights and permits no part of the publication to be reproduced by any means without its prior written
|
|
5
|
+
permission. That is quoted here in paraphrase rather than verbatim for the same reason the text is not
|
|
6
|
+
vendored, and `tests/test_license.py` fails if a clause sentence ever appears in this tree.
|
|
7
|
+
|
|
8
|
+
So `fetch` is a command the user runs, the download lands outside the checkout by default, and a clone of this
|
|
9
|
+
repository contains code, clause arithmetic and pinned digests - never spec text. The tripwire exists because
|
|
10
|
+
the tempting way to make a search test pass is to commit the PDF, and that is the one move a git history
|
|
11
|
+
cannot walk back.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import hashlib
|
|
17
|
+
import json
|
|
18
|
+
import os
|
|
19
|
+
import pathlib
|
|
20
|
+
import time
|
|
21
|
+
import urllib.request
|
|
22
|
+
|
|
23
|
+
from primarysource.errors import LibError
|
|
24
|
+
|
|
25
|
+
from .sources import Amendment, Edition
|
|
26
|
+
|
|
27
|
+
Document = Edition | Amendment
|
|
28
|
+
|
|
29
|
+
# itulib's own default, kept so an existing library is found after the move into primarysource.
|
|
30
|
+
DEFAULT_HOME = pathlib.Path.home() / ".itulib"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def home() -> pathlib.Path:
|
|
34
|
+
"""`$ITULIB_HOME`, else an existing `~/.itulib`, else `primarysource.home.collection_home("itu")`
|
|
35
|
+
(`$PRIMARYSOURCE_HOME/itu` or the per-user `primarysource/itu`)."""
|
|
36
|
+
override = os.environ.get("ITULIB_HOME")
|
|
37
|
+
if override:
|
|
38
|
+
return pathlib.Path(override)
|
|
39
|
+
if DEFAULT_HOME.is_dir() and not os.environ.get("PRIMARYSOURCE_HOME"):
|
|
40
|
+
return DEFAULT_HOME
|
|
41
|
+
from primarysource.home import collection_home
|
|
42
|
+
|
|
43
|
+
return collection_home("itu")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def pdf_path(edition: Document) -> pathlib.Path:
|
|
47
|
+
return home() / "corpus" / f"{edition.key.replace('#', '-').replace('!', '-')}.pdf"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def manifest_path() -> pathlib.Path:
|
|
51
|
+
return home() / "manifest.json"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _record() -> dict[str, dict[str, object]]:
|
|
55
|
+
path = manifest_path()
|
|
56
|
+
if not path.is_file():
|
|
57
|
+
return {}
|
|
58
|
+
loaded = json.loads(path.read_text(encoding="utf-8"))
|
|
59
|
+
return loaded if isinstance(loaded, dict) else {}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def has(edition: Document) -> bool:
|
|
63
|
+
"""Present and, if a digest is registered, byte-identical to it."""
|
|
64
|
+
path = pdf_path(edition)
|
|
65
|
+
if not path.is_file():
|
|
66
|
+
return False
|
|
67
|
+
if not edition.sha256:
|
|
68
|
+
return True
|
|
69
|
+
return hashlib.sha256(path.read_bytes()).hexdigest() == edition.sha256
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def require(edition: Document) -> pathlib.Path:
|
|
73
|
+
"""The local PDF, or an error naming the command that produces it."""
|
|
74
|
+
path = pdf_path(edition)
|
|
75
|
+
if not has(edition):
|
|
76
|
+
raise CorpusMissing(
|
|
77
|
+
f"{edition.key} is not in {home()}. The file is copyright ITU and is fetched to your machine rather "
|
|
78
|
+
"than shipped with this package, so there is no way to obtain it from the repository.",
|
|
79
|
+
"itu_info shows which editions are fetched",
|
|
80
|
+
f"primarysource itu fetch {edition.key}",
|
|
81
|
+
)
|
|
82
|
+
return path
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class CorpusMissing(LibError, RuntimeError):
|
|
86
|
+
"""The corpus has not been fetched. The remedy is a command (`action`), never a committed file."""
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def fetch(edition: Document, *, refresh: bool = False) -> pathlib.Path:
|
|
90
|
+
"""Download one edition, verify it against the registered digest, and record what arrived."""
|
|
91
|
+
path = pdf_path(edition)
|
|
92
|
+
if has(edition) and not refresh:
|
|
93
|
+
return path
|
|
94
|
+
url = edition.pdf_url
|
|
95
|
+
if url is None:
|
|
96
|
+
raise RuntimeError(f"{edition.key}: no download id is registered, so there is nothing to fetch")
|
|
97
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
98
|
+
request = urllib.request.Request(url, headers={"User-Agent": "itulib-fetch"}) # noqa: S310
|
|
99
|
+
with urllib.request.urlopen(request, timeout=300) as response: # noqa: S310
|
|
100
|
+
body = response.read()
|
|
101
|
+
if not body.startswith(b"%PDF"):
|
|
102
|
+
raise RuntimeError(f"{edition.key}: server returned {len(body)} bytes that are not a PDF")
|
|
103
|
+
digest = hashlib.sha256(body).hexdigest()
|
|
104
|
+
if edition.sha256 and digest != edition.sha256:
|
|
105
|
+
raise RuntimeError(
|
|
106
|
+
f"{edition.key}: digest {digest} does not match the registered {edition.sha256}. "
|
|
107
|
+
f"ITU may have re-published the edition; re-verify before trusting any citation from it."
|
|
108
|
+
)
|
|
109
|
+
path.write_bytes(body)
|
|
110
|
+
records = _record()
|
|
111
|
+
records[edition.key] = {
|
|
112
|
+
"sha256": digest,
|
|
113
|
+
"octets": len(body),
|
|
114
|
+
"fetched_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
|
115
|
+
"source": url,
|
|
116
|
+
}
|
|
117
|
+
manifest_path().write_text(json.dumps(records, indent=2), encoding="utf-8")
|
|
118
|
+
return path
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""The text of an erratum, from the fetched covering note. Not indexed: it has no clause structure."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from . import corpus, text
|
|
6
|
+
from .sources import Amendment
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def errata_text(amendment: Amendment) -> str:
|
|
10
|
+
"""Extracted text of a fetched erratum, or CorpusMissing naming the fetch command.
|
|
11
|
+
|
|
12
|
+
The covering note is a figure replacement, so the extract carries the instruction and the figure caption
|
|
13
|
+
but not the figure itself; the PDF is the authority for what the corrected figure shows.
|
|
14
|
+
"""
|
|
15
|
+
pdf = corpus.require(amendment)
|
|
16
|
+
return text.tidy(" ".join(text.pages(pdf)))
|