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 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