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.
Files changed (35) hide show
  1. {vinsynlib-0.2.0/src/vinsynlib.egg-info → vinsynlib-0.3.0}/PKG-INFO +1 -1
  2. {vinsynlib-0.2.0 → vinsynlib-0.3.0}/src/vinsynlib/__init__.py +1 -1
  3. {vinsynlib-0.2.0 → vinsynlib-0.3.0}/src/vinsynlib/conformance.py +24 -13
  4. vinsynlib-0.3.0/src/vinsynlib/devchecks.py +440 -0
  5. {vinsynlib-0.2.0 → vinsynlib-0.3.0}/src/vinsynlib/favorites.py +116 -24
  6. {vinsynlib-0.2.0 → vinsynlib-0.3.0}/src/vinsynlib/midi.py +6 -4
  7. {vinsynlib-0.2.0 → vinsynlib-0.3.0}/src/vinsynlib/spec.py +19 -5
  8. {vinsynlib-0.2.0 → vinsynlib-0.3.0}/src/vinsynlib/terms.py +7 -1
  9. {vinsynlib-0.2.0 → vinsynlib-0.3.0/src/vinsynlib.egg-info}/PKG-INFO +1 -1
  10. {vinsynlib-0.2.0 → vinsynlib-0.3.0}/tests/test_favorites.py +34 -2
  11. {vinsynlib-0.2.0 → vinsynlib-0.3.0}/tests/test_midi_and_checks.py +184 -0
  12. {vinsynlib-0.2.0 → vinsynlib-0.3.0}/tests/test_ux.py +84 -0
  13. vinsynlib-0.2.0/src/vinsynlib/devchecks.py +0 -148
  14. {vinsynlib-0.2.0 → vinsynlib-0.3.0}/COPYING +0 -0
  15. {vinsynlib-0.2.0 → vinsynlib-0.3.0}/DISCLAIMER.md +0 -0
  16. {vinsynlib-0.2.0 → vinsynlib-0.3.0}/LICENSE +0 -0
  17. {vinsynlib-0.2.0 → vinsynlib-0.3.0}/MANIFEST.in +0 -0
  18. {vinsynlib-0.2.0 → vinsynlib-0.3.0}/Makefile +0 -0
  19. {vinsynlib-0.2.0 → vinsynlib-0.3.0}/README.md +0 -0
  20. {vinsynlib-0.2.0 → vinsynlib-0.3.0}/docs/UX-SPEC.md +0 -0
  21. {vinsynlib-0.2.0 → vinsynlib-0.3.0}/pyproject.toml +0 -0
  22. {vinsynlib-0.2.0 → vinsynlib-0.3.0}/setup.cfg +0 -0
  23. {vinsynlib-0.2.0 → vinsynlib-0.3.0}/src/vinsynlib/cli.py +0 -0
  24. {vinsynlib-0.2.0 → vinsynlib-0.3.0}/src/vinsynlib/config.py +0 -0
  25. {vinsynlib-0.2.0 → vinsynlib-0.3.0}/src/vinsynlib/keys.py +0 -0
  26. {vinsynlib-0.2.0 → vinsynlib-0.3.0}/src/vinsynlib/py.typed +0 -0
  27. {vinsynlib-0.2.0 → vinsynlib-0.3.0}/src/vinsynlib/ui/__init__.py +0 -0
  28. {vinsynlib-0.2.0 → vinsynlib-0.3.0}/src/vinsynlib/ui/hints.py +0 -0
  29. {vinsynlib-0.2.0 → vinsynlib-0.3.0}/src/vinsynlib.egg-info/SOURCES.txt +0 -0
  30. {vinsynlib-0.2.0 → vinsynlib-0.3.0}/src/vinsynlib.egg-info/dependency_links.txt +0 -0
  31. {vinsynlib-0.2.0 → vinsynlib-0.3.0}/src/vinsynlib.egg-info/requires.txt +0 -0
  32. {vinsynlib-0.2.0 → vinsynlib-0.3.0}/src/vinsynlib.egg-info/top_level.txt +0 -0
  33. {vinsynlib-0.2.0 → vinsynlib-0.3.0}/tests/test_config.py +0 -0
  34. {vinsynlib-0.2.0 → vinsynlib-0.3.0}/tests/test_hints.py +0 -0
  35. {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.2.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
@@ -56,7 +56,7 @@ __all__ = [
56
56
  "terms",
57
57
  ]
58
58
 
59
- __version__ = "0.2.0"
59
+ __version__ = "0.3.0"
60
60
 
61
61
 
62
62
  def _release_parts(version_string: str, width: int) -> tuple[int, ...] | None:
@@ -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 = keys_module.legend(
233
- extras, favourites=favourites, channel=channel, select=select
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
- 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:
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