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,283 @@
|
|
|
1
|
+
"""A bounded parser for the `usb.ids` format, and the ref spelling of its entries.
|
|
2
|
+
|
|
3
|
+
The file is line-oriented. A `#` line is a comment (the first ones carry `# Version:` and `# Date:`), a blank line
|
|
4
|
+
is nothing, and every other line is `<id> <name>` (two spaces) indented by zero to two tabs. At column zero a
|
|
5
|
+
line is either a vendor (`1d6b Linux Foundation`) or a section record led by an upper-case tag (`C 09 Hub`). One
|
|
6
|
+
tab is a child of the last column-zero entry (a device of a vendor, a subclass of a class, a usage of a usage
|
|
7
|
+
page, a dialect of a language), two tabs a child of the last one-tab entry (an interface of a device, a protocol
|
|
8
|
+
of a subclass). The sections the file has today, from its own `# Syntax:` comments:
|
|
9
|
+
|
|
10
|
+
(none) vendor > device > interface C class > subclass > protocol
|
|
11
|
+
AT audio terminal type HID HID descriptor type
|
|
12
|
+
R HID item type BIAS physical descriptor bias
|
|
13
|
+
PHY physical descriptor item type HUT usage page > usage
|
|
14
|
+
L language > dialect HCC HID country code (decimal)
|
|
15
|
+
VT video terminal type
|
|
16
|
+
|
|
17
|
+
Nothing here reads a file or the network. `parse` takes bytes and `Limits`, never trusts a line, and reports a
|
|
18
|
+
line it cannot place as a `Problem` instead of guessing: the real file parses with none. Ids are lower-cased,
|
|
19
|
+
names are kept as written, less surrounding space (a line that is not valid UTF-8 is read as Latin-1
|
|
20
|
+
and counted), and a repeated id keeps its first entry and is reported.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import re
|
|
26
|
+
from dataclasses import dataclass, field
|
|
27
|
+
|
|
28
|
+
from primarysource.errors import LibError
|
|
29
|
+
|
|
30
|
+
SECTION_REF = {
|
|
31
|
+
"vendor": "vendor",
|
|
32
|
+
"class": "class",
|
|
33
|
+
"at": "at",
|
|
34
|
+
"hid": "hid",
|
|
35
|
+
"item": "item",
|
|
36
|
+
"bias": "bias",
|
|
37
|
+
"phy": "phy",
|
|
38
|
+
"hut": "hut",
|
|
39
|
+
"lang": "lang",
|
|
40
|
+
"hcc": "hcc",
|
|
41
|
+
"vt": "vt",
|
|
42
|
+
}
|
|
43
|
+
"""The first path part of a non-vendor ref, by section name. A vendor ref has no section part: `usbids:1d6b`."""
|
|
44
|
+
|
|
45
|
+
TAGS = {
|
|
46
|
+
"C": "class",
|
|
47
|
+
"AT": "at",
|
|
48
|
+
"HID": "hid",
|
|
49
|
+
"R": "item",
|
|
50
|
+
"BIAS": "bias",
|
|
51
|
+
"PHY": "phy",
|
|
52
|
+
"HUT": "hut",
|
|
53
|
+
"L": "lang",
|
|
54
|
+
"HCC": "hcc",
|
|
55
|
+
"VT": "vt",
|
|
56
|
+
}
|
|
57
|
+
"""The file's tag for each section."""
|
|
58
|
+
|
|
59
|
+
LEVELS = {
|
|
60
|
+
"vendor": ("vendor", "device", "interface"),
|
|
61
|
+
"class": ("class", "subclass", "protocol"),
|
|
62
|
+
"at": ("terminal type",),
|
|
63
|
+
"hid": ("descriptor type",),
|
|
64
|
+
"item": ("item type",),
|
|
65
|
+
"bias": ("bias",),
|
|
66
|
+
"phy": ("item type",),
|
|
67
|
+
"hut": ("usage page", "usage"),
|
|
68
|
+
"lang": ("language", "dialect"),
|
|
69
|
+
"hcc": ("country code",),
|
|
70
|
+
"vt": ("terminal type",),
|
|
71
|
+
}
|
|
72
|
+
"""What each indentation level of a section is called, for `usbids_lookup` output."""
|
|
73
|
+
|
|
74
|
+
_HEX2 = r"[0-9a-f]{2}"
|
|
75
|
+
_HEX4 = r"[0-9a-f]{4}"
|
|
76
|
+
ID_PATTERNS: dict[str, tuple[str, ...]] = {
|
|
77
|
+
"vendor": (_HEX4, _HEX4, r"[0-9a-f]{2,4}"),
|
|
78
|
+
"class": (_HEX2, _HEX2, _HEX2),
|
|
79
|
+
"at": (_HEX4,),
|
|
80
|
+
"hid": (_HEX2,),
|
|
81
|
+
"item": (_HEX2,),
|
|
82
|
+
"bias": (r"[0-9a-f]{1,2}",),
|
|
83
|
+
"phy": (_HEX2,),
|
|
84
|
+
"hut": (_HEX2, r"[0-9a-f]{3,4}"),
|
|
85
|
+
"lang": (_HEX4, _HEX2),
|
|
86
|
+
"hcc": (r"[0-9]{2}",),
|
|
87
|
+
"vt": (_HEX4,),
|
|
88
|
+
}
|
|
89
|
+
"""The shape of the id at each level. `hcc` is decimal (the file says HID country codes are 00 to 35)."""
|
|
90
|
+
|
|
91
|
+
_ID = {section: tuple(re.compile(p) for p in patterns) for section, patterns in ID_PATTERNS.items()}
|
|
92
|
+
_LINE = re.compile(r"(?P<tabs>\t{0,3})(?:(?P<tag>[A-Z]{1,4}) )?(?P<id>[0-9A-Za-z]{1,8}) +(?P<name>\S.*)")
|
|
93
|
+
_VERSION = re.compile(r"#\s*Version:\s*(\S+)")
|
|
94
|
+
_DATE = re.compile(r"#\s*Date:\s*(.+?)\s*$")
|
|
95
|
+
_CONTROL = re.compile(r"[\x00-\x08\x0a-\x1f\x7f]")
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@dataclass(frozen=True, slots=True)
|
|
99
|
+
class Limits:
|
|
100
|
+
"""Ceilings on what a parse accepts. The real file measures 730,605 bytes, 25,705 lines, the longest line
|
|
101
|
+
160 characters."""
|
|
102
|
+
|
|
103
|
+
data_bytes: int = 8_000_000
|
|
104
|
+
lines: int = 200_000
|
|
105
|
+
line_chars: int = 512
|
|
106
|
+
entries: int = 200_000
|
|
107
|
+
problems: int = 1_000
|
|
108
|
+
"""Problems are counted without limit but only this many are kept."""
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
@dataclass(frozen=True, slots=True)
|
|
112
|
+
class Entry:
|
|
113
|
+
"""One record: `section`, the ids from the top down (`("1d6b", "0002")`), the name and its 1-based line."""
|
|
114
|
+
|
|
115
|
+
section: str
|
|
116
|
+
key: tuple[str, ...]
|
|
117
|
+
name: str
|
|
118
|
+
line: int
|
|
119
|
+
|
|
120
|
+
@property
|
|
121
|
+
def ref(self) -> str:
|
|
122
|
+
return format_ref(self.section, self.key)
|
|
123
|
+
|
|
124
|
+
@property
|
|
125
|
+
def level_name(self) -> str:
|
|
126
|
+
return LEVELS[self.section][len(self.key) - 1]
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
@dataclass(frozen=True, slots=True)
|
|
130
|
+
class Problem:
|
|
131
|
+
line: int
|
|
132
|
+
reason: str
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
@dataclass(slots=True)
|
|
136
|
+
class UsbIds:
|
|
137
|
+
"""A parsed file: entries in file order, the children of each, and what was wrong."""
|
|
138
|
+
|
|
139
|
+
entries: dict[tuple[str, tuple[str, ...]], Entry] = field(default_factory=dict)
|
|
140
|
+
children: dict[tuple[str, tuple[str, ...]], list[Entry]] = field(default_factory=dict)
|
|
141
|
+
version: str = ""
|
|
142
|
+
date: str = ""
|
|
143
|
+
problems: list[Problem] = field(default_factory=list)
|
|
144
|
+
problem_count: int = 0
|
|
145
|
+
latin1_lines: int = 0
|
|
146
|
+
"""Lines that were not valid UTF-8 and were read as Latin-1."""
|
|
147
|
+
|
|
148
|
+
def get(self, section: str, key: tuple[str, ...]) -> Entry | None:
|
|
149
|
+
return self.entries.get((section, key))
|
|
150
|
+
|
|
151
|
+
def kids(self, entry: Entry) -> list[Entry]:
|
|
152
|
+
return self.children.get((entry.section, entry.key), [])
|
|
153
|
+
|
|
154
|
+
def counts(self) -> dict[str, int]:
|
|
155
|
+
"""Entries per kind in first-seen order: `vendor`, `device`, `class`, `subclass`, `protocol`, `usage page`,
|
|
156
|
+
`usage`, `language`, `dialect`, and one count under the section's own name (`at`, `hid`, `item`, `bias`,
|
|
157
|
+
`phy`, `hcc`, `vt`) for the single-level tables."""
|
|
158
|
+
out: dict[str, int] = {}
|
|
159
|
+
for entry in self.entries.values():
|
|
160
|
+
label = entry.level_name if entry.section in ("vendor", "class", "hut", "lang") else entry.section
|
|
161
|
+
out[label] = out.get(label, 0) + 1
|
|
162
|
+
return out
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def parse(data: bytes, limits: Limits | None = None) -> UsbIds:
|
|
166
|
+
"""Parse `usb.ids` bytes. Raises `LibError` for input that is not this format at all (too large,
|
|
167
|
+
NUL bytes, too many lines or entries); a bad line inside an otherwise sound file is a `Problem`."""
|
|
168
|
+
limits = limits or Limits()
|
|
169
|
+
if len(data) > limits.data_bytes:
|
|
170
|
+
raise LibError(f"usb.ids input is {len(data):,} bytes, over the {limits.data_bytes:,} limit", "")
|
|
171
|
+
if b"\x00" in data:
|
|
172
|
+
raise LibError("usb.ids input contains NUL bytes: this is not the text file", "")
|
|
173
|
+
raw_lines = data.removeprefix(b"\xef\xbb\xbf").split(b"\n")
|
|
174
|
+
if len(raw_lines) > limits.lines:
|
|
175
|
+
raise LibError(f"usb.ids input has over {limits.lines:,} lines", "")
|
|
176
|
+
ids = UsbIds()
|
|
177
|
+
|
|
178
|
+
def problem(number: int, reason: str) -> None:
|
|
179
|
+
ids.problem_count += 1
|
|
180
|
+
if len(ids.problems) < limits.problems:
|
|
181
|
+
ids.problems.append(Problem(number, reason))
|
|
182
|
+
|
|
183
|
+
parents: list[Entry | None] = [None, None, None]
|
|
184
|
+
for number, raw_bytes in enumerate(raw_lines, 1):
|
|
185
|
+
try:
|
|
186
|
+
raw = raw_bytes.decode("utf-8")
|
|
187
|
+
except UnicodeDecodeError:
|
|
188
|
+
# The published file is UTF-8 with a few Latin-1 bytes (an acute accent written as 0xB4)
|
|
189
|
+
raw = raw_bytes.decode("latin-1")
|
|
190
|
+
ids.latin1_lines += 1
|
|
191
|
+
line = raw.rstrip(" \t\r")
|
|
192
|
+
if not line:
|
|
193
|
+
continue
|
|
194
|
+
if line.startswith("#"):
|
|
195
|
+
if not ids.version and (m := _VERSION.match(line)):
|
|
196
|
+
ids.version = m[1]
|
|
197
|
+
elif not ids.date and (m := _DATE.match(line)):
|
|
198
|
+
ids.date = m[1]
|
|
199
|
+
continue
|
|
200
|
+
if len(line) > limits.line_chars:
|
|
201
|
+
problem(number, f"line over {limits.line_chars} characters")
|
|
202
|
+
continue
|
|
203
|
+
if _CONTROL.search(line):
|
|
204
|
+
problem(number, "control character")
|
|
205
|
+
continue
|
|
206
|
+
match = _LINE.fullmatch(line)
|
|
207
|
+
if match is None:
|
|
208
|
+
problem(number, "not '<id> <name>'")
|
|
209
|
+
continue
|
|
210
|
+
level = len(match["tabs"])
|
|
211
|
+
tag = match["tag"]
|
|
212
|
+
if level > 2 or (level > 0 and tag):
|
|
213
|
+
problem(number, "indentation or tag not allowed here")
|
|
214
|
+
continue
|
|
215
|
+
if level == 0:
|
|
216
|
+
if tag is None:
|
|
217
|
+
section = "vendor"
|
|
218
|
+
elif tag in TAGS:
|
|
219
|
+
section = TAGS[tag]
|
|
220
|
+
else:
|
|
221
|
+
problem(number, f"unknown section tag {tag!r}")
|
|
222
|
+
parents[:] = [None, None, None]
|
|
223
|
+
continue
|
|
224
|
+
parent_key: tuple[str, ...] = ()
|
|
225
|
+
else:
|
|
226
|
+
parent = parents[level - 1]
|
|
227
|
+
if parent is None:
|
|
228
|
+
problem(number, "indented line with no parent")
|
|
229
|
+
continue
|
|
230
|
+
section, parent_key = parent.section, parent.key
|
|
231
|
+
patterns = _ID[section]
|
|
232
|
+
if level >= len(patterns):
|
|
233
|
+
problem(number, f"a {section} section has no level {level}")
|
|
234
|
+
continue
|
|
235
|
+
entry_id = match["id"].lower()
|
|
236
|
+
if patterns[level].fullmatch(entry_id) is None:
|
|
237
|
+
problem(number, f"{entry_id!r} is not a valid {LEVELS[section][level]} id")
|
|
238
|
+
continue
|
|
239
|
+
entry = Entry(section, (*parent_key, entry_id), match["name"], number)
|
|
240
|
+
index = (entry.section, entry.key)
|
|
241
|
+
if index in ids.entries:
|
|
242
|
+
problem(number, f"duplicate {entry.ref}")
|
|
243
|
+
parents[level:] = [None] * (3 - level)
|
|
244
|
+
continue
|
|
245
|
+
if len(ids.entries) >= limits.entries:
|
|
246
|
+
raise LibError(f"usb.ids input has over {limits.entries:,} entries", "")
|
|
247
|
+
ids.entries[index] = entry
|
|
248
|
+
if level > 0:
|
|
249
|
+
ids.children.setdefault((section, parent_key), []).append(entry)
|
|
250
|
+
parents[level:] = [entry] + [None] * (2 - level)
|
|
251
|
+
return ids
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
_VENDOR_REF = re.compile(r"(?P<v>[0-9a-f]{4})(?::(?P<d>[0-9a-f]{4})(?::(?P<i>[0-9a-f]{2,4}))?)?")
|
|
255
|
+
_SECTION_REF = re.compile(r"(?P<s>[a-z]+)(?P<path>(?:/[0-9a-f]{1,4}){1,3})")
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def format_ref(section: str, key: tuple[str, ...]) -> str:
|
|
259
|
+
"""`usbids:1d6b:0002` for the vendor section, `usbids:class/09/00` for the others."""
|
|
260
|
+
if section == "vendor":
|
|
261
|
+
return "usbids:" + ":".join(key)
|
|
262
|
+
return f"usbids:{section}/" + "/".join(key)
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def parse_ref(text: str) -> tuple[str, tuple[str, ...]]:
|
|
266
|
+
"""`usbids:1d6b:0002`, `1d6b:0002`, `usbids:class/09/00/02` -> (section, ids). Raises `LibError` on anything
|
|
267
|
+
else: a string that names nothing is refused, never guessed at."""
|
|
268
|
+
cleaned = text.strip().lower()
|
|
269
|
+
if len(cleaned) > 64:
|
|
270
|
+
raise LibError("not a usbids ref: too long", "")
|
|
271
|
+
cleaned = cleaned.removeprefix("usbids:")
|
|
272
|
+
if (m := _VENDOR_REF.fullmatch(cleaned)) is not None:
|
|
273
|
+
return "vendor", tuple(g for g in (m["v"], m["d"], m["i"]) if g)
|
|
274
|
+
if (m := _SECTION_REF.fullmatch(cleaned)) is not None and m["s"] in SECTION_REF and m["s"] != "vendor":
|
|
275
|
+
key = tuple(m["path"].split("/")[1:])
|
|
276
|
+
patterns = _ID[m["s"]]
|
|
277
|
+
if len(key) <= len(patterns) and all(p.fullmatch(k) for p, k in zip(patterns, key, strict=False)):
|
|
278
|
+
return m["s"], key
|
|
279
|
+
names = ", ".join(sorted(set(SECTION_REF) - {"vendor"}))
|
|
280
|
+
raise LibError(
|
|
281
|
+
f"not a usbids ref: {text!r}",
|
|
282
|
+
f"expected usbids:<vid>[:<pid>] (hex) or usbids:<section>/<id>[/<id>...] with section one of {names}",
|
|
283
|
+
)
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""MCP server for the usbids collection: `primarysource mcp --collection usbids`.
|
|
2
|
+
|
|
3
|
+
Imported only when served, so `mcp` (the `[mcp]` extra) is needed only then. Tools carry the `usbids_` prefix,
|
|
4
|
+
answer in text with the fixed footer, read the local database only (none writes or fetches), and turn a
|
|
5
|
+
`LibError` into an MCP tool error whose text names the remedy.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from mcp.server.mcpserver import MCPServer
|
|
11
|
+
from mcp.types import ToolAnnotations
|
|
12
|
+
|
|
13
|
+
from primarysource import __version__
|
|
14
|
+
from primarysource.scaffold import add_tools
|
|
15
|
+
from primarysource.usbids import tools
|
|
16
|
+
|
|
17
|
+
READ_ONLY = ToolAnnotations(read_only_hint=True, idempotent_hint=True, destructive_hint=False, open_world_hint=False)
|
|
18
|
+
|
|
19
|
+
INSTRUCTIONS = (
|
|
20
|
+
"The USB ID Database (usb.ids from the linux-usb.org USB ID Repository), imported by the user: vendor and "
|
|
21
|
+
"product ids, device classes, HID usages, languages and other lookup tables. It names things; it is not a USB "
|
|
22
|
+
"specification. Loop: usbids_search('<name>') -> usbids_lookup('<ref>'). Refs: 'usbids:1d6b' (vendor), "
|
|
23
|
+
"'usbids:1d6b:0002' (device), 'usbids:class/09/00/02' (class/subclass/protocol), 'usbids:hut/01/002' (HID "
|
|
24
|
+
"usage). usbids_classify('09', '00', '02') decodes a class code triple. The cite: footer line names the "
|
|
25
|
+
"snapshot. If the database is not imported, the error says how."
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def usbids_lookup(ref: str, max_tokens: int = 2000) -> str:
|
|
30
|
+
"""Read one entry by ref with its parents and children. ref: 'usbids:1d6b' (a vendor, lists its devices),
|
|
31
|
+
'usbids:1d6b:0002' (a device), 'usbids:class/09' (a class, lists subclasses), 'usbids:hut/01' (a HID usage
|
|
32
|
+
page), 'usbids:1d6b¶40' (that listing from its 40th line). A 'next: <ref>' footer line is the ref that reads
|
|
33
|
+
the next page: pass it back as ref."""
|
|
34
|
+
return tools.lookup(ref, max_tokens=max_tokens)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def usbids_search(text: str, section: str | None = None, limit: int = 20, start: int = 0) -> str:
|
|
38
|
+
"""Find entries whose name contains every word of text (a vendor's name counts for its devices). One line per
|
|
39
|
+
hit: '<ref> <name>'. section: vendor (vendors, devices, interfaces) | class | hut | lang | at | hid | item |
|
|
40
|
+
bias | phy | hcc | vt. A 'next: start=N' footer line means call again with start=N (a 0-based offset)."""
|
|
41
|
+
return tools.search(text, section=section, limit=limit, start=start)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def usbids_classify(base_class: str, subclass: str | None = None, protocol: str | None = None) -> str:
|
|
45
|
+
"""Name a USB class code triple (hex, with or without 0x): usbids_classify('09', '00', '02')."""
|
|
46
|
+
return tools.classify(base_class, subclass=subclass, protocol=protocol)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def usbids_sections() -> str:
|
|
50
|
+
"""The imported snapshot: version, date, sha256, source, licence and entry counts per section."""
|
|
51
|
+
return tools.sections()
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
server = MCPServer("primarysource-usbids", version=__version__, instructions=INSTRUCTIONS)
|
|
55
|
+
add_tools(server, (usbids_lookup, usbids_search, usbids_classify, usbids_sections), annotations=READ_ONLY)
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Where the imported `usb.ids` lives and how it is read back.
|
|
2
|
+
|
|
3
|
+
`$PRIMARYSOURCE_HOME/usbids/usb.ids` is the file exactly as served; `manifest.json` beside it records its URL,
|
|
4
|
+
sha256, size, the file's own `# Version:` and `# Date:`, when it was fetched, the server's validators and whether
|
|
5
|
+
the digest was the pinned one. Reading parses once per (path, mtime, size) and refuses a file whose sha256 no
|
|
6
|
+
longer matches the manifest.
|
|
7
|
+
|
|
8
|
+
Placement: the database is distributable under GPL-2.0-or-later or BSD-3-Clause (docs/usbids.md), so a built image
|
|
9
|
+
may carry it as a layer; the importer writes the same layout either way.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import hashlib
|
|
15
|
+
import json
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from functools import lru_cache
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
from primarysource.errors import LibError
|
|
22
|
+
from primarysource.home import collection_home
|
|
23
|
+
from primarysource.usbids.parse import Limits, UsbIds, parse
|
|
24
|
+
|
|
25
|
+
FILE = "usb.ids"
|
|
26
|
+
MANIFEST = "manifest.json"
|
|
27
|
+
IMPORT = "primarysource usbids import"
|
|
28
|
+
LICENCE = "GPL-2.0-or-later OR BSD-3-Clause"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def home() -> Path:
|
|
32
|
+
return collection_home("usbids")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def read_manifest(base: Path) -> dict[str, Any]:
|
|
36
|
+
path = base / MANIFEST
|
|
37
|
+
if not path.is_file():
|
|
38
|
+
return {}
|
|
39
|
+
try:
|
|
40
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
41
|
+
except (ValueError, OSError) as exc:
|
|
42
|
+
raise LibError(f"{path} is not a readable manifest", "", f"delete {path}, then {IMPORT} --force") from exc
|
|
43
|
+
if not isinstance(data, dict) or not isinstance(data.get("file"), dict):
|
|
44
|
+
raise LibError(f"{path} is not a readable manifest", "", f"delete {path}, then {IMPORT} --force")
|
|
45
|
+
return dict(data["file"])
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass(frozen=True, slots=True)
|
|
49
|
+
class Loaded:
|
|
50
|
+
ids: UsbIds
|
|
51
|
+
manifest: dict[str, Any]
|
|
52
|
+
sha256: str
|
|
53
|
+
path: Path
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@lru_cache(maxsize=4)
|
|
57
|
+
def _load(path: str, mtime_ns: int, size: int) -> tuple[UsbIds, str]:
|
|
58
|
+
data = Path(path).read_bytes()
|
|
59
|
+
return parse(data, Limits()), hashlib.sha256(data).hexdigest()
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def load(base: Path | None = None) -> Loaded:
|
|
63
|
+
"""The imported database, or a `LibError` that names the import command."""
|
|
64
|
+
root = base or home()
|
|
65
|
+
path = root / FILE
|
|
66
|
+
if not path.is_file():
|
|
67
|
+
raise LibError(f"usb.ids is not imported (looked in {root})", "", IMPORT)
|
|
68
|
+
stat = path.stat()
|
|
69
|
+
ids, digest = _load(str(path), stat.st_mtime_ns, stat.st_size)
|
|
70
|
+
manifest = read_manifest(root)
|
|
71
|
+
recorded = manifest.get("sha256")
|
|
72
|
+
if recorded and recorded != digest:
|
|
73
|
+
raise LibError(
|
|
74
|
+
f"{path} has sha256 {digest}, but the manifest recorded {recorded}: the file changed after import",
|
|
75
|
+
"",
|
|
76
|
+
IMPORT + " --force",
|
|
77
|
+
)
|
|
78
|
+
if not ids.entries:
|
|
79
|
+
raise LibError(f"{path} holds no usb.ids entries", "", IMPORT + " --force")
|
|
80
|
+
return Loaded(ids, manifest, digest, path)
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"""The usbids readers: lookup, search, classify and a summary. Plain text with the core's fixed footer.
|
|
2
|
+
|
|
3
|
+
Each takes the imported database from `store.load()` and never writes or fetches. A missing database is an
|
|
4
|
+
error whose `action` is the import command; a ref that names nothing is a `LibError` too, with a hint, so an empty
|
|
5
|
+
result always means a search matched nothing.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from primarysource import budget, cursor
|
|
14
|
+
from primarysource.errors import LibError
|
|
15
|
+
from primarysource.render import next_start, render
|
|
16
|
+
from primarysource.usbids import store
|
|
17
|
+
from primarysource.usbids.parse import SECTION_REF, Entry, UsbIds, format_ref, parse_ref
|
|
18
|
+
|
|
19
|
+
MAX_QUERY = 200
|
|
20
|
+
MAX_TERMS = 8
|
|
21
|
+
MAX_LIMIT = 200
|
|
22
|
+
_WORD = re.compile(r"[a-z0-9]+")
|
|
23
|
+
_HEX2 = re.compile(r"[0-9a-fA-F]{1,2}")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _cite(loaded: store.Loaded, entry: Entry | None = None) -> str:
|
|
27
|
+
ids = loaded.ids
|
|
28
|
+
head = f"USB ID Database usb.ids {ids.version or 'unversioned'}" + (f" ({ids.date})" if ids.date else "")
|
|
29
|
+
return f"{head}, {entry.ref}" if entry else head
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _line(entry: Entry) -> str:
|
|
33
|
+
return f"{entry.ref} {entry.name}"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _ancestors(ids: UsbIds, entry: Entry) -> list[Entry]:
|
|
37
|
+
found: list[Entry] = []
|
|
38
|
+
for depth in range(1, len(entry.key)):
|
|
39
|
+
parent = ids.get(entry.section, entry.key[:depth])
|
|
40
|
+
if parent is not None:
|
|
41
|
+
found.append(parent)
|
|
42
|
+
return found
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def lookup(ref: str, max_tokens: int = 2000, base: Path | None = None) -> str:
|
|
46
|
+
"""One entry by ref, its ancestors and its children, paged by token budget. `<ref>¶n` starts at line n of that
|
|
47
|
+
listing; the `next:` footer is the ref that reads the next page."""
|
|
48
|
+
text, line = cursor.split_para(ref)
|
|
49
|
+
section, key = parse_ref(text)
|
|
50
|
+
loaded = store.load(base)
|
|
51
|
+
ids = loaded.ids
|
|
52
|
+
entry = ids.get(section, key)
|
|
53
|
+
if entry is None:
|
|
54
|
+
parent = ids.get(section, key[:-1]) if len(key) > 1 else None
|
|
55
|
+
if parent is not None:
|
|
56
|
+
raise LibError(
|
|
57
|
+
f"{format_ref(section, key)} is not listed; its {parent.level_name} is {parent.name!r}",
|
|
58
|
+
f"usbids_lookup('{parent.ref}') lists what is under it",
|
|
59
|
+
)
|
|
60
|
+
raise LibError(
|
|
61
|
+
f"{format_ref(section, key)} is not in usb.ids {ids.version}", "usbids_search finds an entry by name"
|
|
62
|
+
)
|
|
63
|
+
units = [f"{_line(entry)} [{entry.level_name}]"]
|
|
64
|
+
units += [f"within: {_line(a)} [{a.level_name}]" for a in _ancestors(ids, entry)]
|
|
65
|
+
units += [f" {_line(kid)}" for kid in ids.kids(entry)]
|
|
66
|
+
if max_tokens < 1:
|
|
67
|
+
raise LibError("max_tokens must be positive", "")
|
|
68
|
+
page = budget.page(units, cursor.first_unit(line, len(units), "lines", ref), max_tokens)
|
|
69
|
+
footer = {
|
|
70
|
+
"cite": _cite(loaded, entry),
|
|
71
|
+
"unit": "line",
|
|
72
|
+
"span": f"{page.first}-{page.last} of {page.total}",
|
|
73
|
+
"next": cursor.read_next(entry.ref, page),
|
|
74
|
+
}
|
|
75
|
+
return render("\n".join(page.units), footer)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def search(
|
|
79
|
+
text: str,
|
|
80
|
+
section: str | None = None,
|
|
81
|
+
limit: int = 20,
|
|
82
|
+
start: int = 0,
|
|
83
|
+
base: Path | None = None,
|
|
84
|
+
) -> str:
|
|
85
|
+
"""Entries whose name contains every word of `text` (a vendor's name counts for its devices), best first."""
|
|
86
|
+
if section is not None and section not in SECTION_REF:
|
|
87
|
+
raise LibError(f"unknown section {section!r}", "one of " + ", ".join(sorted(SECTION_REF)))
|
|
88
|
+
if len(text) > MAX_QUERY:
|
|
89
|
+
raise LibError(f"query is over {MAX_QUERY} characters", "")
|
|
90
|
+
terms = _WORD.findall(text.lower())[:MAX_TERMS]
|
|
91
|
+
if not terms:
|
|
92
|
+
raise LibError("query has no letters or digits", "give a name or part of one, e.g. 'hub' or 'logitech'")
|
|
93
|
+
if not 1 <= limit <= MAX_LIMIT or start < 0:
|
|
94
|
+
raise LibError(f"limit must be 1 to {MAX_LIMIT} and start (0-based) 0 or more", "")
|
|
95
|
+
loaded = store.load(base)
|
|
96
|
+
ids = loaded.ids
|
|
97
|
+
hits: list[tuple[int, int, Entry, str]] = []
|
|
98
|
+
for order, entry in enumerate(ids.entries.values()):
|
|
99
|
+
if section is not None and entry.section != section:
|
|
100
|
+
continue
|
|
101
|
+
shown = entry.name
|
|
102
|
+
hay = entry.name.lower()
|
|
103
|
+
if entry.section == "vendor" and len(entry.key) > 1:
|
|
104
|
+
owner = ids.get("vendor", entry.key[:1])
|
|
105
|
+
if owner is not None:
|
|
106
|
+
hay = f"{owner.name.lower()} {hay}"
|
|
107
|
+
shown = f"{owner.name} / {entry.name}"
|
|
108
|
+
if not all(t in hay for t in terms):
|
|
109
|
+
continue
|
|
110
|
+
words = _WORD.findall(hay)
|
|
111
|
+
score = sum(3 if t in words else 2 if any(w.startswith(t) for w in words) else 1 for t in terms)
|
|
112
|
+
hits.append((-score, order, entry, shown))
|
|
113
|
+
hits.sort(key=lambda h: (h[0], h[1]))
|
|
114
|
+
window = hits[start : start + limit]
|
|
115
|
+
body = "\n".join(f"{e.ref} {shown}" for _, _, e, shown in window) if window else f"no entry matches {text!r}"
|
|
116
|
+
footer = {"cite": _cite(loaded), "unit": "line"}
|
|
117
|
+
if window:
|
|
118
|
+
footer["span"] = f"{start + 1}-{start + len(window)} of {len(hits)}"
|
|
119
|
+
footer["next"] = next_start(start + limit if start + limit < len(hits) else None)
|
|
120
|
+
return render(body, footer)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _two_hex(value: str, what: str) -> str:
|
|
124
|
+
if not _HEX2.fullmatch(value.strip()):
|
|
125
|
+
raise LibError(f"{what} {value!r} is not one or two hex digits", "e.g. 09 or 0xe0")
|
|
126
|
+
return value.strip().lower().zfill(2)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def classify(
|
|
130
|
+
base_class: str, subclass: str | None = None, protocol: str | None = None, base: Path | None = None
|
|
131
|
+
) -> str:
|
|
132
|
+
"""Name a class code triple as a device or interface descriptor carries it: bDeviceClass, bDeviceSubClass,
|
|
133
|
+
bDeviceProtocol. Digits may be written with or without `0x`."""
|
|
134
|
+
clean = [v.lower().removeprefix("0x") if v is not None else None for v in (base_class, subclass, protocol)]
|
|
135
|
+
if clean[1] is None and clean[2] is not None:
|
|
136
|
+
raise LibError("a protocol needs a subclass", "")
|
|
137
|
+
key: list[str] = []
|
|
138
|
+
for value, what in zip(clean, ("class", "subclass", "protocol"), strict=True):
|
|
139
|
+
if value is not None:
|
|
140
|
+
key.append(_two_hex(value, what))
|
|
141
|
+
loaded = store.load(base)
|
|
142
|
+
ids = loaded.ids
|
|
143
|
+
lines: list[str] = []
|
|
144
|
+
for depth in range(1, len(key) + 1):
|
|
145
|
+
entry = ids.get("class", tuple(key[:depth]))
|
|
146
|
+
if entry is None:
|
|
147
|
+
if depth == 1:
|
|
148
|
+
raise LibError(
|
|
149
|
+
f"class {key[0]} is not in usb.ids {ids.version}", "usbids_search with section='class'"
|
|
150
|
+
)
|
|
151
|
+
lines.append(f"{format_ref('class', tuple(key[:depth]))} (not listed in usb.ids)")
|
|
152
|
+
break
|
|
153
|
+
lines.append(f"{_line(entry)} [{entry.level_name}]")
|
|
154
|
+
return render("\n".join(lines), {"cite": _cite(loaded), "unit": "line"})
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def sections(base: Path | None = None) -> str:
|
|
158
|
+
"""The imported snapshot: version, date, digest, source, licence and entry counts per section."""
|
|
159
|
+
loaded = store.load(base)
|
|
160
|
+
ids = loaded.ids
|
|
161
|
+
per: dict[str, int] = {}
|
|
162
|
+
for entry in ids.entries.values():
|
|
163
|
+
per[entry.section] = per.get(entry.section, 0) + 1
|
|
164
|
+
man = loaded.manifest
|
|
165
|
+
lines = [
|
|
166
|
+
f"usb.ids {ids.version or 'unversioned'} date {ids.date or 'unknown'}",
|
|
167
|
+
f"sha256 {loaded.sha256} ({'pinned snapshot' if man.get('pinned') else 'not the pinned snapshot'})",
|
|
168
|
+
f"source {man.get('url', 'unknown')} fetched {man.get('fetched', 'unknown')}",
|
|
169
|
+
f"licence {man.get('licence', store.LICENCE)}",
|
|
170
|
+
"entries per section (ref prefix):",
|
|
171
|
+
]
|
|
172
|
+
lines += [f" {name}: {count:,}" for name, count in per.items()]
|
|
173
|
+
lines += [f"unparsed lines at import: {ids.problem_count}"]
|
|
174
|
+
return render("\n".join(lines), {"cite": _cite(loaded), "unit": "line"})
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""The only place that imports the native binding.
|
|
2
|
+
|
|
3
|
+
`libzim` is a compiled extension (it carries libzim and Xapian). It ships wheels for CPython 3.11+ on
|
|
4
|
+
Windows x64, macOS (x86-64, arm64) and Linux (glibc and musl, x86-64 and aarch64), and nothing else, so on any
|
|
5
|
+
other platform `import libzim` fails. Everything that does not read a ZIM (the CLI's `info`, `catalog`, `get`,
|
|
6
|
+
the reference grammar, the converter) must keep working there, which is why the import is lazy and why its
|
|
7
|
+
failure is a `ZimloreError` naming the remedy instead of a traceback from deep inside a tool.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import importlib
|
|
13
|
+
from types import ModuleType
|
|
14
|
+
|
|
15
|
+
from .errors import ZimloreError
|
|
16
|
+
|
|
17
|
+
REMEDY = (
|
|
18
|
+
'pip install "primarysource[zim]" (libzim wheels: Windows x64, macOS, Linux glibc/musl; CPython 3.11+). On a '
|
|
19
|
+
"platform without a wheel, build libzim from source (https://github.com/openzim/python-libzim) or serve the "
|
|
20
|
+
"same files with kiwix-serve and read them in a browser"
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def load(name: str) -> ModuleType:
|
|
25
|
+
"""Import `libzim.<name>` (reader, search, suggestion, writer)."""
|
|
26
|
+
try:
|
|
27
|
+
return importlib.import_module(f"libzim.{name}")
|
|
28
|
+
except ImportError as error:
|
|
29
|
+
raise ZimloreError(f"the libzim binding is not available ({error})", REMEDY) from error
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def available() -> bool:
|
|
33
|
+
try:
|
|
34
|
+
load("reader")
|
|
35
|
+
except ZimloreError:
|
|
36
|
+
return False
|
|
37
|
+
return True
|