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/conformance.py
ADDED
|
@@ -0,0 +1,274 @@
|
|
|
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 checks that keep the family one family.
|
|
17
|
+
|
|
18
|
+
Each function here answers one question and returns the problems it found
|
|
19
|
+
as a list of sentences, so a project's test can be the whole contract in a
|
|
20
|
+
dozen lines:
|
|
21
|
+
|
|
22
|
+
from vinsynlib import conformance
|
|
23
|
+
|
|
24
|
+
def test_the_family_contract():
|
|
25
|
+
problems = (
|
|
26
|
+
conformance.check_flags(build_parser(), required={"port", "demo"})
|
|
27
|
+
+ conformance.check_bindings(X5dedApp, channel=True)
|
|
28
|
+
+ conformance.check_legend(KEY_HINTS)
|
|
29
|
+
)
|
|
30
|
+
assert not problems, "\\n".join(problems)
|
|
31
|
+
|
|
32
|
+
Returning sentences rather than asserting is deliberate. A test that says
|
|
33
|
+
``--midi-channel is not a name this family uses; use --device-channel`` is
|
|
34
|
+
worth more than one that says ``False``.
|
|
35
|
+
|
|
36
|
+
What is checked, and why only these:
|
|
37
|
+
|
|
38
|
+
* **Flags.** The name, the help text and the old spelling all come from
|
|
39
|
+
:mod:`vinsynlib.spec`, so a tool cannot quietly reword ``--scan`` or drop
|
|
40
|
+
its alias without a test noticing.
|
|
41
|
+
* **Bindings.** The shared keys bind the shared actions. A tool that
|
|
42
|
+
renames ``f`` has to say why, in its own test.
|
|
43
|
+
* **The legend.** The shared blocks appear, in the shared order, and the
|
|
44
|
+
tool's own hints sit in the one place the family leaves room for them --
|
|
45
|
+
because a legend is a promise about which keys work, and a promise that
|
|
46
|
+
drifts is worse than no promise.
|
|
47
|
+
* **Terms.** The tool declared its vocabulary, and the words it uses for
|
|
48
|
+
the concepts it has are the declared ones.
|
|
49
|
+
|
|
50
|
+
Not checked, deliberately: the hardware. Which bank byte selects what is
|
|
51
|
+
the instrument's business and the family's has no opinion.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
from __future__ import annotations
|
|
55
|
+
|
|
56
|
+
import argparse
|
|
57
|
+
from collections.abc import Sequence
|
|
58
|
+
from typing import Any
|
|
59
|
+
|
|
60
|
+
from . import keys as keys_module
|
|
61
|
+
from . import spec
|
|
62
|
+
from .terms import Terminology
|
|
63
|
+
|
|
64
|
+
__all__ = [
|
|
65
|
+
"check_bindings",
|
|
66
|
+
"check_flags",
|
|
67
|
+
"check_legend",
|
|
68
|
+
"check_terms",
|
|
69
|
+
]
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def check_flags(
|
|
73
|
+
parser: argparse.ArgumentParser,
|
|
74
|
+
*,
|
|
75
|
+
required: Sequence[str] = (),
|
|
76
|
+
forbidden: Sequence[str] = (),
|
|
77
|
+
) -> list[str]:
|
|
78
|
+
"""Every required option present, worded the family's way.
|
|
79
|
+
|
|
80
|
+
``required`` names canonical flags from :data:`spec.CANONICAL_FLAGS`.
|
|
81
|
+
A required flag that is missing, or present with help text that does not
|
|
82
|
+
start with the family's help text, or present without its documented older
|
|
83
|
+
spelling, is a problem.
|
|
84
|
+
"""
|
|
85
|
+
problems: list[str] = []
|
|
86
|
+
known = {action.dest: action for action in parser._actions}
|
|
87
|
+
by_name = {f.name: f for f in spec.CANONICAL_FLAGS}
|
|
88
|
+
|
|
89
|
+
for name in required:
|
|
90
|
+
flag = by_name.get(name)
|
|
91
|
+
if flag is None:
|
|
92
|
+
problems.append(
|
|
93
|
+
f"{name!r} is not a flag this family uses; have "
|
|
94
|
+
f"{', '.join(sorted(by_name))}"
|
|
95
|
+
)
|
|
96
|
+
continue
|
|
97
|
+
action = known.get(flag.name.replace("-", "_"))
|
|
98
|
+
if action is None:
|
|
99
|
+
problems.append(f"--{name} is missing")
|
|
100
|
+
continue
|
|
101
|
+
action_help = action.help or ""
|
|
102
|
+
# The family's help, verbatim; the family's help plus a tool's own
|
|
103
|
+
# sentence appended; or, for a tool with no settings cache, the
|
|
104
|
+
# variant the spec declares for exactly that case. Nothing else -- a
|
|
105
|
+
# shorter prefix is not accepted, so a tool cannot quietly drop half a
|
|
106
|
+
# flag's help and still pass.
|
|
107
|
+
accepted = [flag.help]
|
|
108
|
+
if flag.help_without_config:
|
|
109
|
+
accepted.append(flag.help_without_config)
|
|
110
|
+
if not (action_help in accepted or action_help.startswith(flag.help)):
|
|
111
|
+
problems.append(
|
|
112
|
+
f"--{name} help text does not match the family's:\n"
|
|
113
|
+
f" family: {' or '.join(accepted)}\n"
|
|
114
|
+
f" here: {action_help}"
|
|
115
|
+
)
|
|
116
|
+
present = set(action.option_strings)
|
|
117
|
+
missing = set(flag.options()) - present
|
|
118
|
+
if missing:
|
|
119
|
+
problems.append(
|
|
120
|
+
f"--{name} no longer accepts "
|
|
121
|
+
f"{', '.join(sorted(missing))}; older command lines use it"
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
for name in forbidden:
|
|
125
|
+
if name.replace("-", "_") in known:
|
|
126
|
+
problems.append(f"--{name} should not be offered by this tool")
|
|
127
|
+
|
|
128
|
+
return problems
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _bindings_of(app: Any) -> dict[str, str]:
|
|
132
|
+
"""``key -> action name`` for one App class."""
|
|
133
|
+
out: dict[str, str] = {}
|
|
134
|
+
for binding in getattr(app, "BINDINGS", []):
|
|
135
|
+
key = getattr(binding, "key", None)
|
|
136
|
+
action = getattr(binding, "action", None)
|
|
137
|
+
if key and isinstance(action, str):
|
|
138
|
+
out[key] = action
|
|
139
|
+
return out
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _shared(
|
|
143
|
+
tier: str,
|
|
144
|
+
*,
|
|
145
|
+
favourites: bool,
|
|
146
|
+
channel: bool,
|
|
147
|
+
select: bool,
|
|
148
|
+
) -> list[keys_module.Key]:
|
|
149
|
+
"""The shared keys that apply to a tool with these concepts.
|
|
150
|
+
|
|
151
|
+
One place decides what "applies" means, so that the key check and the
|
|
152
|
+
action check cannot drift into disagreeing about which keys a tool is
|
|
153
|
+
being asked about.
|
|
154
|
+
"""
|
|
155
|
+
table = (
|
|
156
|
+
keys_module.EDITOR_KEYS
|
|
157
|
+
if tier == "editor"
|
|
158
|
+
else keys_module.BROWSER_KEYS
|
|
159
|
+
)
|
|
160
|
+
if tier != "editor" and favourites:
|
|
161
|
+
table = table + keys_module.FAVOURITE_KEYS
|
|
162
|
+
|
|
163
|
+
wanted: list[keys_module.Key] = []
|
|
164
|
+
for key in table:
|
|
165
|
+
if key.key == "enter" and not select:
|
|
166
|
+
continue
|
|
167
|
+
if not channel and key.legend in ("[ ] channel", "c set channel"):
|
|
168
|
+
continue
|
|
169
|
+
wanted.append(key)
|
|
170
|
+
return wanted
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def check_bindings(
|
|
174
|
+
app: Any,
|
|
175
|
+
*,
|
|
176
|
+
tier: str = "browser",
|
|
177
|
+
favourites: bool = True,
|
|
178
|
+
channel: bool = True,
|
|
179
|
+
select: bool = True,
|
|
180
|
+
) -> list[str]:
|
|
181
|
+
"""The shared keys bind the shared actions, or say why not.
|
|
182
|
+
|
|
183
|
+
The keyword flags mirror :func:`keys.legend`: a tool without a
|
|
184
|
+
favourites store is not asked for ``f``.
|
|
185
|
+
"""
|
|
186
|
+
have = _bindings_of(app)
|
|
187
|
+
problems: list[str] = []
|
|
188
|
+
|
|
189
|
+
# The key side: is the family's key bound to the family's action?
|
|
190
|
+
shared = _shared(
|
|
191
|
+
tier, favourites=favourites, channel=channel, select=select
|
|
192
|
+
)
|
|
193
|
+
for key in shared:
|
|
194
|
+
action = have.get(key.key)
|
|
195
|
+
if action is None:
|
|
196
|
+
problems.append(
|
|
197
|
+
f"{key.key} is not bound; the family binds it to {key.action}"
|
|
198
|
+
)
|
|
199
|
+
elif action != key.action:
|
|
200
|
+
problems.append(
|
|
201
|
+
f"{key.key} is bound to {action}; the family binds it to "
|
|
202
|
+
f"{key.action}"
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
# The action side: where did each family action actually go? A key that
|
|
206
|
+
# has been *renamed* -- f moved to a -- leaves every family key unbound,
|
|
207
|
+
# and checking only the keys would report a list of unbound keys and say
|
|
208
|
+
# nothing about where the action went.
|
|
209
|
+
where: dict[str, list[str]] = {}
|
|
210
|
+
for app_key, action in have.items():
|
|
211
|
+
where.setdefault(action, []).append(app_key)
|
|
212
|
+
for key in shared:
|
|
213
|
+
if key.key in where.get(key.action, []):
|
|
214
|
+
continue
|
|
215
|
+
elsewhere = ", ".join(sorted(where.get(key.action, []))) or "nothing"
|
|
216
|
+
problems.append(
|
|
217
|
+
f"{key.action} is not reachable from {key.key}; it is bound to "
|
|
218
|
+
f"{elsewhere}"
|
|
219
|
+
)
|
|
220
|
+
return problems
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def check_legend(
|
|
224
|
+
blocks: Sequence[str],
|
|
225
|
+
*,
|
|
226
|
+
extras: Sequence[str] = (),
|
|
227
|
+
favourites: bool = True,
|
|
228
|
+
channel: bool = True,
|
|
229
|
+
select: bool = True,
|
|
230
|
+
) -> list[str]:
|
|
231
|
+
"""The shared hints appear, in the shared order, with extras in place."""
|
|
232
|
+
expected = keys_module.legend(
|
|
233
|
+
extras, favourites=favourites, channel=channel, select=select
|
|
234
|
+
)
|
|
235
|
+
problems: list[str] = []
|
|
236
|
+
for hint in expected:
|
|
237
|
+
if hint not in blocks:
|
|
238
|
+
problems.append(f"the legend is missing {hint!r}")
|
|
239
|
+
for hint in blocks:
|
|
240
|
+
if hint not in expected:
|
|
241
|
+
problems.append(
|
|
242
|
+
f"the legend shows {hint!r}, which is not in the family's"
|
|
243
|
+
)
|
|
244
|
+
shared = [
|
|
245
|
+
b for b in blocks if b in keys_module.CANONICAL_LEGEND or b in extras
|
|
246
|
+
]
|
|
247
|
+
order = [
|
|
248
|
+
b
|
|
249
|
+
for b in keys_module.legend(
|
|
250
|
+
extras, favourites=favourites, channel=channel, select=select
|
|
251
|
+
)
|
|
252
|
+
if b in shared
|
|
253
|
+
]
|
|
254
|
+
if [b for b in shared if b in order] != order:
|
|
255
|
+
problems.append(
|
|
256
|
+
"the legend's hints are not in the family's order: expected "
|
|
257
|
+
+ ", ".join(order)
|
|
258
|
+
)
|
|
259
|
+
return problems
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def check_terms(terms: Terminology) -> list[str]:
|
|
263
|
+
"""The declared vocabulary is the one the family allows."""
|
|
264
|
+
problems: list[str] = []
|
|
265
|
+
try:
|
|
266
|
+
Terminology(
|
|
267
|
+
app_name=terms.app_name,
|
|
268
|
+
sound=terms.sound,
|
|
269
|
+
container=terms.container,
|
|
270
|
+
device=terms.device,
|
|
271
|
+
)
|
|
272
|
+
except ValueError as exc:
|
|
273
|
+
problems.append(str(exc))
|
|
274
|
+
return problems
|
vinsynlib/devchecks.py
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
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
|
+
"""Checks every project in the family should be running, written once.
|
|
17
|
+
|
|
18
|
+
These are not lints. They are the invariants that let nine separately
|
|
19
|
+
developed programs stay one family, and each of them was a bug that
|
|
20
|
+
actually happened here:
|
|
21
|
+
|
|
22
|
+
**A test must not write ``config.toml`` into the checkout.** The path is
|
|
23
|
+
relative, on purpose -- it is a disposable per-checkout cache -- and being
|
|
24
|
+
gitignored is exactly what makes this worth checking rather than
|
|
25
|
+
remembering. One was found next to the source, dated during a test run.
|
|
26
|
+
|
|
27
|
+
**A project must not import another project in the family.** ``emorphed``
|
|
28
|
+
had a function that imported ``nano.config``: ``nanosyned``'s package,
|
|
29
|
+
which of course is not installed next to it. It was a copy-paste slip from
|
|
30
|
+
the sibling the function was ported from, and it would have raised
|
|
31
|
+
``ImportError`` the moment ``--config`` was used.
|
|
32
|
+
|
|
33
|
+
**A project's public vocabulary must be declared.** A tool that has not
|
|
34
|
+
said what its instrument calls a stored sound has not finished being part
|
|
35
|
+
of this family, and a default would let it drift unnoticed.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
from __future__ import annotations
|
|
39
|
+
|
|
40
|
+
import ast
|
|
41
|
+
import os
|
|
42
|
+
import sys
|
|
43
|
+
from collections.abc import Iterable, Iterator, Sequence
|
|
44
|
+
|
|
45
|
+
__all__ = [
|
|
46
|
+
"FAMILY_THIRD_PARTY",
|
|
47
|
+
"check_foreign_imports",
|
|
48
|
+
"config_saves_without_path",
|
|
49
|
+
"foreign_imports",
|
|
50
|
+
"iter_test_sources",
|
|
51
|
+
]
|
|
52
|
+
|
|
53
|
+
#: Third-party modules every project in the family legitimately imports.
|
|
54
|
+
FAMILY_THIRD_PARTY = frozenset({"textual", "rtmidi", "rich", "vinsynlib"})
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def iter_test_sources(test_dir: str) -> Iterator[tuple[str, ast.Module]]:
|
|
58
|
+
"""Every test module in a directory, as ``(filename, parsed)``."""
|
|
59
|
+
for name in sorted(os.listdir(test_dir)):
|
|
60
|
+
if not (name.startswith("test_") and name.endswith(".py")):
|
|
61
|
+
continue
|
|
62
|
+
path = os.path.join(test_dir, name)
|
|
63
|
+
with open(path, encoding="utf-8") as handle:
|
|
64
|
+
yield name, ast.parse(handle.read())
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def config_saves_without_path(test_dir: str) -> list[str]:
|
|
68
|
+
"""Test calls of ``config.save_*`` that do not name a file.
|
|
69
|
+
|
|
70
|
+
Returns one ``file:lineno name`` string per offender. A save with no
|
|
71
|
+
path writes ``config.toml`` into whatever directory pytest was started
|
|
72
|
+
from, which is the checkout in practice.
|
|
73
|
+
"""
|
|
74
|
+
offenders: list[str] = []
|
|
75
|
+
for name, tree in iter_test_sources(test_dir):
|
|
76
|
+
for node in ast.walk(tree):
|
|
77
|
+
if not (
|
|
78
|
+
isinstance(node, ast.Call)
|
|
79
|
+
and isinstance(node.func, ast.Attribute)
|
|
80
|
+
and node.func.attr.startswith("save_")
|
|
81
|
+
and isinstance(node.func.value, ast.Name)
|
|
82
|
+
and node.func.value.id == "config"
|
|
83
|
+
):
|
|
84
|
+
continue
|
|
85
|
+
named = len(node.args) >= 2 or any(
|
|
86
|
+
kw.arg == "path" for kw in node.keywords
|
|
87
|
+
)
|
|
88
|
+
if not named:
|
|
89
|
+
offenders.append(f"{name}:{node.lineno} {node.func.attr}")
|
|
90
|
+
return offenders
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def own_namespaces(*packages: str) -> frozenset[str]:
|
|
94
|
+
"""The top-level names a project owns, given its package names."""
|
|
95
|
+
return frozenset(packages)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def foreign_imports(
|
|
99
|
+
path: str,
|
|
100
|
+
*,
|
|
101
|
+
own: Iterable[str],
|
|
102
|
+
third_party: Iterable[str] = FAMILY_THIRD_PARTY,
|
|
103
|
+
) -> list[str]:
|
|
104
|
+
"""Top-level imports in one file that belong to nobody here.
|
|
105
|
+
|
|
106
|
+
A name is foreign when it is not the standard library, not one of this
|
|
107
|
+
project's own packages, and not in the third-party allow-list. The
|
|
108
|
+
point is to catch a sibling project's package name in a function that
|
|
109
|
+
was ported from it -- an import that works in the project it was copied
|
|
110
|
+
from and raises ``ImportError`` in this one.
|
|
111
|
+
"""
|
|
112
|
+
allowed = set(own) | set(third_party)
|
|
113
|
+
stdlib: frozenset[str] = getattr(sys, "stdlib_module_names", frozenset())
|
|
114
|
+
with open(path, encoding="utf-8") as handle:
|
|
115
|
+
tree = ast.parse(handle.read())
|
|
116
|
+
|
|
117
|
+
found: list[str] = []
|
|
118
|
+
for node in ast.walk(tree):
|
|
119
|
+
if isinstance(node, ast.Import):
|
|
120
|
+
names = [alias.name for alias in node.names]
|
|
121
|
+
elif isinstance(node, ast.ImportFrom):
|
|
122
|
+
# `from . import x` has no module: a relative import is by
|
|
123
|
+
# definition this project's own.
|
|
124
|
+
if node.level:
|
|
125
|
+
continue
|
|
126
|
+
names = [node.module or ""]
|
|
127
|
+
else:
|
|
128
|
+
continue
|
|
129
|
+
for full in names:
|
|
130
|
+
top = full.split(".")[0]
|
|
131
|
+
if not top or top in allowed or top in stdlib:
|
|
132
|
+
continue
|
|
133
|
+
found.append(f"{os.path.basename(path)}:{node.lineno} {full}")
|
|
134
|
+
|
|
135
|
+
return sorted(set(found))
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def check_foreign_imports(
|
|
139
|
+
paths: Sequence[str],
|
|
140
|
+
*,
|
|
141
|
+
own: Iterable[str],
|
|
142
|
+
third_party: Iterable[str] = FAMILY_THIRD_PARTY,
|
|
143
|
+
) -> list[str]:
|
|
144
|
+
""":func:`foreign_imports` over several files."""
|
|
145
|
+
out: list[str] = []
|
|
146
|
+
for path in paths:
|
|
147
|
+
out.extend(foreign_imports(path, own=own, third_party=third_party))
|
|
148
|
+
return out
|