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