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/config.py ADDED
@@ -0,0 +1,454 @@
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
+ # Assembled from the settings-cache handling that every browser in the
7
+ # family carried its own copy of:
8
+ # Copyright (C) 2026 emorphed contributors - GPL-2.0-or-later
9
+ # Copyright (C) 2026 ensqsqed contributors - GPL-2.0-or-later
10
+ # Copyright (C) 2026 eosed contributors - GPL-2.0-or-later
11
+ # Copyright (C) 2026 kwsed contributors - GPL-2.0-or-later
12
+ # Copyright (C) 2026 nanosyned contributors - GPL-2.0-or-later
13
+ # Copyright (C) 2026 p2ked contributors - GPL-2.0-or-later
14
+ # Copyright (C) 2026 rxved contributors - GPL-2.0-or-later
15
+ # Copyright (C) 2026 s3ked contributors - GPL-2.0-or-later
16
+ # Copyright (C) 2026 x5ded contributors - GPL-2.0-or-later
17
+ #
18
+ # vinsynlib is free software: you can redistribute it and/or modify it under
19
+ # the terms of the GNU General Public License as published by the Free
20
+ # Software Foundation, either version 2 of the License, or (at your option)
21
+ # any later version.
22
+ #
23
+ # vinsynlib is distributed in the hope that it will be useful, but WITHOUT
24
+ # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
25
+ # FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
26
+ # more details.
27
+
28
+ """The local settings cache: which port answered, and which channel.
29
+
30
+ **Disposable on purpose.** Unlike the favourites database, nothing here is
31
+ the user's own work -- it is a note of what was true last time, so that
32
+ starting the program again does not mean setting the same things again.
33
+ Deleting it costs one re-entry of each. That is why it lives in the working
34
+ directory rather than the data directory, why every failure to write it is
35
+ swallowed, and why it is gitignored.
36
+
37
+ Only :class:`OSError` is swallowed, mind: a read-only directory or a full
38
+ disk, where forgetting a preference beats refusing to run. Anything else is
39
+ a bug and should be heard. rxved's first attempt at remembering a channel
40
+ caught everything, and so never noticed that it was raising ``NameError``
41
+ on every single call and writing nothing at all.
42
+
43
+ One :class:`Settings` per application. The application name is only used in
44
+ the one warning message, but it is required, because that message has to
45
+ say which of the family is refusing to save -- otherwise a user with six of
46
+ these open has no way to tell which one is unhappy.
47
+ """
48
+
49
+ from __future__ import annotations
50
+
51
+ import contextlib
52
+ import os
53
+ import sys
54
+ from dataclasses import dataclass, field
55
+ from typing import Any
56
+
57
+ # Every project in the family requires 3.11 or later, and this library
58
+ # only ever imports under a newer interpreter than that, so the fallback
59
+ # below is unreachable in practice and is not tested. It is here because
60
+ # two of the copies it replaces had it and a reader would wonder where it
61
+ # went.
62
+ tomllib: Any
63
+ try:
64
+ import tomllib
65
+ except ModuleNotFoundError: # pragma: no cover - Python < 3.11
66
+ tomllib = None
67
+
68
+ __all__ = [
69
+ "DEFAULT_PATH",
70
+ "Settings",
71
+ "bind",
72
+ ]
73
+
74
+ #: Where the cache lives unless told otherwise. Relative on purpose: it is a
75
+ #: per-checkout note, gitignored, and one checkout per checkout.
76
+ DEFAULT_PATH = "config.toml"
77
+
78
+ #: MIDI has sixteen channels, zero-based on the wire and one-based on every
79
+ #: command line in this family. Both numbers are named because confusing
80
+ #: them costs a channel with no error anywhere.
81
+ MIDI_CHANNELS = 16
82
+ MAX_MIDI_CHANNEL = MIDI_CHANNELS - 1
83
+
84
+ #: A SysEx device ID is a byte. 127 is broadcast in some protocols and a
85
+ #: real device in others, which is why reading one back is range-checked
86
+ #: against a number rather than trusted.
87
+ MAX_DEVICE_ID = 127
88
+
89
+ #: DEL. The one control character with no TOML escape of its own.
90
+ DEL = 0x7F
91
+
92
+ _ESCAPES = {
93
+ "\\": "\\\\",
94
+ '"': '\\"',
95
+ "\n": "\\n",
96
+ "\r": "\\r",
97
+ "\t": "\\t",
98
+ "\b": "\\b",
99
+ "\f": "\\f",
100
+ }
101
+
102
+ #: The end of the first line written by :meth:`Settings.update`, after the
103
+ #: application's name. One string, because two things now read it: the writer
104
+ #: that puts it there, and the reader that refuses to treat another
105
+ #: application's file as its own. The prefix is deliberately loose -- a build
106
+ #: before this one wrote an em dash where this writes a hyphen, and a file
107
+ #: that old still has to read as ours rather than as a stranger's.
108
+ _HEADER = " local config - gitignored, safe to delete."
109
+
110
+
111
+ def _owner(first_line: str | None) -> str | None:
112
+ """The application named in a settings file's first line, or ``None``.
113
+
114
+ ``None`` means "not a family header": a hand-written file, an old build's
115
+ em dash, or no comment at all. Only a file that names *another* tool is
116
+ treated as foreign, so an unrecognised header stays the caller's own and
117
+ the lenient upgrade path keeps working.
118
+ """
119
+ if first_line is None:
120
+ return None
121
+ line = first_line.strip()
122
+ if not line.startswith("#"):
123
+ return None
124
+ body = line[1:].strip()
125
+ if not body.endswith(_HEADER):
126
+ return None
127
+ name = body[: -len(_HEADER)].strip()
128
+ return name or None
129
+
130
+
131
+ @dataclass
132
+ class Settings:
133
+ """One application's settings cache.
134
+
135
+ Construct once per process with the application's name, then call the
136
+ methods. Every method takes an optional ``path`` so a test can hand in
137
+ a temporary file; the default is this application's :data:`DEFAULT_PATH`.
138
+ """
139
+
140
+ app_name: str
141
+ default_path: str = DEFAULT_PATH
142
+ #: Which refusals have already been announced, so a launch prints each
143
+ #: at most once. Module level in the per-project copies this replaces,
144
+ #: which is to say it was shared by every ``Settings`` in the process;
145
+ #: per instance is closer to the intent and behaves the same for a
146
+ #: single application. A set rather than a bool because there are now
147
+ #: three reasons to refuse to write, and one must not silence another.
148
+ _warned: set[str] = field(default_factory=set, init=False, repr=False)
149
+
150
+ # --- reading -------------------------------------------------------------
151
+
152
+ def read(self, path: str | None = None) -> tuple[dict[str, Any], str]:
153
+ """The file's keys, and ``"ok"`` or ``"unreadable"``.
154
+
155
+ A file that does not exist reads as empty and is not an error: the
156
+ first run of a program has no settings yet, and that is the normal
157
+ case rather than a fault to report.
158
+
159
+ A file that exists but cannot be parsed reports ``"unreadable"``,
160
+ which :meth:`update` turns into a refusal rather than a blind
161
+ overwrite. Collapsing the two cases is what made a whole class of
162
+ bug invisible, so they are kept apart.
163
+
164
+ **Decoded leniently, then parsed as TOML.** Three of the copies this
165
+ replaces decoded the bytes themselves and fell back to cp1252 for
166
+ anything that was not valid UTF-8, and dropping that on the way into
167
+ the library was a regression rather than a simplification. The cause
168
+ was real: a writer that opened the file in text mode with no
169
+ ``encoding=`` used the locale codec, which on Windows is cp1252, and
170
+ the em dash in the writer's own header comment then landed as a byte
171
+ ``tomllib`` rejects. The whole file was refused, every setting in it
172
+ silently read back as unset, and -- because refusing to overwrite an
173
+ unparseable file is itself correct -- the settings cache could not
174
+ heal until somebody deleted it by hand.
175
+
176
+ Fixing the writer removed that one cause. It did not remove the
177
+ upgrade path: somebody who ran the broken build still has the file
178
+ it wrote. Decoding leniently lets their hand-edited keys survive, and
179
+ the next save rewrites the file as UTF-8, so it stays readable from
180
+ then on.
181
+
182
+ **A file that belongs to another tool reads as empty.** Nine of
183
+ these tools default to the same relative ``config.toml`` in whatever
184
+ directory they are launched from. When two of them meet in one
185
+ directory, the second must not read the first's remembered port: on
186
+ a bench that means probing the wrong instrument, and for the two
187
+ tools whose hardware shares a manufacturer it means a reply that
188
+ looks right. The file's own header says whose it is; see
189
+ :func:`_owner`.
190
+ """
191
+ target = path or self.default_path
192
+ data, status, first_line = self._read_document(target)
193
+ if status != "ok":
194
+ return data, status
195
+ owner = _owner(first_line)
196
+ if owner is not None and owner != self.app_name:
197
+ return {}, "ok"
198
+ return data, "ok"
199
+
200
+ def _read_document(
201
+ self, target: str
202
+ ) -> tuple[dict[str, Any], str, str | None]:
203
+ """The parsed file, its status, and its first line.
204
+
205
+ The unfiltered form of :meth:`read`: :meth:`update` needs the header
206
+ and any foreign content to decide whether writing is safe, while
207
+ callers of :meth:`read` only ever want this application's keys.
208
+ """
209
+ if not os.path.exists(target) or tomllib is None:
210
+ return {}, "ok", None
211
+ try:
212
+ with open(target, "rb") as handle:
213
+ raw = handle.read()
214
+ except OSError:
215
+ return {}, "unreadable", None
216
+ try:
217
+ text = raw.decode("utf-8")
218
+ except UnicodeDecodeError:
219
+ # Not valid UTF-8, so it was almost certainly written by a build
220
+ # using the locale codec. Decoded leniently so a user's
221
+ # hand-edited keys survive the upgrade; the next save repairs the
222
+ # encoding for good.
223
+ text = raw.decode("cp1252", errors="replace")
224
+ try:
225
+ data = tomllib.loads(text)
226
+ except ValueError:
227
+ return {}, "unreadable", None
228
+ first_line = text.split("\n", 1)[0]
229
+ return data, "ok", first_line
230
+
231
+ # --- writing -------------------------------------------------------------
232
+
233
+ def update(self, path: str | None = None, **changes: Any) -> None:
234
+ """Merge ``changes`` into the file, rewriting what is there.
235
+
236
+ Refuses, and says so once, in three cases, because in all three the
237
+ alternative is destroying something this tool did not write:
238
+
239
+ * the file cannot be parsed -- the refusal that is also why the
240
+ escaping below matters (a port name is an arbitrary string, and a
241
+ quote in one would otherwise produce a file that never heals);
242
+ * the file's header names a **different** tool in the family -- two
243
+ of these in one directory would otherwise overwrite each other's
244
+ remembered port on every launch, for ever;
245
+ * the file holds tables or lists -- this tool writes only scalars,
246
+ so anything nested belongs to somebody else (a foreign project's
247
+ ``config.toml`` looks exactly like this) and the old writer turned
248
+ it into Python ``repr`` and destroyed the file.
249
+
250
+ The write itself is atomic: a temporary file beside the target, then
251
+ ``os.replace``. The old writer truncated the target and refilled it,
252
+ so a reader running at the same time could see half a file. Only
253
+ :class:`OSError` is swallowed, as everywhere here.
254
+
255
+ To unset a key, set its value to ``None``.
256
+ """
257
+ target = path or self.default_path
258
+ data, status, first_line = self._read_document(target)
259
+ if status == "unreadable":
260
+ self._warn_once(
261
+ "unreadable",
262
+ f"{self.app_name}: {target} could not be parsed, so "
263
+ f"settings are not being saved. Fix or delete it; "
264
+ f"nothing has been overwritten.",
265
+ )
266
+ return
267
+ owner = _owner(first_line)
268
+ if owner is not None and owner != self.app_name:
269
+ self._warn_once(
270
+ "foreign",
271
+ f"{self.app_name}: {target} is {owner}'s settings file, so "
272
+ f"{self.app_name} is leaving it alone. Use --config to name "
273
+ f"a path of its own.",
274
+ )
275
+ return
276
+ if any(isinstance(value, (dict, list)) for value in data.values()):
277
+ self._warn_once(
278
+ "complex",
279
+ f"{self.app_name}: {target} holds tables or lists, which "
280
+ f"{self.app_name} does not write, so it is leaving it alone. "
281
+ f"Use --config to name a path of its own.",
282
+ )
283
+ return
284
+ for key, value in changes.items():
285
+ if value is None:
286
+ data.pop(key, None) # Remove key if present
287
+ else:
288
+ data[key] = value
289
+ lines = [f"# {self.app_name}{_HEADER}"]
290
+ for key, value in data.items():
291
+ if isinstance(value, bool):
292
+ lines.append(f"{key} = {'true' if value else 'false'}")
293
+ elif isinstance(value, str):
294
+ lines.append(f"{key} = {self._toml_string(value)}")
295
+ else:
296
+ lines.append(f"{key} = {value}")
297
+ self._write_atomic(target, "\n".join(lines) + "\n")
298
+
299
+ def _warn_once(self, reason: str, message: str) -> None:
300
+ """Print ``message`` once per reason, per instance."""
301
+ if reason in self._warned:
302
+ return
303
+ self._warned.add(reason)
304
+ print(message, file=sys.stderr)
305
+
306
+ @staticmethod
307
+ def _write_atomic(target: str, text: str) -> None:
308
+ """Write ``text`` to ``target`` through a temporary beside it.
309
+
310
+ ``encoding=`` is not optional: without it Python uses the locale
311
+ codec, and TOML is UTF-8 by spec. Both ends must say so.
312
+ """
313
+ tmp = f"{target}.{os.getpid()}.tmp"
314
+ try:
315
+ with open(tmp, "w", encoding="utf-8") as handle:
316
+ handle.write(text)
317
+ os.replace(tmp, target)
318
+ except OSError:
319
+ with contextlib.suppress(OSError):
320
+ os.unlink(tmp)
321
+
322
+ @staticmethod
323
+ def _toml_string(value: str) -> str:
324
+ """One TOML basic string, escaped.
325
+
326
+ TOML basic strings interpret the usual backslash escapes as well,
327
+ so a literal tab or newline is written as an escape rather than
328
+ embedded, and a control character that has no escape becomes a
329
+ ``\\uXXXX``.
330
+ """
331
+ out = ['"']
332
+ for char in value:
333
+ if char in _ESCAPES:
334
+ out.append(_ESCAPES[char])
335
+ elif ord(char) < 0x20 or ord(char) == DEL:
336
+ out.append(f"\\u{ord(char):04X}")
337
+ else:
338
+ out.append(char)
339
+ out.append('"')
340
+ return "".join(out)
341
+
342
+ # --- channel -------------------------------------------------------------
343
+
344
+ def load_channel(self, path: str | None = None) -> int | None:
345
+ """The channel last sent on, zero-based, or None.
346
+
347
+ The bool check is not decoration. ``isinstance(True, int)`` is True
348
+ and ``0 <= True <= 15`` is true, so a hand-edited ``channel = true``
349
+ used to come back as ``True`` and pass every range check downstream
350
+ -- and then ``0xC0 | True`` is ``0xC1``, which is MIDI **channel
351
+ 2**. A wrong channel is not an error anywhere: the instrument
352
+ simply plays nothing, or something else does.
353
+ """
354
+ value = self.read(path)[0].get("channel")
355
+ if isinstance(value, bool) or not isinstance(value, int):
356
+ return None
357
+ return value if 0 <= value <= MAX_MIDI_CHANNEL else None
358
+
359
+ def save_channel(self, channel: int, path: str | None = None) -> None:
360
+ self.update(path, channel=int(channel))
361
+
362
+ # --- ports ---------------------------------------------------------------
363
+
364
+ def load_port(self, path: str | None = None) -> str | None:
365
+ """The port name last used for output."""
366
+ value = self.read(path)[0].get("port")
367
+ return value if isinstance(value, str) and value else None
368
+
369
+ def save_port(self, port: str, path: str | None = None) -> None:
370
+ self.update(path, port=str(port))
371
+
372
+ def load_recv_port(self, path: str | None = None) -> str | None:
373
+ """The input port last opened, when it differed from the output one.
374
+
375
+ A separate key rather than a second port: most of these instruments
376
+ take their input from the same USB port they output to, and a
377
+ second key that is empty most of the time is the cheapest way to
378
+ remember the case where it is not.
379
+ """
380
+ value = self.read(path)[0].get("recv_port")
381
+ return value if isinstance(value, str) and value else None
382
+
383
+ def save_recv_port(self, port: str, path: str | None = None) -> None:
384
+ self.update(path, recv_port=str(port))
385
+
386
+ def load_ports(self, path: str | None = None) -> tuple[str, str] | None:
387
+ """Both remembered ports as a pair, or None.
388
+
389
+ Only when *both* are known. A half-remembered pair is not a pair:
390
+ three of the tools in this family open one port and read the other
391
+ from the same name, and treat an output-only or input-only memory
392
+ as nothing remembered at all rather than as half of what they need.
393
+ """
394
+ data = self.read(path)[0]
395
+ send = data.get("port")
396
+ recv = data.get("recv_port")
397
+ if isinstance(send, str) and send and isinstance(recv, str) and recv:
398
+ return send, recv
399
+ return None
400
+
401
+ def save_ports(
402
+ self, send_port: str, recv_port: str, path: str | None = None
403
+ ) -> None:
404
+ """Remember both ports at once, in one write to the file.
405
+
406
+ One write rather than two because the file is rewritten whole every
407
+ time: writing ``port`` and then ``recv_port`` separately reads and
408
+ rewrites between them, and a failure in between leaves the first
409
+ saved and the second lost.
410
+ """
411
+ self.update(path, port=str(send_port), recv_port=str(recv_port))
412
+
413
+ # --- device id -----------------------------------------------------------
414
+
415
+ def load_device_id(
416
+ self,
417
+ path: str | None = None,
418
+ *,
419
+ minimum: int = 0,
420
+ maximum: int = MAX_DEVICE_ID,
421
+ ) -> int | None:
422
+ """The device ID last used, or None.
423
+
424
+ ``isinstance(True, int)`` is True, so a hand-edited
425
+ ``device_id = true`` would come back as device 1 -- a real device,
426
+ and the wrong one. A wrong device ID is answered with silence by
427
+ these instruments, which is the failure mode that hides behind
428
+ every other.
429
+
430
+ ``minimum``/``maximum`` because a device ID is not one range
431
+ everywhere. It is a byte for most of these instruments, 0-127, and
432
+ on a Roland XV-2020 the synth *displays* it as the panel number
433
+ 17-32, which is the wire byte 0x10-0x1F plus one. That tool
434
+ therefore checks its own range, and it is right to: a value of 5
435
+ there is not a device ID that happens to be unused, it is a value
436
+ its own front panel cannot show, so returning it would hand back
437
+ something the user cannot confirm on the hardware.
438
+ """
439
+ value = self.read(path)[0].get("device_id")
440
+ if isinstance(value, bool) or not isinstance(value, int):
441
+ return None
442
+ return value if minimum <= value <= maximum else None
443
+
444
+ def save_device_id(self, device_id: int, path: str | None = None) -> None:
445
+ self.update(path, device_id=int(device_id))
446
+
447
+
448
+ def bind(app_name: str, default_path: str = DEFAULT_PATH) -> Settings:
449
+ """A :class:`Settings` for one application.
450
+
451
+ The shape every project used to hand-roll: one settings object, named
452
+ after the tool, with the tool's name in its one warning message.
453
+ """
454
+ return Settings(app_name=app_name, default_path=default_path)