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,668 @@
|
|
|
1
|
+
"""The local library: which ZIM files are installed, what they are, and which one a name means.
|
|
2
|
+
|
|
3
|
+
The home directory is the collection's home (`home.collection_home("zim", "zimlore")`): `$ZIMLORE_HOME`, then
|
|
4
|
+
`$PRIMARYSOURCE_ZIM_HOME`, then `$PRIMARYSOURCE_HOME/zim`, then an existing `~/.zimlore`, then the per-user
|
|
5
|
+
default. The ZIM files live in its `zim/` subdirectory. `PRIMARYSOURCE_ZIM_PATH` (legacy: `ZIMLORE_ZIM_PATH`; a
|
|
6
|
+
list separated by the platform's path separator) replaces that subdirectory, so an existing Kiwix library can be
|
|
7
|
+
used where it already is instead of copied. Nothing in this module writes to the
|
|
8
|
+
repository, and the repository's `.gitignore` names every way a ZIM could be spelled.
|
|
9
|
+
|
|
10
|
+
A ZIM identifies itself by its `Name` metadata (`devdocs_en_flask`), not by its file name, and carries a `Date`.
|
|
11
|
+
Two ZIMs with one `Name` and different dates are two *editions* of one library; resolution picks the newest and
|
|
12
|
+
reports the others rather than choosing silently.
|
|
13
|
+
|
|
14
|
+
Producers that build documentation per package version name each build differently (`golang_..._cobra_v1.8.1`),
|
|
15
|
+
so a version is NOT an edition: those are separate Names. They share a *family* (the Name without its trailing
|
|
16
|
+
version token, or the `purl:` tag without its version) and carry a `version`. A query naming a family resolves to
|
|
17
|
+
its newest version by `version_key` order; a query naming a version selects that one (`select`).
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import datetime as dt
|
|
23
|
+
import difflib
|
|
24
|
+
import json
|
|
25
|
+
import os
|
|
26
|
+
import re
|
|
27
|
+
from dataclasses import dataclass, field
|
|
28
|
+
from datetime import UTC, datetime
|
|
29
|
+
from functools import cached_property
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
from typing import Any, NamedTuple
|
|
32
|
+
|
|
33
|
+
from primarysource import home as _homes
|
|
34
|
+
|
|
35
|
+
from . import _libzim, purl
|
|
36
|
+
from .errors import LibraryNotFound, ZimloreError
|
|
37
|
+
|
|
38
|
+
HOME_ENV = "ZIMLORE_HOME"
|
|
39
|
+
"""The setting from before the move into primarysource; it still wins (`home.collection_home`)."""
|
|
40
|
+
PATH_ENV = "PRIMARYSOURCE_ZIM_PATH"
|
|
41
|
+
LEGACY_PATH_ENV = "ZIMLORE_ZIM_PATH"
|
|
42
|
+
STALE_DAYS = 365
|
|
43
|
+
"""A ZIM older than this is reported `stale`. The number is a convention, not a measurement: the response always
|
|
44
|
+
carries the date and the age in days so a caller can apply its own threshold."""
|
|
45
|
+
|
|
46
|
+
_DATE_SUFFIX = re.compile(r"_\d{4}-\d{2}$")
|
|
47
|
+
_VERSION_TOKEN = re.compile(
|
|
48
|
+
r"^(?P<family>.*[A-Za-z].*?)_(?P<version>[vV]?\d+(?:\.[0-9A-Za-z]+)*(?:-[0-9A-Za-z.]+)?)$"
|
|
49
|
+
)
|
|
50
|
+
_BARE_YEAR = re.compile(r"^(19|20)\d\d$")
|
|
51
|
+
_DATE_TEXT = re.compile(r"(\d{4})-(\d{1,2})-(\d{1,2})(?![0-9])")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def parse_date(text: str) -> dt.date | None:
|
|
55
|
+
"""A ZIM `Date` (or a catalogue date) as a calendar date, or None when it is not one. `2026-9-15` is read as
|
|
56
|
+
September 15th, a trailing time is ignored, and nothing else is guessed: free text is never compared as a
|
|
57
|
+
string, because `"2026-9-15" > "2026-10-01"` and `"" < everything` would reorder editions."""
|
|
58
|
+
match = _DATE_TEXT.match(text.strip())
|
|
59
|
+
if match is None:
|
|
60
|
+
return None
|
|
61
|
+
try:
|
|
62
|
+
return dt.date(int(match.group(1)), int(match.group(2)), int(match.group(3)))
|
|
63
|
+
except ValueError:
|
|
64
|
+
return None
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def date_key(text: str) -> dt.date:
|
|
68
|
+
"""A sort key for display and 'which is newest' choices: undated sorts oldest. Nothing is ever DELETED on the
|
|
69
|
+
strength of this; `manage.prune` refuses a group with an unparseable date instead."""
|
|
70
|
+
return parse_date(text) or dt.date.min
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def is_newer(candidate: str, installed: str) -> bool | None:
|
|
74
|
+
"""Is `candidate` a later date than `installed`? None when either is not a date: unknown is not 'no'."""
|
|
75
|
+
a, b = parse_date(candidate), parse_date(installed)
|
|
76
|
+
return None if a is None or b is None else a > b
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def split_name(name: str) -> tuple[str, str | None]:
|
|
80
|
+
"""A ZIM `Name` as (family, version): the trailing `_v?<digits>(.<alnum>)*(-<alnum>)?` token is the version and
|
|
81
|
+
the rest is the family; None when the Name ends in no such token. A `_YYYY-MM` date suffix is an edition
|
|
82
|
+
marker and is dropped first (it is not a version); a bare four-digit year and a bare integer of more than two
|
|
83
|
+
digits (`wikipedia_en_100`) are not versions either, because a count or a year in a name is not a release."""
|
|
84
|
+
base = _DATE_SUFFIX.sub("", name)
|
|
85
|
+
match = _VERSION_TOKEN.match(base)
|
|
86
|
+
if match:
|
|
87
|
+
version = match.group("version")
|
|
88
|
+
bare = version.isdigit()
|
|
89
|
+
if not (bare and (_BARE_YEAR.match(version) or len(version) > 2)):
|
|
90
|
+
return match.group("family"), version
|
|
91
|
+
return base, None
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
_PRE = {"dev": -4, "a": -3, "alpha": -3, "b": -2, "beta": -2, "c": -1, "rc": -1, "pre": -1, "preview": -1}
|
|
95
|
+
_POST = {"post": 1, "p": 1, "patch": 1, "sp": 1, "rev": 1, "r": 1}
|
|
96
|
+
_FINAL = {"final", "release", "ga", "stable"}
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _version_parts(text: str) -> tuple[list[int], list[str]]:
|
|
100
|
+
"""(leading numeric release components, the tokens after them) of a version, case-folded, without a `v` prefix
|
|
101
|
+
and without `+build` metadata."""
|
|
102
|
+
folded = re.sub(r"^v(?=\d)", "", text.strip().lower()).split("+", 1)[0]
|
|
103
|
+
tokens = re.findall(r"\d+|[a-z]+", folded)
|
|
104
|
+
release: list[int] = []
|
|
105
|
+
while tokens and tokens[0].isdigit():
|
|
106
|
+
release.append(int(tokens.pop(0)))
|
|
107
|
+
return release, tokens
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def version_key(text: str) -> tuple[int, tuple[int, ...], int, tuple[int, ...], str]:
|
|
111
|
+
"""A sort key for version strings, tolerant of what package ecosystems write. Numeric components compare as
|
|
112
|
+
numbers (`1.10` > `1.8`), a `v` prefix and trailing zeros do not matter (`v1.0` == `1.0.0`), a pre-release
|
|
113
|
+
(`dev` < `a`/`alpha` < `b`/`beta` < `rc`) sorts below its release and a post-release above it, an unknown
|
|
114
|
+
suffix (`1.2.x`) sorts with the pre-releases, and a version with no number at all (`latest`, `main`) sorts
|
|
115
|
+
below every numbered one: 'newest' never prefers it."""
|
|
116
|
+
release, rest = _version_parts(text)
|
|
117
|
+
numbered = bool(release) # before trimming: `0.0` has a number even though its release trims to nothing
|
|
118
|
+
while release and release[-1] == 0:
|
|
119
|
+
release.pop()
|
|
120
|
+
word = rest[0] if rest and not rest[0].isdigit() else ""
|
|
121
|
+
known = not word or word in _PRE or word in _POST or word in _FINAL
|
|
122
|
+
phase = 0 if not word or word in _FINAL else _PRE.get(word, _POST.get(word, -1))
|
|
123
|
+
numbers = tuple(int(t) for t in rest[1:] if t.isdigit()) if word else tuple(int(t) for t in rest if t.isdigit())
|
|
124
|
+
return (1 if numbered else 0, tuple(release), phase, numbers, "" if known and release else text.strip().lower())
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def same_version(a: str, b: str) -> bool:
|
|
128
|
+
"""The same version as written, ignoring case, a `v` prefix and build metadata. `3.1` is NOT `3.1.0` here
|
|
129
|
+
(they sort alike, but asking for `3.1` means 'the 3.1 line', which `version_prefix` answers)."""
|
|
130
|
+
return _version_parts(a) == _version_parts(b)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def version_prefix(requested: str, candidate: str) -> bool:
|
|
134
|
+
"""Does `candidate` start with `requested`, component by component? `3.1` matches `3.1.4` and not `3.10.0`. A
|
|
135
|
+
requested version carrying a pre-release or other suffix has no prefix reading, only an exact one."""
|
|
136
|
+
wanted, suffix = _version_parts(requested)
|
|
137
|
+
have, _ = _version_parts(candidate)
|
|
138
|
+
return bool(wanted) and not suffix and have[: len(wanted)] == wanted
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def home() -> Path:
|
|
142
|
+
"""Where the library lives. An explicit setting wins; without one an existing `~/.zimlore` is found instead of
|
|
143
|
+
an empty library being started beside it."""
|
|
144
|
+
explicit = any(os.environ.get(name) for name in (HOME_ENV, "PRIMARYSOURCE_ZIM_HOME", "PRIMARYSOURCE_HOME"))
|
|
145
|
+
old = Path.home() / ".zimlore"
|
|
146
|
+
if not explicit and old.is_dir():
|
|
147
|
+
return old
|
|
148
|
+
return _homes.collection_home("zim", "zimlore").expanduser()
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def zim_dirs() -> list[Path]:
|
|
152
|
+
override = os.environ.get(PATH_ENV) or os.environ.get(LEGACY_PATH_ENV)
|
|
153
|
+
if override:
|
|
154
|
+
return [Path(part).expanduser() for part in override.split(os.pathsep) if part]
|
|
155
|
+
return [home() / "zim"]
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def norm(text: str) -> str:
|
|
159
|
+
"""Lower-case words separated by single spaces: `Flask Docs`, `devdocs_en_flask` and `devdocs/flask` all
|
|
160
|
+
become comparable token strings."""
|
|
161
|
+
return " ".join(re.split(r"[^0-9a-z+#]+", text.lower())).strip()
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
@dataclass(frozen=True)
|
|
165
|
+
class Book:
|
|
166
|
+
name: str
|
|
167
|
+
title: str
|
|
168
|
+
date: str # ISO YYYY-MM-DD from the Date metadata, "" when the ZIM has none
|
|
169
|
+
language: str
|
|
170
|
+
creator: str
|
|
171
|
+
publisher: str
|
|
172
|
+
description: str
|
|
173
|
+
tags: tuple[str, ...]
|
|
174
|
+
license: str # the License metadata, "" when the ZIM does not state one
|
|
175
|
+
source: str
|
|
176
|
+
path: Path
|
|
177
|
+
uuid: str
|
|
178
|
+
articles: int
|
|
179
|
+
size: int
|
|
180
|
+
has_fulltext: bool
|
|
181
|
+
has_titles: bool
|
|
182
|
+
scraper: str = ""
|
|
183
|
+
|
|
184
|
+
@property
|
|
185
|
+
def when(self) -> dt.date | None:
|
|
186
|
+
return parse_date(self.date)
|
|
187
|
+
|
|
188
|
+
@cached_property
|
|
189
|
+
def package(self) -> purl.Purl | None:
|
|
190
|
+
"""The package this ZIM documents, from a `purl:pkg:...` tag (set by the producer), else None. A tag that
|
|
191
|
+
is not a usable purl is ignored: a scan must not fail on someone else's metadata."""
|
|
192
|
+
for tag in self.tags:
|
|
193
|
+
if tag[:5].lower() == "purl:":
|
|
194
|
+
try:
|
|
195
|
+
return purl.parse(tag[5:])
|
|
196
|
+
except ZimloreError:
|
|
197
|
+
continue
|
|
198
|
+
return None
|
|
199
|
+
|
|
200
|
+
@cached_property
|
|
201
|
+
def family(self) -> str:
|
|
202
|
+
"""What versions of one package share: the purl without its version when the ZIM carries one, else the
|
|
203
|
+
Name without its trailing version token (`golang_github.com_spf13_cobra_v1.8.1` ->
|
|
204
|
+
`golang_github.com_spf13_cobra`). A Name with no version token is its own family."""
|
|
205
|
+
return self.package.text if self.package else split_name(self.name)[0]
|
|
206
|
+
|
|
207
|
+
@cached_property
|
|
208
|
+
def version(self) -> str | None:
|
|
209
|
+
"""The upstream version this ZIM documents, from the purl tag or the Name's version token; None when the
|
|
210
|
+
ZIM does not say (a DevDocs ZIM records a date, not a release)."""
|
|
211
|
+
return (self.package.version if self.package else None) or split_name(self.name)[1]
|
|
212
|
+
|
|
213
|
+
def age_days(self, today: dt.date | None = None) -> int | None:
|
|
214
|
+
when = self.when
|
|
215
|
+
if when is None:
|
|
216
|
+
return None
|
|
217
|
+
return ((today or datetime.now(UTC).date()) - when).days
|
|
218
|
+
|
|
219
|
+
def freshness(self, today: dt.date | None = None) -> dict[str, object]:
|
|
220
|
+
"""Staleness as data: the date, the age, and whether it passes STALE_DAYS. `unknown` when the ZIM
|
|
221
|
+
carries no usable Date, which is not the same as fresh."""
|
|
222
|
+
age = self.age_days(today)
|
|
223
|
+
return {
|
|
224
|
+
"date": self.date or None,
|
|
225
|
+
"age_days": age,
|
|
226
|
+
"stale": None if age is None else age > STALE_DAYS,
|
|
227
|
+
"stale_after_days": STALE_DAYS,
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
def aliases(self, user: dict[str, str] | None = None) -> set[str]:
|
|
231
|
+
found = name_aliases(self.name, self.title, self.version)
|
|
232
|
+
if self.package:
|
|
233
|
+
found.update(
|
|
234
|
+
{norm(self.package.name), norm(self.package.words), norm(f"{self.package.type} {self.package.name}")}
|
|
235
|
+
)
|
|
236
|
+
if user:
|
|
237
|
+
found.update(norm(alias) for alias, target in user.items() if target in {self.name, self.title})
|
|
238
|
+
found.discard("")
|
|
239
|
+
return found
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def name_aliases(name: str, title: str, version: str | None = None) -> set[str]:
|
|
243
|
+
"""The comparable spellings of one library from its `Name` and `Title`: shared by installed books and by
|
|
244
|
+
catalogue snapshot entries, so a query ranks the same way against both.
|
|
245
|
+
|
|
246
|
+
A versioned library also gets its version-free spellings. Without them `cobra` scores `v1.8.1` above `v1.10.2`
|
|
247
|
+
only because that alias is a character shorter: the version is not part of what a name query asks about."""
|
|
248
|
+
found = {norm(name), norm(_DATE_SUFFIX.sub("", name)), norm(title)}
|
|
249
|
+
normal_title = norm(title)
|
|
250
|
+
if version is None:
|
|
251
|
+
version = split_name(name)[1]
|
|
252
|
+
if version:
|
|
253
|
+
found.add(norm(split_name(name)[0]))
|
|
254
|
+
wanted = {norm(version), norm(version.lstrip("vV"))} - {""}
|
|
255
|
+
for spelling in wanted:
|
|
256
|
+
normal_title = f" {normal_title} ".replace(f" {spelling} ", " ").strip()
|
|
257
|
+
normal_title = " ".join(normal_title.split())
|
|
258
|
+
found.add(normal_title)
|
|
259
|
+
for suffix in (" docs", " documentation", " reference", " manual"):
|
|
260
|
+
if normal_title.endswith(suffix):
|
|
261
|
+
found.add(normal_title[: -len(suffix)])
|
|
262
|
+
parts = (split_name(name)[0] if version else _DATE_SUFFIX.sub("", name)).split("_")
|
|
263
|
+
if parts[0] == "devdocs" and len(parts) >= 3:
|
|
264
|
+
slug = "_".join(parts[2:])
|
|
265
|
+
found.update({norm(slug), norm("devdocs " + slug)})
|
|
266
|
+
host = parts[0]
|
|
267
|
+
if "." in host: # stackoverflow.com_en_all, security.stackexchange.com_en_all, docs.python.org_en_all
|
|
268
|
+
found.add(norm(host))
|
|
269
|
+
first = host.split(".")[0]
|
|
270
|
+
if host.endswith("stackexchange.com") or host.endswith("stackoverflow.com"):
|
|
271
|
+
found.add(norm(first))
|
|
272
|
+
found.discard("")
|
|
273
|
+
return found
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def score_aliases(wanted: str, aliases: set[str]) -> tuple[float, str]:
|
|
277
|
+
"""Best score of a normalised query against a set of aliases: exact beats all-words beats prefix beats
|
|
278
|
+
substring beats fuzzy. `(0.0, "")` means no match. Shared by `rank` and the snapshot's `available`."""
|
|
279
|
+
words = set(wanted.split())
|
|
280
|
+
best, why = 0.0, ""
|
|
281
|
+
for alias in aliases:
|
|
282
|
+
if alias == wanted:
|
|
283
|
+
score, reason = 100.0, f"exact match on '{alias}'"
|
|
284
|
+
elif words and words <= set(alias.split()):
|
|
285
|
+
score, reason = 80.0 - 0.1 * len(alias), f"every word appears in '{alias}'"
|
|
286
|
+
elif alias.startswith(wanted):
|
|
287
|
+
score, reason = 70.0 - 0.1 * len(alias), f"'{alias}' starts with the query"
|
|
288
|
+
elif wanted in alias:
|
|
289
|
+
score, reason = 60.0 - 0.1 * len(alias), f"'{alias}' contains the query"
|
|
290
|
+
else:
|
|
291
|
+
ratio = difflib.SequenceMatcher(None, wanted, alias).ratio()
|
|
292
|
+
score, reason = (ratio * 50.0, f"close to '{alias}' ({ratio:.2f})") if ratio >= 0.8 else (0.0, "")
|
|
293
|
+
if score > best:
|
|
294
|
+
best, why = score, reason
|
|
295
|
+
return best, why
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
@dataclass
|
|
299
|
+
class Scan:
|
|
300
|
+
books: list[Book] = field(default_factory=list)
|
|
301
|
+
problems: list[dict[str, str]] = field(default_factory=list)
|
|
302
|
+
directories: list[Path] = field(default_factory=list)
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
_ARCHIVES: dict[tuple[str, int, int], Any] = {}
|
|
306
|
+
_BOOKS: dict[tuple[str, int, int], Book] = {}
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def close_all() -> None:
|
|
310
|
+
"""Drop every cached archive handle. Windows keeps a ZIM file locked while a handle is open, so anything
|
|
311
|
+
that deletes or replaces a ZIM (the test suite, `primarysource zim get --replace`) calls this first."""
|
|
312
|
+
_ARCHIVES.clear()
|
|
313
|
+
_BOOKS.clear()
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def close_path(path: Path) -> None:
|
|
317
|
+
"""Drop every cached handle for ONE file (under any recorded mtime and size), leaving the others open. Deleting
|
|
318
|
+
or replacing a ZIM calls this: on Windows an `Archive` blocks the delete until its last reference is gone."""
|
|
319
|
+
for cache in (_ARCHIVES, _BOOKS):
|
|
320
|
+
for key in [k for k in cache if k[0] == str(path)]:
|
|
321
|
+
del cache[key]
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def open_archive(path: Path) -> Any:
|
|
325
|
+
stat = path.stat()
|
|
326
|
+
key = (str(path), stat.st_mtime_ns, stat.st_size)
|
|
327
|
+
if key not in _ARCHIVES:
|
|
328
|
+
for stale in [k for k in _ARCHIVES if k[0] == key[0]]:
|
|
329
|
+
del _ARCHIVES[stale] # a replaced file: the old handle would keep the old inode open for the process
|
|
330
|
+
for stale in [k for k in _BOOKS if k[0] == key[0] and k != key]:
|
|
331
|
+
del _BOOKS[stale]
|
|
332
|
+
reader = _libzim.load("reader")
|
|
333
|
+
try:
|
|
334
|
+
_ARCHIVES[key] = reader.Archive(str(path))
|
|
335
|
+
except (RuntimeError, OSError) as error:
|
|
336
|
+
raise ZimloreError(
|
|
337
|
+
f"{path} is not a readable ZIM file ({error})", "re-download it: primarysource zim get"
|
|
338
|
+
) from error
|
|
339
|
+
return _ARCHIVES[key]
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def _meta(archive: Any, key: str) -> str:
|
|
343
|
+
try:
|
|
344
|
+
raw = archive.get_metadata(key)
|
|
345
|
+
except (KeyError, RuntimeError):
|
|
346
|
+
return ""
|
|
347
|
+
return bytes(raw).decode("utf-8", errors="replace").strip()
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def read_book(path: Path) -> Book:
|
|
351
|
+
stat = path.stat()
|
|
352
|
+
key = (str(path), stat.st_mtime_ns, stat.st_size)
|
|
353
|
+
if key in _BOOKS:
|
|
354
|
+
return _BOOKS[key]
|
|
355
|
+
archive = open_archive(path)
|
|
356
|
+
tags = tuple(tag for tag in _meta(archive, "Tags").split(";") if tag and not tag.startswith("_"))
|
|
357
|
+
book = Book(
|
|
358
|
+
name=_meta(archive, "Name") or path.stem,
|
|
359
|
+
title=_meta(archive, "Title") or path.stem,
|
|
360
|
+
date=_meta(archive, "Date"),
|
|
361
|
+
language=_meta(archive, "Language"),
|
|
362
|
+
creator=_meta(archive, "Creator"),
|
|
363
|
+
publisher=_meta(archive, "Publisher"),
|
|
364
|
+
description=_meta(archive, "Description"),
|
|
365
|
+
tags=tags,
|
|
366
|
+
license=_meta(archive, "License"),
|
|
367
|
+
source=_meta(archive, "Source"),
|
|
368
|
+
path=path,
|
|
369
|
+
uuid=str(archive.uuid),
|
|
370
|
+
articles=int(archive.article_count),
|
|
371
|
+
size=stat.st_size,
|
|
372
|
+
has_fulltext=bool(archive.has_fulltext_index),
|
|
373
|
+
has_titles=bool(archive.has_title_index),
|
|
374
|
+
scraper=_meta(archive, "Scraper"),
|
|
375
|
+
)
|
|
376
|
+
_BOOKS[key] = book
|
|
377
|
+
return book
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def scan(directories: list[Path] | None = None) -> Scan:
|
|
381
|
+
"""Read every `*.zim` under the library directories. A file that is not a readable ZIM is reported in
|
|
382
|
+
`problems`, never dropped: the books that are missing are exactly the ones a user wonders about."""
|
|
383
|
+
result = Scan(directories=directories if directories is not None else zim_dirs())
|
|
384
|
+
for directory in result.directories:
|
|
385
|
+
if not directory.is_dir():
|
|
386
|
+
continue
|
|
387
|
+
for path in sorted(directory.glob("*.zim")):
|
|
388
|
+
try:
|
|
389
|
+
result.books.append(read_book(path))
|
|
390
|
+
except ZimloreError as error:
|
|
391
|
+
result.problems.append({"path": str(path), "error": error.message})
|
|
392
|
+
result.books.sort(key=lambda b: (b.name, date_key(b.date), str(b.path)))
|
|
393
|
+
return result
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def user_aliases() -> dict[str, str]:
|
|
397
|
+
"""`$ZIMLORE_HOME/aliases.json`: {"py": "devdocs_en_python"}. A malformed file is an error, not an empty map."""
|
|
398
|
+
path = home() / "aliases.json"
|
|
399
|
+
if not path.is_file():
|
|
400
|
+
return {}
|
|
401
|
+
try:
|
|
402
|
+
loaded = json.loads(path.read_text(encoding="utf-8"))
|
|
403
|
+
except (OSError, ValueError) as error:
|
|
404
|
+
raise ZimloreError(f"{path} is not valid JSON ({error})", f"fix or delete {path}") from error
|
|
405
|
+
if not isinstance(loaded, dict) or not all(isinstance(v, str) for v in loaded.values()):
|
|
406
|
+
raise ZimloreError(f"{path} must map alias to library name", f"fix or delete {path}")
|
|
407
|
+
return {str(k): v for k, v in loaded.items()}
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
@dataclass(frozen=True)
|
|
411
|
+
class Candidate:
|
|
412
|
+
book: Book
|
|
413
|
+
score: float
|
|
414
|
+
why: str
|
|
415
|
+
members: tuple[Book, ...] = () # every installed book of the family that matched; `book` is the newest
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def empty_library_error(scanned: Scan) -> ZimloreError:
|
|
419
|
+
where = ", ".join(str(d) for d in scanned.directories)
|
|
420
|
+
unreadable = ""
|
|
421
|
+
if scanned.problems:
|
|
422
|
+
unreadable = (
|
|
423
|
+
f"; {len(scanned.problems)} file(s) there could not be read, first: {scanned.problems[0]['error']}"
|
|
424
|
+
)
|
|
425
|
+
# Files that cannot be opened because the binding is missing are not an empty library: install it first.
|
|
426
|
+
fix = (
|
|
427
|
+
"download one: primarysource zim catalog flask ; primarysource zim get devdocs_en_flask "
|
|
428
|
+
"(or copy a .zim into that directory)"
|
|
429
|
+
if not scanned.problems or _libzim.available()
|
|
430
|
+
else _libzim.REMEDY
|
|
431
|
+
)
|
|
432
|
+
return ZimloreError(
|
|
433
|
+
f"no ZIM files found in {where}{unreadable}",
|
|
434
|
+
fix,
|
|
435
|
+
searched=[str(d) for d in scanned.directories],
|
|
436
|
+
problems=scanned.problems,
|
|
437
|
+
)
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def newest_version(books: list[Book]) -> Book:
|
|
441
|
+
"""The book with the newest version (a book with no version loses to any that has one), then the newest
|
|
442
|
+
edition date, then the file name so that the answer never depends on directory order."""
|
|
443
|
+
return max(
|
|
444
|
+
sorted(books, key=lambda b: str(b.path)),
|
|
445
|
+
key=lambda b: (version_key(b.version) if b.version else (-1, (), 0, (), ""), date_key(b.date)),
|
|
446
|
+
)
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
def rank(query: str, books: list[Book], user: dict[str, str] | None = None) -> list[Candidate]:
|
|
450
|
+
"""Score each FAMILY of books against a name, best first: exact alias beats all-words beats prefix beats
|
|
451
|
+
substring beats fuzzy. Every version and edition of one family scores alike (the best of them), so a tie can
|
|
452
|
+
only be between different libraries; the candidate's `book` is the family's newest version, newest edition,
|
|
453
|
+
and `members` is everything installed in the family.
|
|
454
|
+
|
|
455
|
+
This is where `cobra` once resolved to v1.8.1 over v1.10.2: each version was scored on its own alias, the
|
|
456
|
+
scores differed by 0.1 per character of alias length, and the ambiguity guard compared only the top two."""
|
|
457
|
+
wanted = norm(query)
|
|
458
|
+
if not wanted:
|
|
459
|
+
return []
|
|
460
|
+
families: dict[str, list[tuple[Book, float, str]]] = {}
|
|
461
|
+
for book in books:
|
|
462
|
+
best, why = score_aliases(wanted, book.aliases(user))
|
|
463
|
+
if best:
|
|
464
|
+
families.setdefault(book.family, []).append((book, best, why))
|
|
465
|
+
out: list[Candidate] = []
|
|
466
|
+
for members in families.values():
|
|
467
|
+
_, score, why = max(members, key=lambda m: m[1])
|
|
468
|
+
matched = [m[0] for m in members]
|
|
469
|
+
out.append(Candidate(newest_version(matched), score, why, tuple(matched)))
|
|
470
|
+
return sorted(out, key=lambda c: (-c.score, c.book.family))
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
def editions(book: Book, books: list[Book]) -> list[Book]:
|
|
474
|
+
"""Every installed file of `book`'s library, newest first by PARSED date (undated last, ties by path)."""
|
|
475
|
+
mine = sorted((b for b in books if b.name == book.name), key=lambda b: str(b.path))
|
|
476
|
+
return sorted(mine, key=lambda b: date_key(b.date), reverse=True)
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
def family_of(book: Book, books: list[Book]) -> list[Book]:
|
|
480
|
+
"""Every installed book in `book`'s family: all its versions and all their editions."""
|
|
481
|
+
return [b for b in books if b.family == book.family]
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
def versions_of(book: Book, books: list[Book]) -> list[str]:
|
|
485
|
+
"""The distinct upstream versions installed in `book`'s family, newest first; versions the ZIMs do not state
|
|
486
|
+
are left out (a family of ZIMs that state none has none to list)."""
|
|
487
|
+
found = {b.version for b in books if b.family == book.family and b.version}
|
|
488
|
+
return sorted(found, key=version_key, reverse=True)
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
def newest(books: list[Book]) -> Book | None:
|
|
492
|
+
"""The newest of `books` by parsed date; None for an empty list."""
|
|
493
|
+
return max(sorted(books, key=lambda b: str(b.path)), key=lambda b: date_key(b.date), default=None)
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
@dataclass(frozen=True)
|
|
497
|
+
class Selection:
|
|
498
|
+
"""What a library query selected. `kind` is `newest` (a family named, no version asked), `exact` or `prefix`
|
|
499
|
+
(the version asked for) or `name` (a whole `Name` was given, which is already one version)."""
|
|
500
|
+
|
|
501
|
+
book: Book
|
|
502
|
+
why: str
|
|
503
|
+
kind: str
|
|
504
|
+
requested: str | None = None
|
|
505
|
+
members: tuple[Book, ...] = ()
|
|
506
|
+
|
|
507
|
+
@property
|
|
508
|
+
def versions(self) -> list[str]:
|
|
509
|
+
return versions_of(self.book, list(self.members))
|
|
510
|
+
|
|
511
|
+
@property
|
|
512
|
+
def other_versions(self) -> list[str]:
|
|
513
|
+
return [v for v in self.versions if v != self.book.version]
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
class Query(NamedTuple):
|
|
517
|
+
package: purl.Purl | None
|
|
518
|
+
text: str # the name part: the whole query, or what is left of it after the version is split off
|
|
519
|
+
version: str | None
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
_VERSION_TAIL = re.compile(r"^[vV]?\d+(?:\.[0-9A-Za-z]+)*(?:-[0-9A-Za-z.]+)?$")
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
def split_query(text: str) -> tuple[str, str] | None:
|
|
526
|
+
"""`cryptography==50.0.2`, `cryptography@50`, `cobra v1.8.1`, `flask 3.1` as (name, version); None when the
|
|
527
|
+
query carries no version. An `@` at the very start is an npm scope, not a version separator."""
|
|
528
|
+
cleaned = text.strip()
|
|
529
|
+
for separator in ("==", "@"):
|
|
530
|
+
if separator in cleaned[1:]:
|
|
531
|
+
name, _, version = cleaned.rpartition(separator)
|
|
532
|
+
if name.strip() and version.strip() and " " not in version.strip():
|
|
533
|
+
return name.strip(), version.strip()
|
|
534
|
+
parts = cleaned.rsplit(None, 1)
|
|
535
|
+
if len(parts) == 2 and _VERSION_TAIL.match(parts[1]):
|
|
536
|
+
return parts[0].strip(), parts[1]
|
|
537
|
+
return None
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
def parse_query(text: str) -> list[Query]:
|
|
541
|
+
"""The readings of a library query, most specific first. A purl is one reading; anything else is a name with a
|
|
542
|
+
version split off (when it has one) and then the whole text as a name."""
|
|
543
|
+
if purl.is_purl(text):
|
|
544
|
+
parsed = purl.parse(text)
|
|
545
|
+
return [Query(parsed, parsed.words, parsed.version)]
|
|
546
|
+
readings: list[Query] = []
|
|
547
|
+
split = split_query(text)
|
|
548
|
+
if split:
|
|
549
|
+
readings.append(Query(None, split[0], split[1]))
|
|
550
|
+
readings.append(Query(None, text.strip(), None))
|
|
551
|
+
return readings
|
|
552
|
+
|
|
553
|
+
|
|
554
|
+
def _version_error(family: str, requested: str, members: list[Book]) -> ZimloreError:
|
|
555
|
+
"""The requested version is not installed. No build command is named: none exists."""
|
|
556
|
+
installed = versions_of(members[0], members)
|
|
557
|
+
stated = (
|
|
558
|
+
f"installed versions: {', '.join(installed)}" if installed else "none of the installed ZIMs states a version"
|
|
559
|
+
)
|
|
560
|
+
return LibraryNotFound(
|
|
561
|
+
f"{family} has no installed version matching '{requested}' ({stated})",
|
|
562
|
+
"ask for one of the installed versions, or omit the version to get the newest; "
|
|
563
|
+
"primarysource zim list shows them"
|
|
564
|
+
if installed
|
|
565
|
+
else "this library records its edition date, not a release: omit the version",
|
|
566
|
+
tool_hint="omit the version to get the newest, or ask for an installed one: zim_libraries lists them",
|
|
567
|
+
requested_version=requested,
|
|
568
|
+
installed_versions=installed,
|
|
569
|
+
installed=sorted({b.name for b in members}),
|
|
570
|
+
)
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
def _pick_version(members: list[Book], requested: str | None) -> tuple[Book, str] | None:
|
|
574
|
+
"""The book of `members` for a requested version and how it matched (`exact`, `prefix`, `newest`); None when
|
|
575
|
+
nothing installed matches the version."""
|
|
576
|
+
if requested is None:
|
|
577
|
+
return newest_version(members), "newest"
|
|
578
|
+
exact = [b for b in members if b.version and same_version(b.version, requested)]
|
|
579
|
+
if exact:
|
|
580
|
+
return newest_version(exact), "exact"
|
|
581
|
+
near = [b for b in members if b.version and version_prefix(requested, b.version)]
|
|
582
|
+
if near:
|
|
583
|
+
return newest_version(near), "prefix"
|
|
584
|
+
return None
|
|
585
|
+
|
|
586
|
+
|
|
587
|
+
def _tied(query: str, tied: list[Candidate]) -> ZimloreError:
|
|
588
|
+
return ZimloreError(
|
|
589
|
+
f"'{query}' matches {len(tied)} libraries equally well; name one of them",
|
|
590
|
+
"use the library name exactly as listed",
|
|
591
|
+
candidates=[{"name": c.book.name, "title": c.book.title, "why": c.why} for c in tied],
|
|
592
|
+
)
|
|
593
|
+
|
|
594
|
+
|
|
595
|
+
def select(query: str, scanned: Scan | None = None) -> Selection:
|
|
596
|
+
"""The one book a library query means, or a `ZimloreError` that lists the candidates or the installed versions.
|
|
597
|
+
|
|
598
|
+
The query may be a name or alias (`cobra`: the newest installed version), a name with a version (`cobra
|
|
599
|
+
v1.8.1`, `flask 3.1`, `cryptography==50.0.2`, `cryptography@50`: that version, or the newest that starts
|
|
600
|
+
with it), a whole `Name` (that very ZIM) or a purl (`pkg:pypi/flask@3.1.0`, matched on the ZIM's `purl:` tag).
|
|
601
|
+
Never guesses between two different libraries that match equally well; the versions of one library are
|
|
602
|
+
ordered, not tied."""
|
|
603
|
+
scanned = scanned or scan()
|
|
604
|
+
if not scanned.books:
|
|
605
|
+
raise empty_library_error(scanned)
|
|
606
|
+
books = scanned.books
|
|
607
|
+
user = user_aliases()
|
|
608
|
+
readings = parse_query(query)
|
|
609
|
+
wanted = norm(query)
|
|
610
|
+
pinned = {target for alias, target in user.items() if norm(alias) == wanted}
|
|
611
|
+
named = [] if readings[0].package else [b for b in books if norm(b.name) == wanted or b.name in pinned]
|
|
612
|
+
if named:
|
|
613
|
+
families = sorted({b.family for b in named})
|
|
614
|
+
if len(families) > 1:
|
|
615
|
+
raise _tied(
|
|
616
|
+
query,
|
|
617
|
+
[
|
|
618
|
+
Candidate(newest_version([b for b in named if b.family == f]), 100.0, "exact Name")
|
|
619
|
+
for f in families
|
|
620
|
+
],
|
|
621
|
+
)
|
|
622
|
+
return Selection(
|
|
623
|
+
newest_version(named),
|
|
624
|
+
f"exact match on '{named[0].name}'",
|
|
625
|
+
"name",
|
|
626
|
+
None,
|
|
627
|
+
tuple(family_of(named[0], books)),
|
|
628
|
+
)
|
|
629
|
+
unstated: ZimloreError | None = None
|
|
630
|
+
for reading in readings:
|
|
631
|
+
if reading.package:
|
|
632
|
+
tagged = [b for b in books if b.package and b.package.key == reading.package.key]
|
|
633
|
+
if tagged:
|
|
634
|
+
members = family_of(tagged[0], books)
|
|
635
|
+
chosen = _pick_version(members, reading.version)
|
|
636
|
+
if chosen is None:
|
|
637
|
+
raise _version_error(tagged[0].family, reading.version or "", members)
|
|
638
|
+
return Selection(
|
|
639
|
+
chosen[0], f"purl tag {reading.package.text}", chosen[1], reading.version, tuple(members)
|
|
640
|
+
)
|
|
641
|
+
ranked = rank(reading.text, books, user)
|
|
642
|
+
if not ranked:
|
|
643
|
+
continue
|
|
644
|
+
top = ranked[0]
|
|
645
|
+
tied = [c for c in ranked if abs(c.score - top.score) < 1e-9]
|
|
646
|
+
if len(tied) > 1:
|
|
647
|
+
raise _tied(query, tied)
|
|
648
|
+
chosen = _pick_version(list(top.members), reading.version)
|
|
649
|
+
if chosen is None:
|
|
650
|
+
if reading.version and not versions_of(top.book, list(top.members)) and len(readings) > 1:
|
|
651
|
+
# nothing installed states a version: `python 3` may be a name, so read the whole text, and
|
|
652
|
+
# report the missing version if that finds nothing either
|
|
653
|
+
unstated = _version_error(top.book.family, reading.version, list(top.members))
|
|
654
|
+
continue
|
|
655
|
+
raise _version_error(top.book.family, reading.version or "", list(top.members))
|
|
656
|
+
return Selection(chosen[0], top.why, chosen[1], reading.version, top.members)
|
|
657
|
+
if unstated:
|
|
658
|
+
raise unstated
|
|
659
|
+
raise LibraryNotFound(
|
|
660
|
+
f"no installed library matches '{query}'",
|
|
661
|
+
"primarysource zim list shows what is installed; primarysource zim catalog <name> finds one to download",
|
|
662
|
+
installed=[b.name for b in books],
|
|
663
|
+
)
|
|
664
|
+
|
|
665
|
+
|
|
666
|
+
def find(query: str, scanned: Scan | None = None) -> Book:
|
|
667
|
+
"""The one book a name means (see `select`)."""
|
|
668
|
+
return select(query, scanned).book
|