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,197 @@
|
|
|
1
|
+
"""The offline catalogue snapshot: what could be installed, answerable with no network.
|
|
2
|
+
|
|
3
|
+
`refresh()` is the only function here that touches the network (through `catalog`'s own `_get`, so the https-only
|
|
4
|
+
rule and the size limit apply). It pages through the English OPDS feed and writes `$ZIMLORE_HOME/catalog.json`
|
|
5
|
+
atomically. Everything else reads that file. A missing snapshot is an error naming
|
|
6
|
+
`primarysource zim catalog --refresh`; a
|
|
7
|
+
corrupt or wrong-schema one is also an error, never an empty snapshot, because "the catalogue has nothing called
|
|
8
|
+
flask" and "the snapshot could not be read" send an agent to opposite conclusions.
|
|
9
|
+
|
|
10
|
+
Facts measured against the live feed on 2026-11-07: `count=5000&lang=eng` returned all 1,309 entries in one 1.5 MB
|
|
11
|
+
document, but `count` is honoured as a page size and `start` as an offset (`itemsPerPage` and `startIndex` echo
|
|
12
|
+
them), so `refresh` pages in 500s rather than relying on one reply that a server may cap.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
import os
|
|
19
|
+
import re
|
|
20
|
+
import urllib.parse
|
|
21
|
+
from datetime import UTC, datetime
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Any
|
|
24
|
+
|
|
25
|
+
from . import catalog, library
|
|
26
|
+
from .errors import ZimloreError
|
|
27
|
+
|
|
28
|
+
SCHEMA = 1
|
|
29
|
+
FILE_NAME = "catalog.json"
|
|
30
|
+
PAGE_SIZE = 500
|
|
31
|
+
MAX_PAGES = 100
|
|
32
|
+
HINT = "primarysource zim catalog --refresh"
|
|
33
|
+
_SAFE_NAME = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]*")
|
|
34
|
+
_FIELDS = ("name", "title", "date", "language", "summary", "creator", "articles", "approx_bytes", "url", "tags")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def path() -> Path:
|
|
38
|
+
return library.home() / FILE_NAME
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _stamp(when: datetime) -> str:
|
|
42
|
+
return when.astimezone(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _entry_dict(entry: catalog.CatalogEntry) -> dict[str, Any]:
|
|
46
|
+
return {
|
|
47
|
+
"name": entry.name,
|
|
48
|
+
"title": entry.title,
|
|
49
|
+
"date": entry.date,
|
|
50
|
+
"language": entry.language,
|
|
51
|
+
"summary": entry.summary,
|
|
52
|
+
"creator": entry.creator,
|
|
53
|
+
"articles": entry.articles,
|
|
54
|
+
"approx_bytes": entry.approx_bytes,
|
|
55
|
+
"url": entry.url,
|
|
56
|
+
"tags": list(entry.tags),
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def refresh(*, language: str = "eng", base: str = "", opener: catalog.Opener | None = None) -> dict[str, Any]:
|
|
61
|
+
"""Fetch the whole feed for `language` and replace the snapshot. Network. The old snapshot is untouched
|
|
62
|
+
unless every page was read and at least one entry came back."""
|
|
63
|
+
base = base or catalog.CATALOG_URL
|
|
64
|
+
entries: list[catalog.CatalogEntry] = []
|
|
65
|
+
total = 0
|
|
66
|
+
start = 0
|
|
67
|
+
pages = 0
|
|
68
|
+
while pages < MAX_PAGES:
|
|
69
|
+
params = {"count": str(PAGE_SIZE), "start": str(start)}
|
|
70
|
+
if language:
|
|
71
|
+
params["lang"] = language
|
|
72
|
+
page, total = catalog.parse_feed(catalog._get(f"{base}?{urllib.parse.urlencode(params)}", opener))
|
|
73
|
+
pages += 1
|
|
74
|
+
entries.extend(e for e in page if _SAFE_NAME.fullmatch(e.name))
|
|
75
|
+
start += PAGE_SIZE
|
|
76
|
+
if start >= total:
|
|
77
|
+
break
|
|
78
|
+
else:
|
|
79
|
+
raise ZimloreError(f"the catalogue still had entries after {MAX_PAGES} pages; not writing a snapshot")
|
|
80
|
+
if not entries:
|
|
81
|
+
raise ZimloreError(
|
|
82
|
+
"the catalogue returned no ZIM entries; the existing snapshot (if any) was kept",
|
|
83
|
+
"try again later, or check the language code",
|
|
84
|
+
)
|
|
85
|
+
fetched_at = _stamp(datetime.now(UTC))
|
|
86
|
+
document = {
|
|
87
|
+
"schema": SCHEMA,
|
|
88
|
+
"fetched_at": fetched_at,
|
|
89
|
+
"language": language,
|
|
90
|
+
"total_results": total,
|
|
91
|
+
"entries": [_entry_dict(e) for e in entries],
|
|
92
|
+
}
|
|
93
|
+
target = path()
|
|
94
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
95
|
+
temporary = target.with_name(target.name + ".tmp")
|
|
96
|
+
temporary.write_text(json.dumps(document, ensure_ascii=False, separators=(",", ":")), encoding="utf-8")
|
|
97
|
+
os.replace(temporary, target)
|
|
98
|
+
return {
|
|
99
|
+
"path": str(target),
|
|
100
|
+
"entries": len(entries),
|
|
101
|
+
"total_results": total,
|
|
102
|
+
"pages": pages,
|
|
103
|
+
"fetched_at": fetched_at,
|
|
104
|
+
"bytes": target.stat().st_size,
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _missing() -> ZimloreError:
|
|
109
|
+
return ZimloreError(f"no catalogue snapshot at {path()}", HINT)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _corrupt(why: str) -> ZimloreError:
|
|
113
|
+
return ZimloreError(f"the catalogue snapshot {path()} cannot be used ({why})", HINT)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _entry_from(raw: object) -> catalog.CatalogEntry:
|
|
117
|
+
if not isinstance(raw, dict) or any(key not in raw for key in _FIELDS):
|
|
118
|
+
raise ValueError("an entry lacks required fields")
|
|
119
|
+
strings = ("name", "title", "date", "language", "summary", "creator", "url")
|
|
120
|
+
ints = ("articles", "approx_bytes")
|
|
121
|
+
if not all(isinstance(raw[k], str) for k in strings) or not all(
|
|
122
|
+
isinstance(raw[k], int) and not isinstance(raw[k], bool) for k in ints
|
|
123
|
+
):
|
|
124
|
+
raise ValueError("an entry has a field of the wrong type")
|
|
125
|
+
tags = raw["tags"]
|
|
126
|
+
if not isinstance(tags, list) or not all(isinstance(t, str) for t in tags):
|
|
127
|
+
raise ValueError("an entry has malformed tags")
|
|
128
|
+
if not _SAFE_NAME.fullmatch(raw["name"]):
|
|
129
|
+
raise ValueError(f"an entry has an unusable name {raw['name']!r}")
|
|
130
|
+
return catalog.CatalogEntry(**{**{k: raw[k] for k in _FIELDS}, "tags": tuple(tags)})
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def load(today: datetime | None = None) -> tuple[list[catalog.CatalogEntry], str, int]:
|
|
134
|
+
"""(entries, fetched_at, age_days). Offline. Raises `ZimloreError` (hint: `primarysource zim catalog
|
|
135
|
+
--refresh`) when
|
|
136
|
+
there is no snapshot or it cannot be used."""
|
|
137
|
+
target = path()
|
|
138
|
+
if not target.is_file():
|
|
139
|
+
raise _missing()
|
|
140
|
+
try:
|
|
141
|
+
raw = json.loads(target.read_text(encoding="utf-8"))
|
|
142
|
+
except (OSError, ValueError) as error:
|
|
143
|
+
raise _corrupt(str(error)) from error
|
|
144
|
+
if not isinstance(raw, dict) or raw.get("schema") != SCHEMA:
|
|
145
|
+
raise _corrupt(
|
|
146
|
+
f"schema {raw.get('schema') if isinstance(raw, dict) else type(raw).__name__}, expected {SCHEMA}"
|
|
147
|
+
)
|
|
148
|
+
listed = raw.get("entries")
|
|
149
|
+
fetched = raw.get("fetched_at")
|
|
150
|
+
if not isinstance(listed, list) or not isinstance(fetched, str):
|
|
151
|
+
raise _corrupt("no entries list or no fetched_at")
|
|
152
|
+
try:
|
|
153
|
+
when = datetime.strptime(fetched, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=UTC)
|
|
154
|
+
entries = [_entry_from(item) for item in listed]
|
|
155
|
+
except ValueError as error:
|
|
156
|
+
raise _corrupt(str(error)) from error
|
|
157
|
+
age = max(0, ((today or datetime.now(UTC)) - when).days)
|
|
158
|
+
return entries, fetched, age
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def available(
|
|
162
|
+
query: str, limit: int = 8, installed: dict[str, str] | None = None, today: datetime | None = None
|
|
163
|
+
) -> dict[str, Any]:
|
|
164
|
+
"""Snapshot entries ranked against `query` with the same ranking as `library.rank` (exact beats all-words
|
|
165
|
+
beats prefix beats substring beats fuzzy), newest edition of each name. Offline."""
|
|
166
|
+
wanted = library.norm(query)
|
|
167
|
+
if not wanted:
|
|
168
|
+
raise ZimloreError("empty query", "give a few words, e.g. primarysource zim available flask")
|
|
169
|
+
entries, fetched_at, age = load(today)
|
|
170
|
+
have = installed or {}
|
|
171
|
+
scored: list[tuple[float, str, catalog.CatalogEntry, str]] = []
|
|
172
|
+
for entry in catalog.latest(entries):
|
|
173
|
+
score, why = library.score_aliases(wanted, library.name_aliases(entry.name, entry.title))
|
|
174
|
+
if score:
|
|
175
|
+
scored.append((score, entry.name, entry, why))
|
|
176
|
+
scored.sort(key=lambda item: (-item[0], item[1]))
|
|
177
|
+
rows = [
|
|
178
|
+
{
|
|
179
|
+
"name": entry.name,
|
|
180
|
+
"title": entry.title,
|
|
181
|
+
"date": entry.date,
|
|
182
|
+
"approx_mb": round(entry.approx_bytes / 1e6, 1),
|
|
183
|
+
"articles": entry.articles,
|
|
184
|
+
"install": f"primarysource zim get {entry.name}",
|
|
185
|
+
"installed": entry.name in have,
|
|
186
|
+
"installed_edition": have.get(entry.name),
|
|
187
|
+
"match": why,
|
|
188
|
+
"snapshot_age_days": age,
|
|
189
|
+
}
|
|
190
|
+
for _, _, entry, why in scored[: max(1, limit)]
|
|
191
|
+
]
|
|
192
|
+
return {
|
|
193
|
+
"query": query,
|
|
194
|
+
"available": rows,
|
|
195
|
+
"matches": len(scored),
|
|
196
|
+
"snapshot": {"fetched_at": fetched_at, "age_days": age, "entries": len(entries), "offline": True},
|
|
197
|
+
}
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
"""Make the installed library match a manifest: plan first, then (unless a dry run) download what is missing.
|
|
2
|
+
|
|
3
|
+
A manifest is TOML and names catalogue entries only:
|
|
4
|
+
|
|
5
|
+
libraries = ["devdocs_en_python", "devdocs_en_rust"]
|
|
6
|
+
groups = ["web-testing"] # named sets from zimlore.groups
|
|
7
|
+
max_age_days = 365 # flag installed editions older than this
|
|
8
|
+
budget = "2GB" # the library may not grow past this on disk
|
|
9
|
+
|
|
10
|
+
It may be committed to a project: it can name nothing that executes, because every entry is validated as a bare
|
|
11
|
+
catalogue name (no scheme, no slash, no colon) and unknown keys are errors. All network goes through `catalog.py`.
|
|
12
|
+
Nothing here deletes: an installed library the manifest does not name is reported as an extra and left alone, and
|
|
13
|
+
an update is a new dated file beside the old edition, exactly as `primarysource zim get` stages it.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import os
|
|
19
|
+
import re
|
|
20
|
+
import tomllib
|
|
21
|
+
from collections.abc import Callable
|
|
22
|
+
from dataclasses import dataclass, field
|
|
23
|
+
from datetime import UTC, date, datetime
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
from typing import Any
|
|
26
|
+
|
|
27
|
+
from . import catalog, groups, library
|
|
28
|
+
from .errors import ZimloreError
|
|
29
|
+
|
|
30
|
+
MANIFEST_NAME = ".zimlore.toml"
|
|
31
|
+
HOME_MANIFEST = "libraries.toml"
|
|
32
|
+
KEYS = {"libraries", "groups", "max_age_days", "budget"}
|
|
33
|
+
NAME_RE = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]{0,127}")
|
|
34
|
+
UNITS = {"B": 1, "KB": 10**3, "MB": 10**6, "GB": 10**9, "TB": 10**12}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True)
|
|
38
|
+
class Manifest:
|
|
39
|
+
path: Path | None = None
|
|
40
|
+
libraries: tuple[str, ...] = ()
|
|
41
|
+
groups: tuple[str, ...] = ()
|
|
42
|
+
max_age_days: int | None = None
|
|
43
|
+
budget: int | None = None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def check_name(name: object, where: str) -> str:
|
|
47
|
+
"""A catalogue name and nothing else. `https://x/y.zim`, `../a`, `C:\\a` and `a.zim` are all refused."""
|
|
48
|
+
if not isinstance(name, str) or not NAME_RE.fullmatch(name) or name.endswith(".zim"):
|
|
49
|
+
raise ZimloreError(
|
|
50
|
+
f"{where}: {name!r} is not a catalogue name",
|
|
51
|
+
"a manifest names catalogue entries (primarysource zim catalog <words> finds them), never URLs or paths",
|
|
52
|
+
)
|
|
53
|
+
return name
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def parse_budget(value: object) -> int:
|
|
57
|
+
"""`"2GB"`, `"500 MB"`, `"1.5GB"` (decimal units, as the CLI prints sizes) or an integer number of bytes."""
|
|
58
|
+
if isinstance(value, bool):
|
|
59
|
+
raise ZimloreError(f"budget {value!r} is not a size", 'write it as "2GB" or a number of bytes')
|
|
60
|
+
if isinstance(value, int) and value > 0:
|
|
61
|
+
return value
|
|
62
|
+
match = (
|
|
63
|
+
re.fullmatch(r"\s*(\d+(?:\.\d+)?)\s*([KMGT]?B)?\s*", value, re.IGNORECASE) if isinstance(value, str) else None
|
|
64
|
+
)
|
|
65
|
+
if not match or float(match.group(1)) <= 0:
|
|
66
|
+
raise ZimloreError(f"budget {value!r} is not a size", 'write it as "2GB", "500MB" or a number of bytes')
|
|
67
|
+
return int(float(match.group(1)) * UNITS[(match.group(2) or "B").upper()])
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def find_manifest(explicit: str = "", cwd: Path | None = None) -> Path | None:
|
|
71
|
+
"""--manifest, else `./.zimlore.toml`, else `$ZIMLORE_HOME/libraries.toml`; None when there is none. An
|
|
72
|
+
explicit path that does not exist is an error rather than a silent fall-through to another manifest."""
|
|
73
|
+
if explicit:
|
|
74
|
+
path = Path(explicit).expanduser()
|
|
75
|
+
if not path.is_file():
|
|
76
|
+
raise ZimloreError(f"manifest {path} does not exist", "check the --manifest path")
|
|
77
|
+
return path
|
|
78
|
+
for candidate in ((cwd or Path.cwd()) / MANIFEST_NAME, library.home() / HOME_MANIFEST):
|
|
79
|
+
if candidate.is_file():
|
|
80
|
+
return candidate
|
|
81
|
+
return None
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def load_manifest(path: Path) -> Manifest:
|
|
85
|
+
try:
|
|
86
|
+
raw = tomllib.loads(path.read_text(encoding="utf-8"))
|
|
87
|
+
except (OSError, tomllib.TOMLDecodeError) as error:
|
|
88
|
+
raise ZimloreError(f"{path} is not a readable TOML manifest ({error})", f"fix or delete {path}") from error
|
|
89
|
+
unknown = sorted(set(raw) - KEYS)
|
|
90
|
+
if unknown:
|
|
91
|
+
raise ZimloreError(f"{path}: unknown key(s) {unknown}", f"a manifest has only {sorted(KEYS)}")
|
|
92
|
+
libs, named = raw.get("libraries", []), raw.get("groups", [])
|
|
93
|
+
if not isinstance(libs, list) or not isinstance(named, list):
|
|
94
|
+
raise ZimloreError(f"{path}: libraries and groups must be lists of names")
|
|
95
|
+
known = sorted(groups.GROUPS)
|
|
96
|
+
for group in named:
|
|
97
|
+
if group not in groups.GROUPS:
|
|
98
|
+
raise ZimloreError(f"{path}: unknown group {group!r}", f"the groups are {known}")
|
|
99
|
+
age = raw.get("max_age_days")
|
|
100
|
+
if age is not None and (isinstance(age, bool) or not isinstance(age, int) or age < 1):
|
|
101
|
+
raise ZimloreError(f"{path}: max_age_days must be a positive whole number")
|
|
102
|
+
budget = parse_budget(raw["budget"]) if "budget" in raw else None
|
|
103
|
+
return Manifest(
|
|
104
|
+
path,
|
|
105
|
+
tuple(check_name(name, f"{path} libraries") for name in libs),
|
|
106
|
+
tuple(named),
|
|
107
|
+
age,
|
|
108
|
+
budget,
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@dataclass
|
|
113
|
+
class Item:
|
|
114
|
+
name: str
|
|
115
|
+
action: str # install | update | current | outdated | unlisted | blocked | unavailable
|
|
116
|
+
installed: str | None = None
|
|
117
|
+
available: str | None = None
|
|
118
|
+
approx_bytes: int = 0
|
|
119
|
+
over_max_age: bool = False
|
|
120
|
+
note: str = ""
|
|
121
|
+
file: str = ""
|
|
122
|
+
entry: catalog.CatalogEntry | None = field(default=None, repr=False)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
@dataclass
|
|
126
|
+
class Plan:
|
|
127
|
+
manifest: Path | None
|
|
128
|
+
items: list[Item]
|
|
129
|
+
extras: list[dict[str, Any]]
|
|
130
|
+
download_bytes: int
|
|
131
|
+
installed_bytes: int
|
|
132
|
+
budget: int | None
|
|
133
|
+
max_age_days: int | None
|
|
134
|
+
|
|
135
|
+
@property
|
|
136
|
+
def over_budget(self) -> bool:
|
|
137
|
+
return self.budget is not None and self.installed_bytes + self.download_bytes > self.budget
|
|
138
|
+
|
|
139
|
+
@property
|
|
140
|
+
def to_fetch(self) -> list[Item]:
|
|
141
|
+
return [i for i in self.items if i.action in {"install", "update"}]
|
|
142
|
+
|
|
143
|
+
@property
|
|
144
|
+
def problems(self) -> list[Item]:
|
|
145
|
+
return [i for i in self.items if i.action in {"blocked", "unavailable"}]
|
|
146
|
+
|
|
147
|
+
def as_dict(self) -> dict[str, Any]:
|
|
148
|
+
return {
|
|
149
|
+
"manifest": str(self.manifest) if self.manifest else None,
|
|
150
|
+
"items": [{k: v for k, v in i.__dict__.items() if k != "entry"} for i in self.items],
|
|
151
|
+
"extras": self.extras,
|
|
152
|
+
"download_bytes_approx": self.download_bytes,
|
|
153
|
+
"download_note": "approximate: the catalogue's rounded lengths; the .meta4 has the exact size",
|
|
154
|
+
"installed_bytes": self.installed_bytes,
|
|
155
|
+
"budget": self.budget,
|
|
156
|
+
"over_budget": self.over_budget,
|
|
157
|
+
"max_age_days": self.max_age_days,
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def wanted_names(libraries: tuple[str, ...], named_groups: tuple[str, ...]) -> list[str]:
|
|
162
|
+
"""Manifest libraries first, then each group's members, each name once."""
|
|
163
|
+
names: dict[str, None] = dict.fromkeys(libraries)
|
|
164
|
+
for group in named_groups:
|
|
165
|
+
if group not in groups.GROUPS:
|
|
166
|
+
raise ZimloreError(f"unknown group {group!r}", f"the groups are {sorted(groups.GROUPS)}")
|
|
167
|
+
names.update(dict.fromkeys(groups.GROUPS[group]))
|
|
168
|
+
return list(names)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def make_plan(
|
|
172
|
+
manifest: Manifest,
|
|
173
|
+
extra_groups: tuple[str, ...] = (),
|
|
174
|
+
*,
|
|
175
|
+
update: bool = False,
|
|
176
|
+
today: date | None = None,
|
|
177
|
+
) -> Plan:
|
|
178
|
+
"""Compare the manifest with what is installed and with the catalogue. Reads the catalogue (one query per
|
|
179
|
+
name, for sizes and dates) and the local library; downloads and writes nothing."""
|
|
180
|
+
names = wanted_names(manifest.libraries, (*manifest.groups, *extra_groups))
|
|
181
|
+
today = today or datetime.now(UTC).date()
|
|
182
|
+
scanned = library.scan()
|
|
183
|
+
directory = library.zim_dirs()[0]
|
|
184
|
+
items: list[Item] = []
|
|
185
|
+
for name in names:
|
|
186
|
+
mine = [b for b in scanned.books if b.name == name]
|
|
187
|
+
newest_book = library.newest(mine)
|
|
188
|
+
have = newest_book.date if newest_book else ""
|
|
189
|
+
newest = catalog.latest(catalog.editions_of(name))
|
|
190
|
+
item = Item(name, "unavailable", installed=have or None)
|
|
191
|
+
if newest_book is not None:
|
|
192
|
+
age = newest_book.age_days(today)
|
|
193
|
+
item.over_max_age = bool(manifest.max_age_days and age is not None and age > manifest.max_age_days)
|
|
194
|
+
if not newest:
|
|
195
|
+
item.note = "the catalogue has no entry with this name"
|
|
196
|
+
if mine:
|
|
197
|
+
item.action, item.note = "unlisted", item.note + "; the installed edition is kept"
|
|
198
|
+
items.append(item)
|
|
199
|
+
continue
|
|
200
|
+
entry = newest[0]
|
|
201
|
+
item.available, item.entry, item.file = entry.date, entry, catalog.target_name(entry)
|
|
202
|
+
if not mine:
|
|
203
|
+
item.action = "install"
|
|
204
|
+
elif (newer := library.is_newer(entry.date, have)) is not False:
|
|
205
|
+
item.action = "update" if update else "outdated"
|
|
206
|
+
item.note = "" if update else "newer edition available; --update stages it beside the installed one"
|
|
207
|
+
if newer is None:
|
|
208
|
+
item.note = "the installed edition has no usable Date, so it may be older; " + item.note
|
|
209
|
+
else:
|
|
210
|
+
item.action = "current"
|
|
211
|
+
if item.action in {"install", "update"}:
|
|
212
|
+
item.approx_bytes = entry.approx_bytes
|
|
213
|
+
if os.path.lexists(directory / item.file):
|
|
214
|
+
item.action, item.approx_bytes = "blocked", 0
|
|
215
|
+
item.note = (
|
|
216
|
+
f"{item.file} is already in {directory} but is not a readable edition of {name}; move it aside"
|
|
217
|
+
)
|
|
218
|
+
items.append(item)
|
|
219
|
+
present = {b.name for b in scanned.books}
|
|
220
|
+
extras = [
|
|
221
|
+
{
|
|
222
|
+
"name": n,
|
|
223
|
+
"editions": sum(b.name == n for b in scanned.books),
|
|
224
|
+
"bytes": sum(b.size for b in scanned.books if b.name == n),
|
|
225
|
+
}
|
|
226
|
+
for n in sorted(present - set(names))
|
|
227
|
+
]
|
|
228
|
+
return Plan(
|
|
229
|
+
manifest.path,
|
|
230
|
+
items,
|
|
231
|
+
extras,
|
|
232
|
+
sum(i.approx_bytes for i in items),
|
|
233
|
+
sum(b.size for b in scanned.books),
|
|
234
|
+
manifest.budget,
|
|
235
|
+
manifest.max_age_days,
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def execute(plan: Plan, *, progress: Callable[[str, int, int], None] | None = None) -> list[dict[str, Any]]:
|
|
240
|
+
"""Download what the plan says to fetch. Refuses (before the first byte) when the plan exceeds the budget.
|
|
241
|
+
One failed library does not stop the others: each outcome is reported, and the caller exits non-zero."""
|
|
242
|
+
if plan.over_budget:
|
|
243
|
+
raise ZimloreError(
|
|
244
|
+
f"the sync would grow the library to about {(plan.installed_bytes + plan.download_bytes) / 1e6:.1f} MB "
|
|
245
|
+
f"({plan.installed_bytes / 1e6:.1f} MB installed + about {plan.download_bytes / 1e6:.1f} MB new), "
|
|
246
|
+
f"over the {(plan.budget or 0) / 1e6:.1f} MB budget; nothing was downloaded",
|
|
247
|
+
"raise `budget` in the manifest, drop a library or group, or free space: primarysource zim prune --yes",
|
|
248
|
+
)
|
|
249
|
+
directory = library.zim_dirs()[0]
|
|
250
|
+
library.close_all()
|
|
251
|
+
results: list[dict[str, Any]] = []
|
|
252
|
+
for item in plan.to_fetch:
|
|
253
|
+
if item.entry is None:
|
|
254
|
+
continue
|
|
255
|
+
name = item.name
|
|
256
|
+
|
|
257
|
+
def report(done: int, total: int, name: str = name) -> None:
|
|
258
|
+
if progress:
|
|
259
|
+
progress(name, done, total)
|
|
260
|
+
|
|
261
|
+
try:
|
|
262
|
+
path = catalog.download(item.entry, directory, progress=report, added_by="sync")
|
|
263
|
+
except ZimloreError as error:
|
|
264
|
+
results.append(
|
|
265
|
+
{"name": name, "action": item.action, "ok": False, "error": error.message, "hint": error.cli_hint}
|
|
266
|
+
)
|
|
267
|
+
continue
|
|
268
|
+
results.append({"name": name, "action": item.action, "ok": True, "file": str(path)})
|
|
269
|
+
return results
|