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,15 @@
|
|
|
1
|
+
"""`pep`: Python Enhancement Proposals, a pinned commit of python/peps, read as reStructuredText. Served under `pep_`.
|
|
2
|
+
|
|
3
|
+
Stdlib only. `mcp` is imported by `server.py` when a server is built; `primarysource pep ...` runs `cli.py`.
|
|
4
|
+
`docs/pep.md` is the description; `catalogue.py` holds the pin, the licence and the build.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from primarysource.pep.catalogue import SPEC
|
|
10
|
+
from primarysource.pkgdocs.runtime import make_collection
|
|
11
|
+
|
|
12
|
+
COLLECTION = make_collection(SPEC, "primarysource.pep")
|
|
13
|
+
"""Refs: `pep:440§Version scheme`; see `pkgdocs.runtime`."""
|
|
14
|
+
|
|
15
|
+
__all__ = ["COLLECTION", "SPEC"]
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
"""The `pep` collection: the Python Enhancement Proposals in python/peps, read as reStructuredText.
|
|
2
|
+
|
|
3
|
+
Source, placement and pin (read from the repository on 2026-11-13, see `docs/pep.md`):
|
|
4
|
+
|
|
5
|
+
- the tree is `peps/` of python/peps at one `main` commit, fetched as a GitHub tarball. Each PEP is
|
|
6
|
+
`peps/pep-NNNN.rst`; the few with appendices also have `peps/pep-NNNN/*.rst`;
|
|
7
|
+
- the licence is per document, not per repository (the repository has no LICENSE file at the root). Each PEP's own
|
|
8
|
+
"Copyright" section is the statement, and the build classifies it into the document's `licence` meta. Measured
|
|
9
|
+
at this commit over the 747 PEP files: 246 say "placed in the public domain or under the CC0-1.0-Universal
|
|
10
|
+
license, whichever is more permissive" (`public-domain-or-cc0`), 467 say "public domain" in other words
|
|
11
|
+
(`public-domain`), 6 name the Open Publication License (`open-publication-license`) and 28 carry no statement
|
|
12
|
+
(`unstated`, the oldest); the 14 appendix files are `see-parent-pep`. The import prints none of this, `status`
|
|
13
|
+
does not either: read the meta, or `licence_counts`, when deciding placement per document;
|
|
14
|
+
- placement is **layer** for the collection, with that caveat recorded: the statement is read from the text, the
|
|
15
|
+
repository carries no machine-readable licence for it.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import re
|
|
21
|
+
from collections import Counter
|
|
22
|
+
from collections.abc import Mapping
|
|
23
|
+
|
|
24
|
+
from primarysource.errors import LibError
|
|
25
|
+
from primarysource.model import Unit
|
|
26
|
+
from primarysource.pkgdocs.fetch import Source
|
|
27
|
+
from primarysource.pkgdocs.model import Doc, Sec
|
|
28
|
+
from primarysource.pkgdocs.readers._common import decode
|
|
29
|
+
from primarysource.pkgdocs.readers.rst import read_rst
|
|
30
|
+
from primarysource.pkgdocs.runtime import Build, Licence, Pin, Spec, TreeSpec, matching
|
|
31
|
+
|
|
32
|
+
COMMIT = "155e5bee7d985ef30555b496eb994d24a591f70b"
|
|
33
|
+
|
|
34
|
+
PIN = Pin(
|
|
35
|
+
commit=COMMIT,
|
|
36
|
+
label="main 2026-11-12",
|
|
37
|
+
date="2026-11-12",
|
|
38
|
+
files_sha256="6d4a01026a0a2c4b81ecb18d6e23939cc6ba59f6ffc41b7f3d614238951343e8",
|
|
39
|
+
sources=(
|
|
40
|
+
Source(f"https://codeload.github.com/python/peps/tar.gz/{COMMIT}", strip=1, subdir="peps"),
|
|
41
|
+
Source(
|
|
42
|
+
f"https://github.com/python/peps/archive/{COMMIT}.tar.gz",
|
|
43
|
+
strip=1,
|
|
44
|
+
hosts=("codeload.github.com",),
|
|
45
|
+
subdir="peps",
|
|
46
|
+
),
|
|
47
|
+
),
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
LICENCE = Licence(
|
|
51
|
+
spdx="CC0-1.0 OR LicenseRef-public-domain",
|
|
52
|
+
redistribute="layer",
|
|
53
|
+
attribution="each PEP is by its authors; the licence is the one in its own Copyright section",
|
|
54
|
+
evidence="the Copyright section of each pep-NNNN.rst; per-document result in meta `licence`",
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
_FILE = re.compile(r"pep-(\d{4})\.rst")
|
|
58
|
+
_APPENDIX = re.compile(r"pep-(\d{4})/([A-Za-z0-9._-]+)\.rst")
|
|
59
|
+
_KEY = re.compile(r"([A-Za-z][A-Za-z-]*):[ \t]*(.*)")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _licence_of(text: str) -> str:
|
|
63
|
+
"""Which licence statement a PEP carries, from its text: `public-domain-or-cc0` (the standard one),
|
|
64
|
+
`public-domain`, `open-publication-license` or `unstated`."""
|
|
65
|
+
low = text.lower()
|
|
66
|
+
if "placed in the public domain or under the cc0-1.0-universal" in low or "whichever is more permissive" in low:
|
|
67
|
+
return "public-domain-or-cc0"
|
|
68
|
+
if "open publication license" in low:
|
|
69
|
+
return "open-publication-license"
|
|
70
|
+
if "public domain" in low:
|
|
71
|
+
return "public-domain"
|
|
72
|
+
return "unstated"
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def split_header(text: str) -> tuple[dict[str, str], str]:
|
|
76
|
+
"""The RFC 822 style header at the top of a PEP and the body after it. A text whose first line is not
|
|
77
|
+
`PEP: <n>` has no header: the whole text is the body."""
|
|
78
|
+
head, sep, body = text.partition("\n\n")
|
|
79
|
+
if not head.startswith("PEP:"):
|
|
80
|
+
return {}, text
|
|
81
|
+
fields: dict[str, str] = {}
|
|
82
|
+
last = ""
|
|
83
|
+
for line in head.split("\n"):
|
|
84
|
+
if line[:1] in (" ", "\t") and last:
|
|
85
|
+
fields[last] += " " + line.strip()
|
|
86
|
+
continue
|
|
87
|
+
found = _KEY.fullmatch(line)
|
|
88
|
+
if found:
|
|
89
|
+
last = found.group(1).lower().replace("-", "_")
|
|
90
|
+
fields[last] = found.group(2).strip()
|
|
91
|
+
return fields, body if sep else ""
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def read_pep(data: bytes, doc_id: str, number: str) -> Doc:
|
|
95
|
+
"""A PEP: its header as block 1 of section `0`, its meta from the header, its body as reStructuredText."""
|
|
96
|
+
text = decode(data, f"PEP {number}")
|
|
97
|
+
fields, body = split_header(text)
|
|
98
|
+
title = f"PEP {int(number)} - {fields['title']}" if fields.get("title") else f"PEP {int(number)}"
|
|
99
|
+
doc = read_rst(body.encode("utf-8"), doc_id, title)
|
|
100
|
+
header = text[: len(text) - len(body)].rstrip() if fields else ""
|
|
101
|
+
sections = doc.sections
|
|
102
|
+
if header:
|
|
103
|
+
if sections and sections[0].id == "0":
|
|
104
|
+
first = sections[0]
|
|
105
|
+
sections = (Sec("0", first.title, first.depth, first.anchor, (header, *first.units)), *sections[1:])
|
|
106
|
+
else:
|
|
107
|
+
sections = (Sec("0", title, 1, "header", (header,)), *sections)
|
|
108
|
+
meta = {k: v for k, v in fields.items() if k in _META_KEYS}
|
|
109
|
+
meta["licence"] = _licence_of(body)
|
|
110
|
+
return Doc(doc_id, title, Unit.BLOCK, sections, meta, doc.warnings)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
_META_KEYS = frozenset(
|
|
114
|
+
{
|
|
115
|
+
"title",
|
|
116
|
+
"author",
|
|
117
|
+
"status",
|
|
118
|
+
"type",
|
|
119
|
+
"topic",
|
|
120
|
+
"created",
|
|
121
|
+
"python_version",
|
|
122
|
+
"replaces",
|
|
123
|
+
"superseded_by",
|
|
124
|
+
"requires",
|
|
125
|
+
"resolution",
|
|
126
|
+
"discussions_to",
|
|
127
|
+
"post_history",
|
|
128
|
+
"pep",
|
|
129
|
+
}
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def build(files: Mapping[str, bytes], pin: Pin) -> Build:
|
|
134
|
+
docs: list[Doc] = []
|
|
135
|
+
aliases: dict[str, str] = {}
|
|
136
|
+
skipped: Counter[str] = Counter()
|
|
137
|
+
for path in sorted(files):
|
|
138
|
+
main = _FILE.fullmatch(path)
|
|
139
|
+
appendix = _APPENDIX.fullmatch(path)
|
|
140
|
+
try:
|
|
141
|
+
if main:
|
|
142
|
+
number = main.group(1)
|
|
143
|
+
doc_id = str(int(number))
|
|
144
|
+
doc = read_pep(files[path], doc_id, number)
|
|
145
|
+
aliases[f"pep-{number}"] = doc_id
|
|
146
|
+
aliases[number] = doc_id
|
|
147
|
+
elif appendix:
|
|
148
|
+
number = appendix.group(1)
|
|
149
|
+
doc_id = f"{int(number)}/{appendix.group(2)}"
|
|
150
|
+
doc = read_rst(files[path], doc_id, f"PEP {int(number)} appendix {appendix.group(2)}").with_meta(
|
|
151
|
+
licence="see-parent-pep"
|
|
152
|
+
)
|
|
153
|
+
else:
|
|
154
|
+
skipped["not a PEP source"] += 1
|
|
155
|
+
continue
|
|
156
|
+
except LibError:
|
|
157
|
+
skipped["not UTF-8 text"] += 1
|
|
158
|
+
continue
|
|
159
|
+
docs.append(doc.with_meta(path=f"peps/{path}"))
|
|
160
|
+
return Build(tuple(docs), aliases, skipped)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def licence_counts(docs: Mapping[str, Doc] | list[Doc]) -> Counter[str]:
|
|
164
|
+
"""How many documents carry each licence statement (for the import report and the tests)."""
|
|
165
|
+
values = docs.values() if isinstance(docs, Mapping) else docs
|
|
166
|
+
return Counter(d.meta.get("licence", "unstated") for d in values)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
SPEC = Spec(
|
|
170
|
+
name="pep",
|
|
171
|
+
title="Python Enhancement Proposals",
|
|
172
|
+
licence=LICENCE,
|
|
173
|
+
trees=(
|
|
174
|
+
TreeSpec(
|
|
175
|
+
name="peps",
|
|
176
|
+
title="peps/ of python/peps (reStructuredText)",
|
|
177
|
+
pins=(PIN,),
|
|
178
|
+
include=matching(r"pep-\d{4}\.rst|pep-\d{4}/[A-Za-z0-9._-]+\.rst"),
|
|
179
|
+
build=build,
|
|
180
|
+
),
|
|
181
|
+
),
|
|
182
|
+
instructions=(
|
|
183
|
+
"Python Enhancement Proposals (PEP 440 versions, 427/491 wheels, 517/621 builds and metadata, ...) from "
|
|
184
|
+
"the user's imported copy of python/peps at a pinned commit. Loop: pep_search('<words>') -> "
|
|
185
|
+
"pep_outline('440') -> pep_read('<ref>'). A document id is the PEP number without leading zeros ('440'); "
|
|
186
|
+
"'pep-0440' and '0440' work too, and '440/appendix-x' names an appendix. Section 0 holds the header "
|
|
187
|
+
"(status, authors, dates). A section is an id (3.1), an anchor (version-scheme), a heading or a unique "
|
|
188
|
+
"part of one. Each document's licence statement is in its meta and shown by pep_status counts."
|
|
189
|
+
),
|
|
190
|
+
example_refs=("pep:440§Version scheme", "pep:440@155e5be§3.1¶2", "pep:517§Build backend interface"),
|
|
191
|
+
)
|
primarysource/pep/cli.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""`primarysource pep import|status|docs|outline|read|search|cite`."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Sequence
|
|
6
|
+
|
|
7
|
+
from primarysource.pep.catalogue import SPEC
|
|
8
|
+
from primarysource.pkgdocs.serving import run
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def main(argv: Sequence[str]) -> int:
|
|
12
|
+
return run(SPEC, argv)
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""The MCP server for `pep` (`primarysource mcp --collection pep`). Imports `mcp` when this module loads."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from primarysource.pep.catalogue import SPEC
|
|
6
|
+
from primarysource.pkgdocs.serving import build_server
|
|
7
|
+
|
|
8
|
+
server = build_server(SPEC)
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Shared plumbing for the packaging and tooling specification collections: `gitdocs`, `debpolicy`, `rpmdocs`,
|
|
2
|
+
`pms`, `pep` and `kconfig`.
|
|
3
|
+
|
|
4
|
+
Every one of them is published as text in a git repository (AsciiDoc, reStructuredText, Markdown, scdoc, POD,
|
|
5
|
+
LaTeX). They share one shape, so the shape lives here once and each collection directory holds only what is its
|
|
6
|
+
own: the pinned trees, the licence, the choice of files, the document ids and the ref spelling.
|
|
7
|
+
|
|
8
|
+
- `readers/`: minimal, bounded readers, one per format, bytes in and a `Doc` out. Nothing here opens a path a
|
|
9
|
+
document names, runs a command or imports anything outside the standard library. These are the proposed core
|
|
10
|
+
readers (ROADMAP rows `cl.reader_markdown`, `cl.reader_rst`, ...); they are private to this package until two
|
|
11
|
+
collections outside it need one.
|
|
12
|
+
- `fetch.py`: a pinned archive streamed, never extracted to disk (the proposed `cl.fetch_git_tree_pinned`).
|
|
13
|
+
- `store.py`, `index.py`: an installed edition under `$PRIMARYSOURCE_HOME/<collection>/<tree>/<commit>/`, and its
|
|
14
|
+
FTS5 index.
|
|
15
|
+
- `runtime.py`, `serving.py`, `cli.py`: read, outline, search, cite and docs, and the MCP tools and command line
|
|
16
|
+
that expose them under each collection's prefix.
|
|
17
|
+
|
|
18
|
+
No document text is kept in this repository: tests use invented text.
|
|
19
|
+
"""
|
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
"""A pinned source archive, streamed: verified, bounded, never extracted to disk.
|
|
2
|
+
|
|
3
|
+
`fetch_tree` opens a gzip or plain tar over HTTPS (or `read_tree` takes any binary stream, which is how a local
|
|
4
|
+
file and a test's in-memory archive come in), walks the members in stream mode with `tarfile` mode `r|`, and keeps
|
|
5
|
+
only the regular files `include` accepts, in memory.
|
|
6
|
+
|
|
7
|
+
What is pinned is **the selected files**, not the archive: `manifest_digest` is the sha256 of one line per kept
|
|
8
|
+
file, `<sha256 of its bytes> <path>`, sorted by path. It is checked before anything is returned, so a damaged,
|
|
9
|
+
truncated or substituted archive yields nothing. It is independent of how the archive was made, which matters:
|
|
10
|
+
a forge tarball, a `git archive` and a gitiles subtree archive of the same commit are three different byte
|
|
11
|
+
streams (gitiles stamps each header with the time of the request, so its tar differs from one fetch to the next,
|
|
12
|
+
measured), and the same selected files give the same manifest from all three. The digests of the `.tar` and the
|
|
13
|
+
`.gz` are reported but are not what a pin means.
|
|
14
|
+
|
|
15
|
+
Bounds, each a `LibError` naming what to do:
|
|
16
|
+
|
|
17
|
+
- compressed bytes, decompressed bytes (a gzip bomb), member count, bytes per kept member, bytes in all;
|
|
18
|
+
- a kept member whose header declares more than the per-member bound is refused before its data is read;
|
|
19
|
+
- links, devices and directories are skipped; a member name that is absolute, has a `..` part, a NUL or a
|
|
20
|
+
backslash is skipped and counted, never joined to a path;
|
|
21
|
+
- a redirect only to a host the source lists;
|
|
22
|
+
- one request per second (`Throttle`), whichever source is tried.
|
|
23
|
+
|
|
24
|
+
This is the interim form of the proposed core function `cl.fetch_git_tree_pinned`. Two things it would take from
|
|
25
|
+
sibling projects rather than own: from `archivekit` (D:\\code\\archivekit) the bounded stream reader for tar and
|
|
26
|
+
gzip members and its safe-name rules; from `gitobj` (D:\\code\\gitobj) a git tree hash, so a pin can name the
|
|
27
|
+
tree object (`git rev-parse <commit>^{tree}`) and the digest of the files read can be checked against it, which
|
|
28
|
+
holds whichever server or compression produced the bytes.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
import gzip
|
|
34
|
+
import hashlib
|
|
35
|
+
import http.client
|
|
36
|
+
import io
|
|
37
|
+
import re
|
|
38
|
+
import tarfile
|
|
39
|
+
import time
|
|
40
|
+
import urllib.error
|
|
41
|
+
import urllib.request
|
|
42
|
+
import zlib
|
|
43
|
+
from collections import Counter
|
|
44
|
+
from collections.abc import Callable, Mapping, Sequence
|
|
45
|
+
from dataclasses import dataclass, field
|
|
46
|
+
from typing import IO, Any, Protocol
|
|
47
|
+
from urllib.parse import urlsplit
|
|
48
|
+
|
|
49
|
+
from primarysource import __version__
|
|
50
|
+
from primarysource.errors import LibError
|
|
51
|
+
|
|
52
|
+
MIN_INTERVAL = 1.0
|
|
53
|
+
_CHUNK = 1 << 20
|
|
54
|
+
_USER_AGENT = f"primarysource/{__version__} (pinned archive reader)"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class Refused(LibError):
|
|
58
|
+
"""A failure that no other source can cure: a digest that is not the pin, a bound exceeded, an unlisted
|
|
59
|
+
redirect. `fetch_tree` raises it at once instead of trying the next mirror."""
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass(frozen=True, slots=True)
|
|
63
|
+
class Source:
|
|
64
|
+
"""Where a tree can be fetched. `strip` leading path components are removed from member names (1 for a
|
|
65
|
+
forge tarball's top directory, 0 for a subtree archive). With `subdir`,
|
|
66
|
+
only members below it are read and the prefix is removed, so one tree spec serves a whole-repository tarball
|
|
67
|
+
and a subtree archive alike. `hosts` are the hosts a redirect may lead to, besides the URL's own."""
|
|
68
|
+
|
|
69
|
+
url: str
|
|
70
|
+
strip: int = 1
|
|
71
|
+
hosts: tuple[str, ...] = ()
|
|
72
|
+
subdir: str = ""
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@dataclass(frozen=True, slots=True)
|
|
76
|
+
class Limits:
|
|
77
|
+
"""Ceilings on what a fetch will accept."""
|
|
78
|
+
|
|
79
|
+
compressed_bytes: int = 150_000_000
|
|
80
|
+
tar_bytes: int = 800_000_000
|
|
81
|
+
members: int = 100_000
|
|
82
|
+
member_bytes: int = 8_000_000
|
|
83
|
+
total_bytes: int = 400_000_000
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@dataclass(frozen=True, slots=True)
|
|
87
|
+
class Fetched:
|
|
88
|
+
"""The kept files and what was measured while reading them."""
|
|
89
|
+
|
|
90
|
+
files: dict[str, bytes]
|
|
91
|
+
files_sha256: str
|
|
92
|
+
tar_sha256: str
|
|
93
|
+
gz_sha256: str | None
|
|
94
|
+
members: int
|
|
95
|
+
tar_bytes: int
|
|
96
|
+
kept_bytes: int
|
|
97
|
+
skipped: dict[str, int] = field(default_factory=dict)
|
|
98
|
+
url: str = ""
|
|
99
|
+
pinned: bool = True
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class Throttle:
|
|
103
|
+
"""At most one call per `interval` seconds; the clock and the sleep are injectable for tests."""
|
|
104
|
+
|
|
105
|
+
def __init__(
|
|
106
|
+
self,
|
|
107
|
+
interval: float = MIN_INTERVAL,
|
|
108
|
+
clock: Callable[[], float] = time.monotonic,
|
|
109
|
+
sleep: Callable[[float], None] = time.sleep,
|
|
110
|
+
) -> None:
|
|
111
|
+
self.interval = interval
|
|
112
|
+
self._clock = clock
|
|
113
|
+
self._sleep = sleep
|
|
114
|
+
self._last: float | None = None
|
|
115
|
+
|
|
116
|
+
def wait(self) -> None:
|
|
117
|
+
now = self._clock()
|
|
118
|
+
if self._last is not None and now - self._last < self.interval:
|
|
119
|
+
self._sleep(self.interval - (now - self._last))
|
|
120
|
+
now = self._clock()
|
|
121
|
+
self._last = now
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
DEFAULT_THROTTLE = Throttle()
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class _Reader(Protocol):
|
|
128
|
+
def read(self, size: int = -1, /) -> bytes: ...
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class _Counting:
|
|
132
|
+
"""Wraps a stream: counts and hashes what passes through and stops at a ceiling."""
|
|
133
|
+
|
|
134
|
+
def __init__(self, stream: _Reader, ceiling: int, what: str, hint: str) -> None:
|
|
135
|
+
self._stream = stream
|
|
136
|
+
self._ceiling = ceiling
|
|
137
|
+
self._what = what
|
|
138
|
+
self._hint = hint
|
|
139
|
+
self.count = 0
|
|
140
|
+
self.tail = b""
|
|
141
|
+
self.digest = hashlib.sha256()
|
|
142
|
+
|
|
143
|
+
def read(self, size: int = -1, /) -> bytes:
|
|
144
|
+
chunk = self._stream.read(size if size > 0 else _CHUNK)
|
|
145
|
+
self.count += len(chunk)
|
|
146
|
+
if self.count > self._ceiling:
|
|
147
|
+
raise Refused(
|
|
148
|
+
f"{self._what} is over {self._ceiling:,} bytes; this is not the expected archive", self._hint
|
|
149
|
+
)
|
|
150
|
+
self.digest.update(chunk)
|
|
151
|
+
self.tail = (self.tail + chunk)[-1024:]
|
|
152
|
+
return chunk
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
class _Prefixed:
|
|
156
|
+
"""A stream with some bytes already read from it put back in front."""
|
|
157
|
+
|
|
158
|
+
def __init__(self, prefix: bytes, stream: _Reader) -> None:
|
|
159
|
+
self._prefix = prefix
|
|
160
|
+
self._stream = stream
|
|
161
|
+
|
|
162
|
+
def read(self, size: int = -1, /) -> bytes:
|
|
163
|
+
if not self._prefix:
|
|
164
|
+
return self._stream.read(size)
|
|
165
|
+
if size < 0:
|
|
166
|
+
head, self._prefix = self._prefix, b""
|
|
167
|
+
return head + self._stream.read()
|
|
168
|
+
head, self._prefix = self._prefix[:size], self._prefix[size:]
|
|
169
|
+
if len(head) < size:
|
|
170
|
+
head += self._stream.read(size - len(head))
|
|
171
|
+
return head
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _safe_name(name: str, strip: int, subdir: str = "") -> str | None:
|
|
175
|
+
"""The member's path inside the tree, or None when it is unsafe; an empty string when it is not below the
|
|
176
|
+
stripped prefix (or `subdir`)."""
|
|
177
|
+
if "\x00" in name or "\\" in name or name.startswith("/") or re.match(r"[A-Za-z]:", name):
|
|
178
|
+
return None
|
|
179
|
+
parts = [p for p in name.split("/") if p not in ("", ".")]
|
|
180
|
+
if ".." in parts:
|
|
181
|
+
return None
|
|
182
|
+
if len(parts) <= strip:
|
|
183
|
+
return ""
|
|
184
|
+
inside = parts[strip:]
|
|
185
|
+
if subdir:
|
|
186
|
+
head = [p for p in subdir.split("/") if p]
|
|
187
|
+
if inside[: len(head)] != head or len(inside) == len(head):
|
|
188
|
+
return ""
|
|
189
|
+
inside = inside[len(head) :]
|
|
190
|
+
return "/".join(inside)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def manifest_digest(files: Mapping[str, bytes]) -> str:
|
|
194
|
+
"""The pin of a set of files: sha256 over `<sha256 of the bytes> <path>` lines, sorted by path."""
|
|
195
|
+
lines = "".join(f"{hashlib.sha256(files[path]).hexdigest()} {path}\n" for path in sorted(files))
|
|
196
|
+
return hashlib.sha256(lines.encode("utf-8")).hexdigest()
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def read_tree(
|
|
200
|
+
stream: _Reader,
|
|
201
|
+
source: Source,
|
|
202
|
+
*,
|
|
203
|
+
include: Callable[[str], bool],
|
|
204
|
+
expect: str | None = None,
|
|
205
|
+
limits: Limits | None = None,
|
|
206
|
+
) -> Fetched:
|
|
207
|
+
"""Walk one archive stream. Raises `LibError` for a manifest digest that is not `expect` (when given), a bound
|
|
208
|
+
exceeded, or an archive that is truncated or damaged; returns nothing partial."""
|
|
209
|
+
limits = limits or Limits()
|
|
210
|
+
remedy = "fetch the archive again; if it keeps failing, the pin in the catalogue is wrong or the source changed"
|
|
211
|
+
raw = _Counting(stream, limits.compressed_bytes, "the archive", remedy)
|
|
212
|
+
magic = raw.read(2)
|
|
213
|
+
gz = magic == b"\x1f\x8b"
|
|
214
|
+
prefixed: Any = _Prefixed(magic, raw)
|
|
215
|
+
plain: Any = gzip.GzipFile(fileobj=prefixed, mode="rb") if gz else prefixed
|
|
216
|
+
tar_stream = _Counting(plain, limits.tar_bytes, "the decompressed archive", remedy)
|
|
217
|
+
files: dict[str, bytes] = {}
|
|
218
|
+
skipped: Counter[str] = Counter()
|
|
219
|
+
kept = 0
|
|
220
|
+
members = 0
|
|
221
|
+
try:
|
|
222
|
+
with tarfile.open(fileobj=tar_stream, mode="r|") as tar: # type: ignore[call-overload]
|
|
223
|
+
for member in tar:
|
|
224
|
+
members += 1
|
|
225
|
+
if members > limits.members:
|
|
226
|
+
raise Refused(f"the archive has more than {limits.members:,} members", remedy)
|
|
227
|
+
if not member.isreg():
|
|
228
|
+
skipped["link, directory or special file"] += 1
|
|
229
|
+
continue
|
|
230
|
+
name = _safe_name(member.name, source.strip, source.subdir)
|
|
231
|
+
if name is None:
|
|
232
|
+
skipped["unsafe member name"] += 1
|
|
233
|
+
continue
|
|
234
|
+
if not name or not include(name):
|
|
235
|
+
skipped["not selected"] += 1
|
|
236
|
+
continue
|
|
237
|
+
if member.size > limits.member_bytes:
|
|
238
|
+
raise Refused(
|
|
239
|
+
f"refusing {member.name!r}: it declares {member.size:,} bytes, over the "
|
|
240
|
+
f"{limits.member_bytes:,} "
|
|
241
|
+
"limit per file",
|
|
242
|
+
remedy,
|
|
243
|
+
)
|
|
244
|
+
handle = tar.extractfile(member)
|
|
245
|
+
data = handle.read(limits.member_bytes + 1) if handle is not None else b""
|
|
246
|
+
if len(data) > limits.member_bytes:
|
|
247
|
+
raise Refused(f"refusing {member.name!r}: over the per-file limit", remedy)
|
|
248
|
+
kept += len(data)
|
|
249
|
+
if kept > limits.total_bytes:
|
|
250
|
+
raise Refused(f"the selected files total more than {limits.total_bytes:,} bytes", remedy)
|
|
251
|
+
files[name] = data
|
|
252
|
+
while tar_stream.read(_CHUNK): # the trailing padding, and the gzip trailer's CRC check
|
|
253
|
+
pass
|
|
254
|
+
# tarfile treats a header cut short as the end of the archive, so check the end marker ourselves:
|
|
255
|
+
# a whole number of 512-byte blocks whose last block is zero
|
|
256
|
+
if tar_stream.count % 512 or tar_stream.tail[-512:] != bytes(512):
|
|
257
|
+
raise LibError("the archive is truncated (it does not end with the tar end-of-archive marker)", remedy)
|
|
258
|
+
except Refused:
|
|
259
|
+
raise
|
|
260
|
+
except (tarfile.TarError, EOFError, gzip.BadGzipFile, zlib.error, http.client.HTTPException, OSError) as exc:
|
|
261
|
+
raise LibError(f"the archive is truncated or damaged ({type(exc).__name__}: {exc})", remedy) from exc
|
|
262
|
+
digest = manifest_digest(files)
|
|
263
|
+
if expect is not None and digest != expect:
|
|
264
|
+
raise Refused(
|
|
265
|
+
f"the selected files have manifest sha256 {digest}, not the {expect} pinned for them",
|
|
266
|
+
"the commit is not the pinned one, the source changed, or the pin is wrong; check the commit, then "
|
|
267
|
+
"update the catalogue's pin",
|
|
268
|
+
)
|
|
269
|
+
return Fetched(
|
|
270
|
+
files=files,
|
|
271
|
+
files_sha256=digest,
|
|
272
|
+
tar_sha256=tar_stream.digest.hexdigest(),
|
|
273
|
+
gz_sha256=raw.digest.hexdigest() if gz else None,
|
|
274
|
+
members=members,
|
|
275
|
+
tar_bytes=tar_stream.count,
|
|
276
|
+
kept_bytes=kept,
|
|
277
|
+
skipped=dict(skipped),
|
|
278
|
+
url=source.url,
|
|
279
|
+
pinned=expect is not None,
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
class _Redirects(urllib.request.HTTPRedirectHandler):
|
|
284
|
+
def __init__(self, hosts: frozenset[str]) -> None:
|
|
285
|
+
self._hosts = hosts
|
|
286
|
+
|
|
287
|
+
def redirect_request(self, req: Any, fp: Any, code: int, msg: str, headers: Any, newurl: str) -> Any:
|
|
288
|
+
target = urlsplit(newurl)
|
|
289
|
+
if target.scheme != "https" or (target.hostname or "") not in self._hosts:
|
|
290
|
+
raise Refused(
|
|
291
|
+
f"refusing a redirect to {newurl!r}: its host is not one the catalogue lists for this source",
|
|
292
|
+
"add the host to the source's `hosts` only after checking it is the forge's own download host",
|
|
293
|
+
)
|
|
294
|
+
return super().redirect_request(req, fp, code, msg, headers, newurl)
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
Opener = Callable[[urllib.request.Request], IO[bytes]]
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _default_opener(source: Source) -> Opener:
|
|
301
|
+
hosts = frozenset({urlsplit(source.url).hostname or "", *source.hosts})
|
|
302
|
+
return urllib.request.build_opener(_Redirects(hosts)).open
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def fetch_tree(
|
|
306
|
+
sources: Sequence[Source],
|
|
307
|
+
*,
|
|
308
|
+
include: Callable[[str], bool],
|
|
309
|
+
expect: str | None = None,
|
|
310
|
+
limits: Limits | None = None,
|
|
311
|
+
opener: Opener | None = None,
|
|
312
|
+
throttle: Throttle | None = None,
|
|
313
|
+
timeout: float = 60.0,
|
|
314
|
+
) -> Fetched:
|
|
315
|
+
"""Try each source in order; the first that reads and verifies wins. A digest mismatch is raised at once and
|
|
316
|
+
does not fall through to the next mirror (a mirror that disagrees is a finding, not a retry)."""
|
|
317
|
+
if not sources:
|
|
318
|
+
raise LibError("no source is listed for this edition", "add one to the catalogue")
|
|
319
|
+
wait = throttle or DEFAULT_THROTTLE
|
|
320
|
+
failures: list[str] = []
|
|
321
|
+
for source in sources:
|
|
322
|
+
if not source.url.startswith("https://"):
|
|
323
|
+
raise Refused(f"refusing {source.url!r}: only https sources are read", "")
|
|
324
|
+
wait.wait()
|
|
325
|
+
request = urllib.request.Request(source.url, headers={"User-Agent": _USER_AGENT}) # noqa: S310
|
|
326
|
+
try:
|
|
327
|
+
handle = (opener or _default_opener(source))(request)
|
|
328
|
+
with handle:
|
|
329
|
+
return read_tree(handle, source, include=include, expect=expect, limits=limits)
|
|
330
|
+
except Refused:
|
|
331
|
+
raise
|
|
332
|
+
except LibError as exc:
|
|
333
|
+
failures.append(f"{source.url}: {exc.message}")
|
|
334
|
+
except (urllib.error.URLError, TimeoutError, OSError, http.client.HTTPException) as exc:
|
|
335
|
+
failures.append(f"{source.url}: {exc}")
|
|
336
|
+
raise LibError(
|
|
337
|
+
"no source could be read:\n " + "\n ".join(failures), "check the network, or pass a downloaded archive"
|
|
338
|
+
)
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def read_local(
|
|
342
|
+
path: str,
|
|
343
|
+
source: Source,
|
|
344
|
+
*,
|
|
345
|
+
include: Callable[[str], bool],
|
|
346
|
+
expect: str | None = None,
|
|
347
|
+
limits: Limits | None = None,
|
|
348
|
+
) -> Fetched:
|
|
349
|
+
"""The same walk over a file the user downloaded."""
|
|
350
|
+
try:
|
|
351
|
+
with open(path, "rb") as stream: # noqa: PTH123
|
|
352
|
+
return read_tree(io.BufferedReader(stream), source, include=include, expect=expect, limits=limits)
|
|
353
|
+
except FileNotFoundError as exc:
|
|
354
|
+
raise LibError(
|
|
355
|
+
f"no file at {path}", "download the archive first, or omit --archive to fetch the pinned one"
|
|
356
|
+
) from exc
|