vinsynlib 0.2.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.
- vinsynlib/__init__.py +107 -0
- vinsynlib/cli.py +256 -0
- vinsynlib/config.py +454 -0
- vinsynlib/conformance.py +274 -0
- vinsynlib/devchecks.py +148 -0
- vinsynlib/favorites.py +718 -0
- vinsynlib/keys.py +267 -0
- vinsynlib/midi.py +329 -0
- vinsynlib/py.typed +0 -0
- vinsynlib/spec.py +341 -0
- vinsynlib/terms.py +234 -0
- vinsynlib/ui/__init__.py +24 -0
- vinsynlib/ui/hints.py +84 -0
- vinsynlib-0.2.0.dist-info/METADATA +284 -0
- vinsynlib-0.2.0.dist-info/RECORD +19 -0
- vinsynlib-0.2.0.dist-info/WHEEL +5 -0
- vinsynlib-0.2.0.dist-info/licenses/COPYING +339 -0
- vinsynlib-0.2.0.dist-info/licenses/LICENSE +58 -0
- vinsynlib-0.2.0.dist-info/top_level.txt +1 -0
vinsynlib/__init__.py
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# SPDX-License-Identifier: GPL-2.0-or-later
|
|
2
|
+
# SPDX-FileCopyrightText: Copyright (C) 2026 vinsynlib contributors
|
|
3
|
+
#
|
|
4
|
+
# This file is part of vinsynlib.
|
|
5
|
+
#
|
|
6
|
+
# vinsynlib is free software: you can redistribute it and/or modify it under
|
|
7
|
+
# the terms of the GNU General Public License as published by the Free
|
|
8
|
+
# Software Foundation, either version 2 of the License, or (at your option)
|
|
9
|
+
# any later version.
|
|
10
|
+
#
|
|
11
|
+
# vinsynlib is distributed in the hope that it will be useful, but WITHOUT
|
|
12
|
+
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
|
|
13
|
+
# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
|
|
14
|
+
# more details.
|
|
15
|
+
|
|
16
|
+
"""The shared base of the ROMpler instrument browsers.
|
|
17
|
+
|
|
18
|
+
Nine sibling programs -- emorphed, ensqsqed, eosed, kwsed, nanosyned,
|
|
19
|
+
p2ked, rxved, s3ked and x5ded -- grew nine copies of the same handful of
|
|
20
|
+
modules: a settings cache, a favourites database, MIDI port discovery, a
|
|
21
|
+
wrapped key legend, an error format and a set of command-line conventions.
|
|
22
|
+
Each copy then drifted. This library is the common version of that code,
|
|
23
|
+
kept in step across the family rather than copied nine times.
|
|
24
|
+
|
|
25
|
+
What belongs here is what is *not* the manufacturer's: how a preference is
|
|
26
|
+
remembered, where a favourite lives, what an error says, which key means
|
|
27
|
+
what. What stays in each project is what the hardware owns: the wire
|
|
28
|
+
format, the bank table, the meaning of a byte.
|
|
29
|
+
|
|
30
|
+
The three documents worth reading first:
|
|
31
|
+
|
|
32
|
+
``docs/UX-SPEC.md``
|
|
33
|
+
The contract itself -- terminology, command line, keys, exit codes --
|
|
34
|
+
in prose, with the reasoning behind the parts that are not obvious.
|
|
35
|
+
|
|
36
|
+
:mod:`vinsynlib.spec`
|
|
37
|
+
The same contract as data: every flag, its help text and its older
|
|
38
|
+
spellings; every command; the exit codes.
|
|
39
|
+
|
|
40
|
+
:mod:`vinsynlib.conformance`
|
|
41
|
+
The checks a project runs against its own front end to stay in step.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
from __future__ import annotations
|
|
45
|
+
|
|
46
|
+
__all__ = [
|
|
47
|
+
"cli",
|
|
48
|
+
"config",
|
|
49
|
+
"conformance",
|
|
50
|
+
"devchecks",
|
|
51
|
+
"favorites",
|
|
52
|
+
"is_compatible_version",
|
|
53
|
+
"keys",
|
|
54
|
+
"midi",
|
|
55
|
+
"spec",
|
|
56
|
+
"terms",
|
|
57
|
+
]
|
|
58
|
+
|
|
59
|
+
__version__ = "0.2.0"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _release_parts(version_string: str, width: int) -> tuple[int, ...] | None:
|
|
63
|
+
"""The leading numeric components of a version, or ``None``.
|
|
64
|
+
|
|
65
|
+
A component must begin with a digit; anything after the digits is a
|
|
66
|
+
pre-release or build marker and is ignored, so ``"0.2rc1"`` and
|
|
67
|
+
``"1.0.0+local"`` compare as ``0.2`` and ``1.0.0``. A component that
|
|
68
|
+
does not begin with a digit at all (``"0.1.x"``, ``"not-a-version"``)
|
|
69
|
+
makes the whole string unusable rather than being guessed at, which is
|
|
70
|
+
the one case a version check must not get wrong.
|
|
71
|
+
"""
|
|
72
|
+
parts: list[int] = []
|
|
73
|
+
for piece in version_string.split(".")[:width]:
|
|
74
|
+
digits = ""
|
|
75
|
+
for char in piece:
|
|
76
|
+
if not char.isdigit():
|
|
77
|
+
break
|
|
78
|
+
digits += char
|
|
79
|
+
if not digits:
|
|
80
|
+
return None
|
|
81
|
+
parts.append(int(digits))
|
|
82
|
+
if not parts:
|
|
83
|
+
return None
|
|
84
|
+
return tuple(parts + [0] * (width - len(parts)))
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def is_compatible_version(
|
|
88
|
+
version_string: str, minimum: tuple[int, ...]
|
|
89
|
+
) -> bool:
|
|
90
|
+
"""Return True if version_string meets or exceeds minimum version tuple.
|
|
91
|
+
|
|
92
|
+
Handles version strings with fewer than 3 components by treating missing
|
|
93
|
+
components as 0 for comparison purposes, and ignores a pre-release or
|
|
94
|
+
build suffix on a component.
|
|
95
|
+
|
|
96
|
+
For example, with minimum=(0, 1, 0):
|
|
97
|
+
- "0.1" -> (0, 1, 0) -> True (equal)
|
|
98
|
+
- "0.1.0" -> (0, 1, 0) -> True (equal)
|
|
99
|
+
- "0.2rc1" -> (0, 2, 0) -> True (greater than)
|
|
100
|
+
- "0.0.9" -> (0, 0, 9) -> False (less than)
|
|
101
|
+
- "1" -> (1, 0, 0) -> True (greater than)
|
|
102
|
+
- "0.1.x"/"not-a-version"/None -> False (not a version)
|
|
103
|
+
"""
|
|
104
|
+
if not isinstance(version_string, str):
|
|
105
|
+
return False
|
|
106
|
+
current = _release_parts(version_string, len(minimum))
|
|
107
|
+
return current is not None and current >= minimum
|
vinsynlib/cli.py
ADDED
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
# SPDX-License-Identifier: GPL-2.0-or-later
|
|
2
|
+
# SPDX-FileCopyrightText: Copyright (C) 2026 vinsynlib contributors
|
|
3
|
+
#
|
|
4
|
+
# This file is part of vinsynlib.
|
|
5
|
+
#
|
|
6
|
+
# vinsynlib is free software: you can redistribute it and/or modify it under
|
|
7
|
+
# the terms of the GNU General Public License as published by the Free
|
|
8
|
+
# Software Foundation, either version 2 of the License, or (at your option)
|
|
9
|
+
# any later version.
|
|
10
|
+
#
|
|
11
|
+
# vinsynlib is distributed in the hope that it will be useful, but WITHOUT
|
|
12
|
+
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
|
|
13
|
+
# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
|
|
14
|
+
# more details.
|
|
15
|
+
|
|
16
|
+
"""Building the same command line nine times.
|
|
17
|
+
|
|
18
|
+
:func:`add_common_arguments` puts the family's options on a parser, in the
|
|
19
|
+
family's order, with the family's help text and the family's older
|
|
20
|
+
spellings still accepted. A tool adds only what its hardware needs.
|
|
21
|
+
|
|
22
|
+
The reason this is a function and not a paragraph in a README is that the
|
|
23
|
+
tools had already drifted nine ways and nobody noticed, because there was
|
|
24
|
+
nothing to notice *against*. Now there is.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import argparse
|
|
30
|
+
from collections.abc import Sequence
|
|
31
|
+
from importlib.metadata import (
|
|
32
|
+
PackageNotFoundError,
|
|
33
|
+
)
|
|
34
|
+
from importlib.metadata import (
|
|
35
|
+
version as _dist_version,
|
|
36
|
+
)
|
|
37
|
+
from typing import Any
|
|
38
|
+
|
|
39
|
+
from . import spec
|
|
40
|
+
from .config import MAX_DEVICE_ID, MAX_MIDI_CHANNEL, MIDI_CHANNELS
|
|
41
|
+
|
|
42
|
+
__all__ = [
|
|
43
|
+
"add_common_arguments",
|
|
44
|
+
"append_flag_help",
|
|
45
|
+
"channel_of",
|
|
46
|
+
"make_parser",
|
|
47
|
+
"validate_common",
|
|
48
|
+
]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def make_parser(
|
|
52
|
+
prog: str,
|
|
53
|
+
description: str,
|
|
54
|
+
*,
|
|
55
|
+
epilog: str = "",
|
|
56
|
+
distribution: str | None = None,
|
|
57
|
+
version: str | None = None,
|
|
58
|
+
) -> argparse.ArgumentParser:
|
|
59
|
+
"""A parser with the family's conventions already applied.
|
|
60
|
+
|
|
61
|
+
Every tool's parser was written from scratch with the same three lines
|
|
62
|
+
in it. The conventions: the program is asked to print its own help on a
|
|
63
|
+
pipe rather than a width chosen by guesswork, and a subcommand is
|
|
64
|
+
required rather than defaulting to something surprising.
|
|
65
|
+
|
|
66
|
+
``--version`` reports the project's own version, found from the
|
|
67
|
+
installed distribution named by ``distribution`` -- which defaults to
|
|
68
|
+
``prog``, and is right for every tool's terminal front end, where the
|
|
69
|
+
command and the distribution share a name. It is NOT right for the pipe
|
|
70
|
+
front end: the distribution is ``eosed`` and the command is ``eoscli``,
|
|
71
|
+
so those callers name it. Guessing it from the caller's module would
|
|
72
|
+
work and would be the kind of clever this family keeps having to
|
|
73
|
+
unpick.
|
|
74
|
+
|
|
75
|
+
``version`` is for a caller that is not installed as a distribution at
|
|
76
|
+
all: a test, or a run straight from a checkout.
|
|
77
|
+
|
|
78
|
+
A tool that cannot be found as a distribution gets no ``--version``
|
|
79
|
+
rather than a bare ``--version`` that prints nothing useful: an option
|
|
80
|
+
that answers "which version?" with a shrug is worse than an option that
|
|
81
|
+
is not there.
|
|
82
|
+
"""
|
|
83
|
+
parser = argparse.ArgumentParser(
|
|
84
|
+
prog=prog,
|
|
85
|
+
description=description,
|
|
86
|
+
epilog=epilog or None,
|
|
87
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
88
|
+
)
|
|
89
|
+
found: str | None
|
|
90
|
+
if version is not None:
|
|
91
|
+
found = version
|
|
92
|
+
else:
|
|
93
|
+
found = _version_of(distribution or prog)
|
|
94
|
+
if found:
|
|
95
|
+
parser.add_argument(
|
|
96
|
+
"--version", action="version", version=f"%(prog)s {found}"
|
|
97
|
+
)
|
|
98
|
+
return parser
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _version_of(distribution: str) -> str | None:
|
|
102
|
+
"""The installed version of ``distribution``, or ``None``.
|
|
103
|
+
|
|
104
|
+
By name rather than by importing something: every tool in this family
|
|
105
|
+
installs a distribution named exactly what the command is called, and a
|
|
106
|
+
console script can be run from a virtualenv in which the package is not
|
|
107
|
+
importable yet. ``PackageNotFoundError`` is not an error here -- a
|
|
108
|
+
checkout that was never installed is a normal way to run these.
|
|
109
|
+
"""
|
|
110
|
+
try:
|
|
111
|
+
return _dist_version(distribution)
|
|
112
|
+
except PackageNotFoundError:
|
|
113
|
+
return None
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def add_common_arguments(
|
|
117
|
+
parser: argparse.ArgumentParser,
|
|
118
|
+
*,
|
|
119
|
+
port: bool = True,
|
|
120
|
+
scan: bool = False,
|
|
121
|
+
recv_port: bool = False,
|
|
122
|
+
channel: bool = True,
|
|
123
|
+
device_channel: bool = False,
|
|
124
|
+
device_id: bool = False,
|
|
125
|
+
exclusive_channel: bool = False,
|
|
126
|
+
demo: bool = True,
|
|
127
|
+
timeout: bool = False,
|
|
128
|
+
catalog: bool = False,
|
|
129
|
+
config: bool = True,
|
|
130
|
+
favorites: bool = False,
|
|
131
|
+
yes: bool = False,
|
|
132
|
+
allow_write: bool = False,
|
|
133
|
+
) -> argparse.ArgumentParser:
|
|
134
|
+
"""Add the shared options, in the family's order, to ``parser``.
|
|
135
|
+
|
|
136
|
+
Each keyword says whether this tool has the concept at all. A tool
|
|
137
|
+
without a channel does not get ``--channel``: a flag that is accepted
|
|
138
|
+
and then ignored is worse than no flag, because it teaches the user
|
|
139
|
+
that a flag can lie.
|
|
140
|
+
|
|
141
|
+
Returns the parser, so a caller can chain. Options are added in
|
|
142
|
+
:data:`spec.CANONICAL_FLAGS` order rather than in the order the
|
|
143
|
+
keywords are written, so ``--help`` reads the same in every tool.
|
|
144
|
+
"""
|
|
145
|
+
wanted = {
|
|
146
|
+
"port": port,
|
|
147
|
+
"scan": scan,
|
|
148
|
+
"recv-port": recv_port,
|
|
149
|
+
"channel": channel,
|
|
150
|
+
"device-channel": device_channel,
|
|
151
|
+
"device-id": device_id,
|
|
152
|
+
"exclusive-channel": exclusive_channel,
|
|
153
|
+
"demo": demo,
|
|
154
|
+
"timeout": timeout,
|
|
155
|
+
"catalog": catalog,
|
|
156
|
+
"config": config,
|
|
157
|
+
"favorites": favorites,
|
|
158
|
+
"yes": yes,
|
|
159
|
+
"allow-write": allow_write,
|
|
160
|
+
}
|
|
161
|
+
for flag in spec.CANONICAL_FLAGS:
|
|
162
|
+
if not wanted.get(flag.name, False):
|
|
163
|
+
continue
|
|
164
|
+
# A flag may describe a settings cache the tool does not have. In a
|
|
165
|
+
# tool built with config=False the variant from the spec is used
|
|
166
|
+
# instead, so the wording still comes from the contract rather than
|
|
167
|
+
# from a branch here.
|
|
168
|
+
help_text = flag.help
|
|
169
|
+
if not config and flag.help_without_config:
|
|
170
|
+
help_text = flag.help_without_config
|
|
171
|
+
|
|
172
|
+
kwargs: dict[str, Any] = {
|
|
173
|
+
"help": help_text,
|
|
174
|
+
"dest": flag.name.replace("-", "_"),
|
|
175
|
+
}
|
|
176
|
+
if flag.kind == "flag":
|
|
177
|
+
kwargs["action"] = "store_true"
|
|
178
|
+
kwargs["default"] = False
|
|
179
|
+
elif flag.kind == "int":
|
|
180
|
+
kwargs["type"] = int
|
|
181
|
+
if flag.metavar:
|
|
182
|
+
kwargs["metavar"] = flag.metavar
|
|
183
|
+
elif flag.metavar:
|
|
184
|
+
kwargs["metavar"] = flag.metavar
|
|
185
|
+
parser.add_argument(*flag.options(), **kwargs)
|
|
186
|
+
return parser
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def append_flag_help(
|
|
190
|
+
parser: argparse.ArgumentParser, name: str, extra: str
|
|
191
|
+
) -> None:
|
|
192
|
+
"""Append ``extra`` to the help of one already-added option.
|
|
193
|
+
|
|
194
|
+
A tool that keeps the family's option but has one thing more to say about
|
|
195
|
+
it -- the phrasing of a default, a hint for finding a value -- uses this
|
|
196
|
+
rather than reaching into ``parser._actions`` itself or rewriting the
|
|
197
|
+
option. The family's help is the prefix and stays the prefix, so the
|
|
198
|
+
conformance check still recognises the flag.
|
|
199
|
+
|
|
200
|
+
``name`` is the canonical flag name from :data:`spec.CANONICAL_FLAGS`.
|
|
201
|
+
Silently does nothing if the option is not on this parser: a tool that did
|
|
202
|
+
not ask for the flag has nothing to append to.
|
|
203
|
+
"""
|
|
204
|
+
dest = name.replace("-", "_")
|
|
205
|
+
for action in parser._actions:
|
|
206
|
+
if action.dest == dest:
|
|
207
|
+
action.help = f"{action.help or ''} {extra}".rstrip()
|
|
208
|
+
return
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def validate_common(
|
|
212
|
+
args: argparse.Namespace,
|
|
213
|
+
*,
|
|
214
|
+
channel_names: Sequence[str] = ("channel", "device_channel"),
|
|
215
|
+
) -> None:
|
|
216
|
+
"""Check the shared options' values, and refuse a bad one by name.
|
|
217
|
+
|
|
218
|
+
Called before anything is opened, so a mistyped channel costs a
|
|
219
|
+
message rather than a wrong program change sent to a real instrument.
|
|
220
|
+
"""
|
|
221
|
+
for name in channel_names:
|
|
222
|
+
value = getattr(args, name, None)
|
|
223
|
+
if value is None:
|
|
224
|
+
continue
|
|
225
|
+
if not isinstance(value, int) or not 1 <= value <= MIDI_CHANNELS:
|
|
226
|
+
option = name.replace("_", "-")
|
|
227
|
+
raise SystemExit(f"error: --{option} is 1-{MIDI_CHANNELS}")
|
|
228
|
+
|
|
229
|
+
device_id = getattr(args, "device_id", None)
|
|
230
|
+
if device_id is not None and (
|
|
231
|
+
not isinstance(device_id, int) or not 0 <= device_id <= MAX_DEVICE_ID
|
|
232
|
+
):
|
|
233
|
+
raise SystemExit(f"error: --device-id is 0-{MAX_DEVICE_ID}")
|
|
234
|
+
|
|
235
|
+
exclusive = getattr(args, "exclusive_channel", None)
|
|
236
|
+
if exclusive is not None and (
|
|
237
|
+
not isinstance(exclusive, int)
|
|
238
|
+
or not 0 <= exclusive <= MAX_MIDI_CHANNEL
|
|
239
|
+
):
|
|
240
|
+
raise SystemExit(f"error: --exclusive-channel is 0-{MAX_MIDI_CHANNEL}")
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def channel_of(
|
|
244
|
+
args: argparse.Namespace, *, default: int | None = None
|
|
245
|
+
) -> int | None:
|
|
246
|
+
"""The channel to send on: the option if given, else ``default``.
|
|
247
|
+
|
|
248
|
+
Kept as a function because two tools disagreed about whether
|
|
249
|
+
``--channel`` was 0-based or 1-based, and a 1-based option that is
|
|
250
|
+
passed straight to ``0xC0 |`` is off by one channel with no error
|
|
251
|
+
anywhere.
|
|
252
|
+
"""
|
|
253
|
+
value = getattr(args, "channel", None)
|
|
254
|
+
if isinstance(value, int):
|
|
255
|
+
return value - 1
|
|
256
|
+
return default
|