vinsynlib 0.2.0__tar.gz → 0.3.0__tar.gz
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-0.2.0/src/vinsynlib.egg-info → vinsynlib-0.3.0}/PKG-INFO +1 -1
- {vinsynlib-0.2.0 → vinsynlib-0.3.0}/src/vinsynlib/__init__.py +1 -1
- {vinsynlib-0.2.0 → vinsynlib-0.3.0}/src/vinsynlib/conformance.py +24 -13
- vinsynlib-0.3.0/src/vinsynlib/devchecks.py +440 -0
- {vinsynlib-0.2.0 → vinsynlib-0.3.0}/src/vinsynlib/favorites.py +116 -24
- {vinsynlib-0.2.0 → vinsynlib-0.3.0}/src/vinsynlib/midi.py +6 -4
- {vinsynlib-0.2.0 → vinsynlib-0.3.0}/src/vinsynlib/spec.py +19 -5
- {vinsynlib-0.2.0 → vinsynlib-0.3.0}/src/vinsynlib/terms.py +7 -1
- {vinsynlib-0.2.0 → vinsynlib-0.3.0/src/vinsynlib.egg-info}/PKG-INFO +1 -1
- {vinsynlib-0.2.0 → vinsynlib-0.3.0}/tests/test_favorites.py +34 -2
- {vinsynlib-0.2.0 → vinsynlib-0.3.0}/tests/test_midi_and_checks.py +184 -0
- {vinsynlib-0.2.0 → vinsynlib-0.3.0}/tests/test_ux.py +84 -0
- vinsynlib-0.2.0/src/vinsynlib/devchecks.py +0 -148
- {vinsynlib-0.2.0 → vinsynlib-0.3.0}/COPYING +0 -0
- {vinsynlib-0.2.0 → vinsynlib-0.3.0}/DISCLAIMER.md +0 -0
- {vinsynlib-0.2.0 → vinsynlib-0.3.0}/LICENSE +0 -0
- {vinsynlib-0.2.0 → vinsynlib-0.3.0}/MANIFEST.in +0 -0
- {vinsynlib-0.2.0 → vinsynlib-0.3.0}/Makefile +0 -0
- {vinsynlib-0.2.0 → vinsynlib-0.3.0}/README.md +0 -0
- {vinsynlib-0.2.0 → vinsynlib-0.3.0}/docs/UX-SPEC.md +0 -0
- {vinsynlib-0.2.0 → vinsynlib-0.3.0}/pyproject.toml +0 -0
- {vinsynlib-0.2.0 → vinsynlib-0.3.0}/setup.cfg +0 -0
- {vinsynlib-0.2.0 → vinsynlib-0.3.0}/src/vinsynlib/cli.py +0 -0
- {vinsynlib-0.2.0 → vinsynlib-0.3.0}/src/vinsynlib/config.py +0 -0
- {vinsynlib-0.2.0 → vinsynlib-0.3.0}/src/vinsynlib/keys.py +0 -0
- {vinsynlib-0.2.0 → vinsynlib-0.3.0}/src/vinsynlib/py.typed +0 -0
- {vinsynlib-0.2.0 → vinsynlib-0.3.0}/src/vinsynlib/ui/__init__.py +0 -0
- {vinsynlib-0.2.0 → vinsynlib-0.3.0}/src/vinsynlib/ui/hints.py +0 -0
- {vinsynlib-0.2.0 → vinsynlib-0.3.0}/src/vinsynlib.egg-info/SOURCES.txt +0 -0
- {vinsynlib-0.2.0 → vinsynlib-0.3.0}/src/vinsynlib.egg-info/dependency_links.txt +0 -0
- {vinsynlib-0.2.0 → vinsynlib-0.3.0}/src/vinsynlib.egg-info/requires.txt +0 -0
- {vinsynlib-0.2.0 → vinsynlib-0.3.0}/src/vinsynlib.egg-info/top_level.txt +0 -0
- {vinsynlib-0.2.0 → vinsynlib-0.3.0}/tests/test_config.py +0 -0
- {vinsynlib-0.2.0 → vinsynlib-0.3.0}/tests/test_hints.py +0 -0
- {vinsynlib-0.2.0 → vinsynlib-0.3.0}/tests/test_version.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: vinsynlib
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Shared base for ROMpler instrument browsers: settings, favourites, banks, dumps, CLI and key conventions
|
|
5
5
|
License-Expression: GPL-2.0-or-later
|
|
6
6
|
Project-URL: Homepage, https://github.com/lentferj/vinsynlib
|
|
@@ -59,7 +59,7 @@ from typing import Any
|
|
|
59
59
|
|
|
60
60
|
from . import keys as keys_module
|
|
61
61
|
from . import spec
|
|
62
|
-
from .terms import Terminology
|
|
62
|
+
from .terms import FAMILY, Terminology
|
|
63
63
|
|
|
64
64
|
__all__ = [
|
|
65
65
|
"check_bindings",
|
|
@@ -229,8 +229,10 @@ def check_legend(
|
|
|
229
229
|
select: bool = True,
|
|
230
230
|
) -> list[str]:
|
|
231
231
|
"""The shared hints appear, in the shared order, with extras in place."""
|
|
232
|
-
expected =
|
|
233
|
-
|
|
232
|
+
expected = list(
|
|
233
|
+
keys_module.legend(
|
|
234
|
+
extras, favourites=favourites, channel=channel, select=select
|
|
235
|
+
)
|
|
234
236
|
)
|
|
235
237
|
problems: list[str] = []
|
|
236
238
|
for hint in expected:
|
|
@@ -241,17 +243,15 @@ def check_legend(
|
|
|
241
243
|
problems.append(
|
|
242
244
|
f"the legend shows {hint!r}, which is not in the family's"
|
|
243
245
|
)
|
|
244
|
-
shared
|
|
246
|
+
# Order. `shared` is the blocks this tool owns that the family also
|
|
247
|
+
# defines; `order` is the same set in the family's order. Comparing the
|
|
248
|
+
# block sequence against it catches a reordering, and a block that
|
|
249
|
+
# appears twice shows up as a difference rather than passing.
|
|
250
|
+
shared = {
|
|
245
251
|
b for b in blocks if b in keys_module.CANONICAL_LEGEND or b in extras
|
|
246
|
-
|
|
247
|
-
order = [
|
|
248
|
-
|
|
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:
|
|
252
|
+
}
|
|
253
|
+
order = [b for b in expected if b in shared]
|
|
254
|
+
if [b for b in blocks if b in shared] != order:
|
|
255
255
|
problems.append(
|
|
256
256
|
"the legend's hints are not in the family's order: expected "
|
|
257
257
|
+ ", ".join(order)
|
|
@@ -271,4 +271,15 @@ def check_terms(terms: Terminology) -> list[str]:
|
|
|
271
271
|
)
|
|
272
272
|
except ValueError as exc:
|
|
273
273
|
problems.append(str(exc))
|
|
274
|
+
# `Terminology.word` looks in `own` *before* FAMILY, so a tool's own
|
|
275
|
+
# word silently shadows the family's when the two share a concept. That
|
|
276
|
+
# is the one collision the module docstring warns about, and it was
|
|
277
|
+
# invisible here because the check never looked at `own`.
|
|
278
|
+
for concept in terms.own:
|
|
279
|
+
if concept in FAMILY:
|
|
280
|
+
problems.append(
|
|
281
|
+
f"{terms.app_name}: {concept!r} is a family word "
|
|
282
|
+
f"({FAMILY[concept]!r}), so {terms.app_name} cannot have its "
|
|
283
|
+
f"own spelling of it in Terminology.own"
|
|
284
|
+
)
|
|
274
285
|
return problems
|
|
@@ -0,0 +1,440 @@
|
|
|
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 copy
|
|
42
|
+
import difflib
|
|
43
|
+
import os
|
|
44
|
+
import sys
|
|
45
|
+
from collections.abc import Iterable, Iterator, Sequence
|
|
46
|
+
|
|
47
|
+
__all__ = [
|
|
48
|
+
"FAMILY_THIRD_PARTY",
|
|
49
|
+
"check_foreign_imports",
|
|
50
|
+
"config_saves_without_path",
|
|
51
|
+
"foreign_imports",
|
|
52
|
+
"iter_test_sources",
|
|
53
|
+
"release_parts_drift",
|
|
54
|
+
"release_parts_invariants",
|
|
55
|
+
]
|
|
56
|
+
|
|
57
|
+
#: Third-party modules every project in the family legitimately imports.
|
|
58
|
+
FAMILY_THIRD_PARTY = frozenset({"textual", "rtmidi", "rich", "vinsynlib"})
|
|
59
|
+
|
|
60
|
+
#: The name of the private helper every project's ``entry.py`` carries. It is
|
|
61
|
+
#: a copy *on purpose* -- a version gate must not import the library it is
|
|
62
|
+
#: checking, because a library too old to contain the gate would raise, which
|
|
63
|
+
#: is the exact failure the gate exists to prevent.
|
|
64
|
+
_PARTS_FUNCTION = "_release_parts"
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def iter_test_sources(test_dir: str) -> Iterator[tuple[str, ast.Module]]:
|
|
68
|
+
"""Every test module in a directory, as ``(filename, parsed)``."""
|
|
69
|
+
for name in sorted(os.listdir(test_dir)):
|
|
70
|
+
if not (name.startswith("test_") and name.endswith(".py")):
|
|
71
|
+
continue
|
|
72
|
+
path = os.path.join(test_dir, name)
|
|
73
|
+
with open(path, encoding="utf-8") as handle:
|
|
74
|
+
yield name, ast.parse(handle.read())
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
#: How many positional arguments each writer takes before ``path``. A table
|
|
78
|
+
#: rather than a single number, because the arity is not uniform:
|
|
79
|
+
#: ``save_ports`` takes two port names and ``path`` is the third. A call that
|
|
80
|
+
#: passes no more than this many positional arguments, and no ``path=``
|
|
81
|
+
#: keyword, has no path and writes ``./config.toml`` into the checkout.
|
|
82
|
+
#:
|
|
83
|
+
#: A project's own writer over the same store is listed under its own name.
|
|
84
|
+
#: An unlisted one falls back to :data:`_DEFAULT_WRITER_ARITY`.
|
|
85
|
+
_WRITER_ARITY: dict[str, int] = {
|
|
86
|
+
# vinsynlib's own.
|
|
87
|
+
"update": 0,
|
|
88
|
+
"save_channel": 1,
|
|
89
|
+
"save_port": 1,
|
|
90
|
+
"save_recv_port": 1,
|
|
91
|
+
"save_device_id": 1,
|
|
92
|
+
"save_ports": 2,
|
|
93
|
+
# The family's wrappers over the same store.
|
|
94
|
+
"save_last_port": 1,
|
|
95
|
+
"save_last_recv_port": 1,
|
|
96
|
+
"save_exclusive_channel": 1,
|
|
97
|
+
"save_last_ports": 2,
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
#: Assumed for a writer :data:`_WRITER_ARITY` has not been told about. One is
|
|
101
|
+
#: the shape these methods share -- the value or values, then the path -- and
|
|
102
|
+
#: a project that grows a two-value writer adds its name above. Being wrong
|
|
103
|
+
#: in this direction misses a call rather than reporting one that is fine.
|
|
104
|
+
_DEFAULT_WRITER_ARITY = 1
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def config_saves_without_path(test_dir: str) -> list[str]:
|
|
108
|
+
"""Test calls of ``config.save_*`` that do not name a file.
|
|
109
|
+
|
|
110
|
+
Returns one ``file:lineno name`` string per offender. A save with no
|
|
111
|
+
path writes ``config.toml`` into whatever directory pytest was started
|
|
112
|
+
from, which is the checkout in practice.
|
|
113
|
+
|
|
114
|
+
Deciding whether a call names a path is per-method, not one number:
|
|
115
|
+
``save_ports(send, recv, path)`` takes two values before it, so a
|
|
116
|
+
"two arguments means it has a path" rule reads
|
|
117
|
+
``config.save_ports("In", "Out")`` as safe and lets it write into the
|
|
118
|
+
checkout. See :data:`_WRITER_ARITY`.
|
|
119
|
+
|
|
120
|
+
Only calls on a name bound to ``config`` are examined, so a test that
|
|
121
|
+
imports the module under another spelling is not covered.
|
|
122
|
+
"""
|
|
123
|
+
offenders: list[str] = []
|
|
124
|
+
for name, tree in iter_test_sources(test_dir):
|
|
125
|
+
for node in ast.walk(tree):
|
|
126
|
+
if not (
|
|
127
|
+
isinstance(node, ast.Call)
|
|
128
|
+
and isinstance(node.func, ast.Attribute)
|
|
129
|
+
and node.func.attr.startswith("save_")
|
|
130
|
+
and isinstance(node.func.value, ast.Name)
|
|
131
|
+
and node.func.value.id == "config"
|
|
132
|
+
):
|
|
133
|
+
continue
|
|
134
|
+
before = _WRITER_ARITY.get(node.func.attr, _DEFAULT_WRITER_ARITY)
|
|
135
|
+
named = len(node.args) > before or any(
|
|
136
|
+
kw.arg == "path" for kw in node.keywords
|
|
137
|
+
)
|
|
138
|
+
if not named:
|
|
139
|
+
offenders.append(f"{name}:{node.lineno} {node.func.attr}")
|
|
140
|
+
return offenders
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def foreign_imports(
|
|
144
|
+
path: str,
|
|
145
|
+
*,
|
|
146
|
+
own: Iterable[str],
|
|
147
|
+
third_party: Iterable[str] = FAMILY_THIRD_PARTY,
|
|
148
|
+
) -> list[str]:
|
|
149
|
+
"""Top-level imports in one file that belong to nobody here.
|
|
150
|
+
|
|
151
|
+
A name is foreign when it is not the standard library, not one of this
|
|
152
|
+
project's own packages, and not in the third-party allow-list. The
|
|
153
|
+
point is to catch a sibling project's package name in a function that
|
|
154
|
+
was ported from it -- an import that works in the project it was copied
|
|
155
|
+
from and raises ``ImportError`` in this one.
|
|
156
|
+
"""
|
|
157
|
+
allowed = set(own) | set(third_party)
|
|
158
|
+
stdlib: frozenset[str] = getattr(sys, "stdlib_module_names", frozenset())
|
|
159
|
+
with open(path, encoding="utf-8") as handle:
|
|
160
|
+
tree = ast.parse(handle.read())
|
|
161
|
+
|
|
162
|
+
found: list[str] = []
|
|
163
|
+
for node in ast.walk(tree):
|
|
164
|
+
if isinstance(node, ast.Import):
|
|
165
|
+
names = [alias.name for alias in node.names]
|
|
166
|
+
elif isinstance(node, ast.ImportFrom):
|
|
167
|
+
# `from . import x` has no module: a relative import is by
|
|
168
|
+
# definition this project's own.
|
|
169
|
+
if node.level:
|
|
170
|
+
continue
|
|
171
|
+
names = [node.module or ""]
|
|
172
|
+
else:
|
|
173
|
+
continue
|
|
174
|
+
for full in names:
|
|
175
|
+
top = full.split(".")[0]
|
|
176
|
+
if not top or top in allowed or top in stdlib:
|
|
177
|
+
continue
|
|
178
|
+
found.append(f"{os.path.basename(path)}:{node.lineno} {full}")
|
|
179
|
+
|
|
180
|
+
return sorted(set(found))
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def check_foreign_imports(
|
|
184
|
+
paths: Sequence[str],
|
|
185
|
+
*,
|
|
186
|
+
own: Iterable[str],
|
|
187
|
+
third_party: Iterable[str] = FAMILY_THIRD_PARTY,
|
|
188
|
+
) -> list[str]:
|
|
189
|
+
""":func:`foreign_imports` over several files."""
|
|
190
|
+
out: list[str] = []
|
|
191
|
+
for path in paths:
|
|
192
|
+
out.extend(foreign_imports(path, own=own, third_party=third_party))
|
|
193
|
+
return out
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
# --- the version gate copies -------------------------------------------------
|
|
197
|
+
#
|
|
198
|
+
# Every project carries a copy of `_release_parts`. The duplication is
|
|
199
|
+
# deliberate and must never be "fixed" by making the gate import the library
|
|
200
|
+
# it checks. What it needs is a check that the copies stay identical, and
|
|
201
|
+
# that is what this section is.
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _canonical_path() -> str:
|
|
205
|
+
"""The library's own ``__init__.py`` -- the copy everything must match."""
|
|
206
|
+
return os.path.join(
|
|
207
|
+
os.path.dirname(os.path.abspath(__file__)), "__init__.py"
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _parts_function(path: str) -> ast.FunctionDef | None:
|
|
212
|
+
"""The top-level ``_release_parts`` in one file, or ``None``."""
|
|
213
|
+
try:
|
|
214
|
+
with open(path, encoding="utf-8") as handle:
|
|
215
|
+
tree = ast.parse(handle.read())
|
|
216
|
+
except (OSError, SyntaxError):
|
|
217
|
+
return None
|
|
218
|
+
for node in tree.body:
|
|
219
|
+
if isinstance(node, ast.FunctionDef) and node.name == _PARTS_FUNCTION:
|
|
220
|
+
return node
|
|
221
|
+
return None
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _normalised(node: ast.FunctionDef) -> ast.FunctionDef:
|
|
225
|
+
"""A copy of ``node`` with the two spellings that are not drift erased.
|
|
226
|
+
|
|
227
|
+
The library names its first parameter ``version_string`` because its
|
|
228
|
+
public function does; every project names it ``version``. The docstring's
|
|
229
|
+
wording differs for the same reason. Neither is a difference in
|
|
230
|
+
behaviour, so both are erased -- and nothing else is, which is what makes
|
|
231
|
+
the comparison worth running.
|
|
232
|
+
"""
|
|
233
|
+
first = node.args.args[0].arg
|
|
234
|
+
clone = copy.deepcopy(node)
|
|
235
|
+
body = clone.body
|
|
236
|
+
if (
|
|
237
|
+
body
|
|
238
|
+
and isinstance(body[0], ast.Expr)
|
|
239
|
+
and isinstance(body[0].value, ast.Constant)
|
|
240
|
+
):
|
|
241
|
+
clone.body = body[1:]
|
|
242
|
+
for arg in clone.args.args:
|
|
243
|
+
if arg.arg == first:
|
|
244
|
+
arg.arg = "first"
|
|
245
|
+
for inner in ast.walk(clone):
|
|
246
|
+
if isinstance(inner, ast.Name) and inner.id == first:
|
|
247
|
+
inner.id = "first"
|
|
248
|
+
return clone
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _guard_on(node: ast.FunctionDef, name: str) -> ast.If | None:
|
|
252
|
+
"""The ``if not <name>:`` in one function, if there is one."""
|
|
253
|
+
for inner in ast.walk(node):
|
|
254
|
+
if not isinstance(inner, ast.If):
|
|
255
|
+
continue
|
|
256
|
+
test = inner.test
|
|
257
|
+
if (
|
|
258
|
+
isinstance(test, ast.UnaryOp)
|
|
259
|
+
and isinstance(test.op, ast.Not)
|
|
260
|
+
and isinstance(test.operand, ast.Name)
|
|
261
|
+
and test.operand.id == name
|
|
262
|
+
):
|
|
263
|
+
return inner
|
|
264
|
+
return None
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _refuses(arm: ast.If) -> bool:
|
|
268
|
+
"""Whether an ``if`` arm's body is exactly ``return None``."""
|
|
269
|
+
return bool(arm.body) and all(
|
|
270
|
+
isinstance(stmt, ast.Return)
|
|
271
|
+
and isinstance(stmt.value, ast.Constant)
|
|
272
|
+
and stmt.value.value is None
|
|
273
|
+
for stmt in arm.body
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def release_parts_invariants(path: str) -> list[str]:
|
|
278
|
+
"""The four things any project's ``_release_parts`` must keep true.
|
|
279
|
+
|
|
280
|
+
Checked against the copy in **one file**, and deliberately not against
|
|
281
|
+
the library's own: if both sides of a comparison are wrong the
|
|
282
|
+
comparison passes, so the library has to be held to these separately or
|
|
283
|
+
the whole guard can be disarmed by editing the canonical and propagating
|
|
284
|
+
the edit.
|
|
285
|
+
|
|
286
|
+
Each invariant is here because breaking it produces a specific bad
|
|
287
|
+
outcome rather than a general one:
|
|
288
|
+
|
|
289
|
+
* a component with no leading digit **refused** rather than skipped --
|
|
290
|
+
a ``break`` there reports a version it could not read as *compatible*,
|
|
291
|
+
which is the guess the docstring exists to forbid, and it fails open;
|
|
292
|
+
* the split bounded by ``width`` -- a hardcoded ``[:3]`` silently
|
|
293
|
+
truncated any comparison wider than three components;
|
|
294
|
+
* a **tuple** returned -- a list raises ``TypeError`` against a MINIMUM
|
|
295
|
+
and stops every tool that uses it;
|
|
296
|
+
* two parameters -- ``width`` is the width of the comparison and has to
|
|
297
|
+
come from the caller.
|
|
298
|
+
"""
|
|
299
|
+
function = _parts_function(path)
|
|
300
|
+
if function is None:
|
|
301
|
+
return [f"{path} has no {_PARTS_FUNCTION} to check"]
|
|
302
|
+
problems: list[str] = []
|
|
303
|
+
|
|
304
|
+
names = [arg.arg for arg in function.args.args]
|
|
305
|
+
if len(names) != 2 or names[1] != "width":
|
|
306
|
+
problems.append(
|
|
307
|
+
f"{path}: {_PARTS_FUNCTION} takes {names}; it needs two "
|
|
308
|
+
"parameters with width last"
|
|
309
|
+
)
|
|
310
|
+
|
|
311
|
+
bounded = any(
|
|
312
|
+
isinstance(node.slice, ast.Slice)
|
|
313
|
+
and isinstance(node.slice.upper, ast.Name)
|
|
314
|
+
and node.slice.upper.id == "width"
|
|
315
|
+
for node in ast.walk(function)
|
|
316
|
+
if isinstance(node, ast.Subscript)
|
|
317
|
+
)
|
|
318
|
+
if not bounded:
|
|
319
|
+
problems.append(
|
|
320
|
+
f"{path}: {_PARTS_FUNCTION} does not bound its split by `width`; "
|
|
321
|
+
"a literal there truncated any comparison wider than three "
|
|
322
|
+
"components, which was a bug once already"
|
|
323
|
+
)
|
|
324
|
+
|
|
325
|
+
# The dangerous break is the one in the guard's arm, not any break: the
|
|
326
|
+
# inner loop breaks on the first non-digit by design, and that is what
|
|
327
|
+
# makes "0.2rc1" read as 0.2. What must never happen is the outer loop
|
|
328
|
+
# *skipping* a component it could not parse, which fails open.
|
|
329
|
+
arm = _guard_on(function, "digits")
|
|
330
|
+
if arm is not None:
|
|
331
|
+
for node in ast.walk(arm):
|
|
332
|
+
if isinstance(node, (ast.Break, ast.Continue)):
|
|
333
|
+
problems.append(
|
|
334
|
+
f"{path}: {_PARTS_FUNCTION} uses "
|
|
335
|
+
f"{type(node).__name__.lower()} inside the "
|
|
336
|
+
"`if not digits` arm at line "
|
|
337
|
+
f"{node.lineno}, so a component it cannot parse would be "
|
|
338
|
+
"skipped instead of refused -- the gate then reports a "
|
|
339
|
+
"version it never read as compatible"
|
|
340
|
+
)
|
|
341
|
+
if not _refuses(arm):
|
|
342
|
+
problems.append(
|
|
343
|
+
f"{path}: {_PARTS_FUNCTION} does not `return None` when a "
|
|
344
|
+
"component has no leading digit, so an unreadable version is "
|
|
345
|
+
"guessed at instead of refused"
|
|
346
|
+
)
|
|
347
|
+
|
|
348
|
+
if not any(
|
|
349
|
+
isinstance(node, ast.Return)
|
|
350
|
+
and isinstance(node.value, ast.Call)
|
|
351
|
+
and isinstance(node.value.func, ast.Name)
|
|
352
|
+
and node.value.func.id == "tuple"
|
|
353
|
+
for node in ast.walk(function)
|
|
354
|
+
):
|
|
355
|
+
problems.append(
|
|
356
|
+
f"{path}: {_PARTS_FUNCTION} does not return a tuple(), which is "
|
|
357
|
+
"what makes it comparable against a MINIMUM"
|
|
358
|
+
)
|
|
359
|
+
return problems
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def _library_version() -> str:
|
|
363
|
+
"""The version of the library file this module compares against.
|
|
364
|
+
|
|
365
|
+
Read from the source rather than from ``importlib.metadata`` so that the
|
|
366
|
+
version named in a message is the one belonging to the file the message
|
|
367
|
+
is about.
|
|
368
|
+
"""
|
|
369
|
+
try:
|
|
370
|
+
with open(_canonical_path(), encoding="utf-8") as handle:
|
|
371
|
+
tree = ast.parse(handle.read())
|
|
372
|
+
except (OSError, SyntaxError): # pragma: no cover - our own file
|
|
373
|
+
return "unknown"
|
|
374
|
+
for node in tree.body:
|
|
375
|
+
if not (
|
|
376
|
+
isinstance(node, ast.Assign)
|
|
377
|
+
and any(
|
|
378
|
+
isinstance(target, ast.Name) and target.id == "__version__"
|
|
379
|
+
for target in node.targets
|
|
380
|
+
)
|
|
381
|
+
and isinstance(node.value, ast.Constant)
|
|
382
|
+
):
|
|
383
|
+
continue
|
|
384
|
+
return str(node.value.value)
|
|
385
|
+
return "unknown" # pragma: no cover - our own file
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def release_parts_drift(entry_path: str) -> list[str]:
|
|
389
|
+
"""Whether one project's ``_release_parts`` still matches the library's.
|
|
390
|
+
|
|
391
|
+
Every project carries its own copy, so that a version gate never imports
|
|
392
|
+
the library it is checking -- a library too old to contain the gate would
|
|
393
|
+
raise, which is the failure the gate exists to prevent. That duplication
|
|
394
|
+
is deliberate and must not be "fixed" by sharing it.
|
|
395
|
+
|
|
396
|
+
What it needs is a check that the copies stay identical, and the reason
|
|
397
|
+
is the shape of the failure when they do not: a drift lands in *one*
|
|
398
|
+
tool, so the symptom is "s3ked is broken with this vinsynlib and the
|
|
399
|
+
other nine work" -- a support ticket about one program, rather than a red
|
|
400
|
+
build anyone can see.
|
|
401
|
+
|
|
402
|
+
The comparison is against the **installed** library, because that is what
|
|
403
|
+
the project's gate actually runs against. A virtualenv that has not been
|
|
404
|
+
synced therefore reports drift that is only staleness, so the message
|
|
405
|
+
names the version it compared against and the file it came from.
|
|
406
|
+
|
|
407
|
+
A failing message carries a diff, not just a verdict: the point is to say
|
|
408
|
+
*what* drifted, and a diff does that better than an enumeration would.
|
|
409
|
+
"""
|
|
410
|
+
theirs = _parts_function(entry_path)
|
|
411
|
+
if theirs is None:
|
|
412
|
+
return [f"{entry_path} has no {_PARTS_FUNCTION} to check"]
|
|
413
|
+
canonical = _canonical_path()
|
|
414
|
+
ours = _parts_function(canonical)
|
|
415
|
+
if ours is None: # pragma: no cover - the library's own file
|
|
416
|
+
return [f"{canonical} has no {_PARTS_FUNCTION} to check"]
|
|
417
|
+
|
|
418
|
+
problems = release_parts_invariants(entry_path)
|
|
419
|
+
if ast.dump(_normalised(ours), include_attributes=False) == ast.dump(
|
|
420
|
+
_normalised(theirs), include_attributes=False
|
|
421
|
+
):
|
|
422
|
+
return problems
|
|
423
|
+
|
|
424
|
+
problems.append(
|
|
425
|
+
f"{entry_path}'s {_PARTS_FUNCTION} differs from the one in vinsynlib "
|
|
426
|
+
f"{_library_version()} ({canonical}). A project's copy must stay "
|
|
427
|
+
"identical to the library's; update this one, or the library's first "
|
|
428
|
+
"and then this one."
|
|
429
|
+
)
|
|
430
|
+
problems.extend(
|
|
431
|
+
" " + line
|
|
432
|
+
for line in difflib.unified_diff(
|
|
433
|
+
ast.unparse(_normalised(ours)).splitlines(),
|
|
434
|
+
ast.unparse(_normalised(theirs)).splitlines(),
|
|
435
|
+
"library",
|
|
436
|
+
"project",
|
|
437
|
+
lineterm="",
|
|
438
|
+
)
|
|
439
|
+
)
|
|
440
|
+
return problems
|