vinsynlib 0.4.1__tar.gz → 0.4.2__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 (63) hide show
  1. {vinsynlib-0.4.1/src/vinsynlib.egg-info → vinsynlib-0.4.2}/PKG-INFO +5 -3
  2. {vinsynlib-0.4.1 → vinsynlib-0.4.2}/README.md +4 -2
  3. {vinsynlib-0.4.1 → vinsynlib-0.4.2}/src/vinsynlib/__init__.py +1 -1
  4. vinsynlib-0.4.2/src/vinsynlib/build.py +305 -0
  5. {vinsynlib-0.4.1 → vinsynlib-0.4.2}/src/vinsynlib/cli.py +6 -15
  6. vinsynlib-0.4.2/src/vinsynlib/ui/report.py +98 -0
  7. {vinsynlib-0.4.1 → vinsynlib-0.4.2/src/vinsynlib.egg-info}/PKG-INFO +5 -3
  8. {vinsynlib-0.4.1 → vinsynlib-0.4.2}/src/vinsynlib.egg-info/SOURCES.txt +4 -0
  9. vinsynlib-0.4.2/tests/test_build.py +317 -0
  10. {vinsynlib-0.4.1 → vinsynlib-0.4.2}/tests/test_config.py +50 -1
  11. vinsynlib-0.4.2/tests/test_report.py +177 -0
  12. {vinsynlib-0.4.1 → vinsynlib-0.4.2}/COPYING +0 -0
  13. {vinsynlib-0.4.1 → vinsynlib-0.4.2}/DISCLAIMER.md +0 -0
  14. {vinsynlib-0.4.1 → vinsynlib-0.4.2}/LICENSE +0 -0
  15. {vinsynlib-0.4.1 → vinsynlib-0.4.2}/MANIFEST.in +0 -0
  16. {vinsynlib-0.4.1 → vinsynlib-0.4.2}/Makefile +0 -0
  17. {vinsynlib-0.4.1 → vinsynlib-0.4.2}/docs/UX-SPEC.md +0 -0
  18. {vinsynlib-0.4.1 → vinsynlib-0.4.2}/docs/unify2.md +0 -0
  19. {vinsynlib-0.4.1 → vinsynlib-0.4.2}/pyproject.toml +0 -0
  20. {vinsynlib-0.4.1 → vinsynlib-0.4.2}/setup.cfg +0 -0
  21. {vinsynlib-0.4.1 → vinsynlib-0.4.2}/src/vinsynlib/backup.py +0 -0
  22. {vinsynlib-0.4.1 → vinsynlib-0.4.2}/src/vinsynlib/catalog.py +0 -0
  23. {vinsynlib-0.4.1 → vinsynlib-0.4.2}/src/vinsynlib/config.py +0 -0
  24. {vinsynlib-0.4.1 → vinsynlib-0.4.2}/src/vinsynlib/conformance.py +0 -0
  25. {vinsynlib-0.4.1 → vinsynlib-0.4.2}/src/vinsynlib/devchecks.py +0 -0
  26. {vinsynlib-0.4.1 → vinsynlib-0.4.2}/src/vinsynlib/favorites.py +0 -0
  27. {vinsynlib-0.4.1 → vinsynlib-0.4.2}/src/vinsynlib/keys.py +0 -0
  28. {vinsynlib-0.4.1 → vinsynlib-0.4.2}/src/vinsynlib/livenames.py +0 -0
  29. {vinsynlib-0.4.1 → vinsynlib-0.4.2}/src/vinsynlib/midi.py +0 -0
  30. {vinsynlib-0.4.1 → vinsynlib-0.4.2}/src/vinsynlib/py.typed +0 -0
  31. {vinsynlib-0.4.1 → vinsynlib-0.4.2}/src/vinsynlib/render.py +0 -0
  32. {vinsynlib-0.4.1 → vinsynlib-0.4.2}/src/vinsynlib/spec.py +0 -0
  33. {vinsynlib-0.4.1 → vinsynlib-0.4.2}/src/vinsynlib/terms.py +0 -0
  34. {vinsynlib-0.4.1 → vinsynlib-0.4.2}/src/vinsynlib/ui/__init__.py +0 -0
  35. {vinsynlib-0.4.1 → vinsynlib-0.4.2}/src/vinsynlib/ui/browser.py +0 -0
  36. {vinsynlib-0.4.1 → vinsynlib-0.4.2}/src/vinsynlib/ui/confirm.py +0 -0
  37. {vinsynlib-0.4.1 → vinsynlib-0.4.2}/src/vinsynlib/ui/hints.py +0 -0
  38. {vinsynlib-0.4.1 → vinsynlib-0.4.2}/src/vinsynlib/ui/modals.py +0 -0
  39. {vinsynlib-0.4.1 → vinsynlib-0.4.2}/src/vinsynlib/ui/prompt.py +0 -0
  40. {vinsynlib-0.4.1 → vinsynlib-0.4.2}/src/vinsynlib/ui/protocol.py +0 -0
  41. {vinsynlib-0.4.1 → vinsynlib-0.4.2}/src/vinsynlib/ui/work.py +0 -0
  42. {vinsynlib-0.4.1 → vinsynlib-0.4.2}/src/vinsynlib/undo.py +0 -0
  43. {vinsynlib-0.4.1 → vinsynlib-0.4.2}/src/vinsynlib.egg-info/dependency_links.txt +0 -0
  44. {vinsynlib-0.4.1 → vinsynlib-0.4.2}/src/vinsynlib.egg-info/requires.txt +0 -0
  45. {vinsynlib-0.4.1 → vinsynlib-0.4.2}/src/vinsynlib.egg-info/top_level.txt +0 -0
  46. {vinsynlib-0.4.1 → vinsynlib-0.4.2}/tests/test_app_contract.py +0 -0
  47. {vinsynlib-0.4.1 → vinsynlib-0.4.2}/tests/test_backup.py +0 -0
  48. {vinsynlib-0.4.1 → vinsynlib-0.4.2}/tests/test_catalog.py +0 -0
  49. {vinsynlib-0.4.1 → vinsynlib-0.4.2}/tests/test_confirm_screen.py +0 -0
  50. {vinsynlib-0.4.1 → vinsynlib-0.4.2}/tests/test_favorites.py +0 -0
  51. {vinsynlib-0.4.1 → vinsynlib-0.4.2}/tests/test_hints.py +0 -0
  52. {vinsynlib-0.4.1 → vinsynlib-0.4.2}/tests/test_identity.py +0 -0
  53. {vinsynlib-0.4.1 → vinsynlib-0.4.2}/tests/test_midi_and_checks.py +0 -0
  54. {vinsynlib-0.4.1 → vinsynlib-0.4.2}/tests/test_modals.py +0 -0
  55. {vinsynlib-0.4.1 → vinsynlib-0.4.2}/tests/test_prompt_screen.py +0 -0
  56. {vinsynlib-0.4.1 → vinsynlib-0.4.2}/tests/test_rating_cell.py +0 -0
  57. {vinsynlib-0.4.1 → vinsynlib-0.4.2}/tests/test_readme_modules.py +0 -0
  58. {vinsynlib-0.4.1 → vinsynlib-0.4.2}/tests/test_render.py +0 -0
  59. {vinsynlib-0.4.1 → vinsynlib-0.4.2}/tests/test_transport.py +0 -0
  60. {vinsynlib-0.4.1 → vinsynlib-0.4.2}/tests/test_undo.py +0 -0
  61. {vinsynlib-0.4.1 → vinsynlib-0.4.2}/tests/test_ux.py +0 -0
  62. {vinsynlib-0.4.1 → vinsynlib-0.4.2}/tests/test_version.py +0 -0
  63. {vinsynlib-0.4.1 → vinsynlib-0.4.2}/tests/test_work.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: vinsynlib
3
- Version: 0.4.1
3
+ Version: 0.4.2
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
@@ -81,12 +81,14 @@ vinsynlib/
81
81
  catalog.py printed names, plus what has been read off the unit
82
82
  livenames.py names read off the unit, kept between runs
83
83
  render.py table and progress rendering, without Textual
84
+ build.py which build is running: version, commit, tag
84
85
  undo.py the undo log: inspectable records, no threads or locks
85
86
  ui/hints.py the legend widget
86
87
  ui/browser.py BrowserApp, the AppProtocol implementation
87
88
  ui/protocol.py the App contract screens depend on
88
89
  ui/work.py worker infrastructure: busy gate, error set, idle-close
89
90
  ui/confirm.py a generic yes/no confirmation modal
91
+ ui/report.py a scrollable block of text, as a modal
90
92
  ui/prompt.py a one-line modal text prompt
91
93
  ui/modals.py the shared modal screens: picker, search, report, rating
92
94
  ```
@@ -131,11 +133,11 @@ unit or emulator **before** connecting irreplaceable equipment.
131
133
  ## Installing it, and who depends on it
132
134
 
133
135
  This is a library, not a program. It is published on PyPI — the current
134
- release is 0.4.1 — so the ordinary way to depend on it is a dependency
136
+ release is 0.4.2 — so the ordinary way to depend on it is a dependency
135
137
  line:
136
138
 
137
139
  ```toml
138
- vinsynlib>=0.4.1
140
+ vinsynlib>=0.4.2
139
141
  ```
140
142
 
141
143
  ```sh
@@ -55,12 +55,14 @@ vinsynlib/
55
55
  catalog.py printed names, plus what has been read off the unit
56
56
  livenames.py names read off the unit, kept between runs
57
57
  render.py table and progress rendering, without Textual
58
+ build.py which build is running: version, commit, tag
58
59
  undo.py the undo log: inspectable records, no threads or locks
59
60
  ui/hints.py the legend widget
60
61
  ui/browser.py BrowserApp, the AppProtocol implementation
61
62
  ui/protocol.py the App contract screens depend on
62
63
  ui/work.py worker infrastructure: busy gate, error set, idle-close
63
64
  ui/confirm.py a generic yes/no confirmation modal
65
+ ui/report.py a scrollable block of text, as a modal
64
66
  ui/prompt.py a one-line modal text prompt
65
67
  ui/modals.py the shared modal screens: picker, search, report, rating
66
68
  ```
@@ -105,11 +107,11 @@ unit or emulator **before** connecting irreplaceable equipment.
105
107
  ## Installing it, and who depends on it
106
108
 
107
109
  This is a library, not a program. It is published on PyPI — the current
108
- release is 0.4.1 — so the ordinary way to depend on it is a dependency
110
+ release is 0.4.2 — so the ordinary way to depend on it is a dependency
109
111
  line:
110
112
 
111
113
  ```toml
112
- vinsynlib>=0.4.1
114
+ vinsynlib>=0.4.2
113
115
  ```
114
116
 
115
117
  ```sh
@@ -56,7 +56,7 @@ __all__ = [
56
56
  "terms",
57
57
  ]
58
58
 
59
- __version__ = "0.4.1"
59
+ __version__ = "0.4.2"
60
60
 
61
61
 
62
62
  def _release_parts(version_string: str, width: int) -> tuple[int, ...] | None:
@@ -0,0 +1,305 @@
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
+ """Which build of a component is running, as far as it can be known.
17
+
18
+ A support report needs two names: the tool's and the library's, because
19
+ they are installed separately and only one of them is usually the bug.
20
+ So the question is asked per *distribution*, by name, the way
21
+ :func:`vinsynlib.cli` finds a version -- and this module is where the
22
+ answer lives, so the CLI and a help screen cannot drift apart.
23
+
24
+ The answer is tiered, and the tier is the point:
25
+
26
+ - a **checkout** can prove a commit, so it says the sha, and the tag that
27
+ points at it when one does;
28
+ - a **release** cannot. A wheel is built from an sdist with no ``.git``
29
+ in it, so no sha is knowable there -- and it does not need one: the
30
+ family tags releases ``v<version>``, so ``0.4.1`` *is* the exact
31
+ identity and resolves to one commit in the repository.
32
+
33
+ So a build with no commit is not a failure, and it must not be dressed
34
+ up as one. Nothing here runs ``git``, reads the network, or guesses: a
35
+ value that cannot be proved stays ``None``.
36
+ """
37
+
38
+ from __future__ import annotations
39
+
40
+ import json
41
+ import zlib
42
+ from dataclasses import dataclass
43
+ from importlib.metadata import PackageNotFoundError
44
+ from importlib.metadata import distribution as _distribution
45
+ from importlib.metadata import version as _dist_version
46
+ from pathlib import Path
47
+ from urllib.parse import unquote, urlparse
48
+
49
+ __all__ = ["Build", "build_of", "version_of"]
50
+
51
+ #: A loose object lives at ``objects/xx/yyyy…``: two characters of
52
+ #: directory and at least one of file, so a sha shorter than this names
53
+ #: nothing that could be read.
54
+ _LOOSE_OBJECT_MINIMUM = 3
55
+
56
+
57
+ @dataclass(frozen=True, slots=True)
58
+ class Build:
59
+ """One component's build, in the strongest terms that could be proved.
60
+
61
+ ``version`` is the installed distribution's, or ``None`` for a
62
+ checkout that was never installed -- a normal way to run these
63
+ tools, and not an error. ``commit`` and ``tag`` are ``None`` exactly
64
+ when they could not be known.
65
+ """
66
+
67
+ version: str | None
68
+ commit: str | None = None
69
+ tag: str | None = None
70
+
71
+
72
+ def version_of(distribution: str) -> str | None:
73
+ """The installed version of ``distribution``, or ``None``.
74
+
75
+ By name rather than by importing something: every tool in this
76
+ family installs a distribution named exactly what the command is
77
+ called, and a console script can be run from a virtualenv in which
78
+ the package is not importable yet. ``PackageNotFoundError`` is not an
79
+ error here -- a checkout that was never installed is a normal way to
80
+ run these.
81
+ """
82
+ try:
83
+ return _dist_version(distribution)
84
+ except PackageNotFoundError:
85
+ return None
86
+
87
+
88
+ def build_of(distribution: str, origin: str | None = None) -> Build:
89
+ """Which build of ``distribution`` is running, as far as it can be known.
90
+
91
+ ``origin`` is an optional path *inside* the component -- typically
92
+ its ``__file__`` -- and is the last lead, for a checkout that was
93
+ never installed and so has no distribution metadata at all.
94
+ """
95
+ version = version_of(distribution)
96
+ commit, git_dir = _commit_for(distribution, origin)
97
+ tag = _tag_of(git_dir, commit) if git_dir is not None else None
98
+ return Build(version=version, commit=commit, tag=tag)
99
+
100
+
101
+ # --- where the checkout is ---------------------------------------------
102
+
103
+
104
+ def _commit_for(
105
+ distribution: str, origin: str | None
106
+ ) -> tuple[str | None, Path | None]:
107
+ """``(commit, git dir)`` for a distribution, or ``(None, None)``.
108
+
109
+ Three leads, strongest first: a VCS install names its commit in its
110
+ metadata (PEP 610); a local install names the checkout's path; a
111
+ caller can name a file inside it. The family runs the second way,
112
+ which is why the first is not the only one implemented.
113
+ """
114
+ try:
115
+ dist = _distribution(distribution)
116
+ except PackageNotFoundError:
117
+ dist = None
118
+ if dist is not None:
119
+ document = dist.read_text("direct_url.json")
120
+ if document:
121
+ metadata = json.loads(document)
122
+ commit = metadata.get("vcs_info", {}).get("commit_id")
123
+ if commit:
124
+ return commit, None
125
+ url = metadata.get("url")
126
+ if url and url.startswith("file:"):
127
+ found = _commit_in(_path_of_file_url(url))
128
+ if found:
129
+ return found
130
+ if origin is not None:
131
+ found = _commit_in(Path(origin).resolve().parent)
132
+ if found:
133
+ return found
134
+ return None, None
135
+
136
+
137
+ def _commit_in(start: Path) -> tuple[str, Path] | None:
138
+ """The commit ``start`` is on, and the repository holding it."""
139
+ git_dir = _git_dir(start)
140
+ if git_dir is None:
141
+ return None
142
+ commit = _commit_of(git_dir)
143
+ if commit is None:
144
+ return None
145
+ return commit, git_dir
146
+
147
+
148
+ def _path_of_file_url(url: str) -> Path:
149
+ """The path a ``file:`` URL names, as the local path it is.
150
+
151
+ The path component of a file URI is a *URI* path, not a filesystem
152
+ path, and the difference is where a drive letter sits: on Windows
153
+ it arrives as ``/C:/Users/...`` -- a drive with a leading slash in
154
+ front of it. :class:`~pathlib.Path` of that is drive-less and rooted
155
+ at ``/``, so it resolves against whichever drive the process happens
156
+ to be on rather than the one the URL names, and the checkout is
157
+ never found. Dropping that one slash is the whole of the conversion
158
+ on Windows, and on POSIX, where the path already is a filesystem
159
+ path, the same code is a no-op -- so the leading slash survives and
160
+ a ``//`` is never mistaken for a drive.
161
+ """
162
+ path = unquote(urlparse(url).path)
163
+ # A drive is a *character*, not a path level, so it shows up as
164
+ # ``/C:/…`` -- the slash is the URI's, not Windows'. A leading ``//``
165
+ # is not a drive letter, and ``path[1:2]`` on a short path is the
166
+ # empty string, which is not a letter.
167
+ if path[:1] == "/" and path[1:2].isalpha() and path[2:3] == ":":
168
+ return Path(path[1:])
169
+ return Path(path)
170
+
171
+
172
+ def _git_dir(start: Path) -> Path | None:
173
+ """The repository ``start`` sits in, searching upwards.
174
+
175
+ A ``.git`` that is a *file* is a worktree: it points at the real
176
+ repository, which is where the refs are, so it is followed rather
177
+ than mistaken for a corrupt checkout.
178
+ """
179
+ for candidate in (start, *start.parents):
180
+ dot = candidate / ".git"
181
+ if dot.is_dir():
182
+ return dot
183
+ if dot.is_file():
184
+ pointer = dot.read_text(encoding="utf-8", errors="replace")
185
+ if pointer.startswith("gitdir:"):
186
+ return Path(pointer.removeprefix("gitdir:").strip())
187
+ return None
188
+
189
+
190
+ # --- reading a commit out of one ---------------------------------------
191
+
192
+
193
+ def _commit_of(git_dir: Path) -> str | None:
194
+ """The commit ``HEAD`` names: a symbolic ref, or a detached sha."""
195
+ head = git_dir / "HEAD"
196
+ if not head.is_file():
197
+ return None
198
+ target = head.read_text(encoding="utf-8", errors="replace").strip()
199
+ if not target:
200
+ return None
201
+ if target.startswith("ref: "):
202
+ return _resolve_ref(git_dir, target.removeprefix("ref: ").strip())
203
+ return target
204
+
205
+
206
+ def _resolve_ref(git_dir: Path, ref: str) -> str | None:
207
+ """The sha ``ref`` names, from a loose ref or from ``packed-refs``."""
208
+ refs = _refs_dir(git_dir)
209
+ loose = refs / ref
210
+ if loose.is_file():
211
+ value = loose.read_text(encoding="utf-8", errors="replace").strip()
212
+ return value or None
213
+ packed = refs / "packed-refs"
214
+ if packed.is_file():
215
+ for line in packed.read_text(
216
+ encoding="utf-8", errors="replace"
217
+ ).splitlines():
218
+ if line.startswith("^") or " " not in line:
219
+ continue
220
+ sha, name = line.split(" ", 1)
221
+ if name.strip() == ref:
222
+ return sha
223
+ return None
224
+
225
+
226
+ def _refs_dir(git_dir: Path) -> Path:
227
+ """Where the refs live: this directory, or the common one it names.
228
+
229
+ A linked worktree keeps its own ``HEAD`` where it sits and its refs
230
+ in the repository, named by a ``commondir`` file. Stopping at the
231
+ worktree would find an empty ``refs`` and call a tag unproved when
232
+ it is one file away.
233
+ """
234
+ pointer = git_dir / "commondir"
235
+ if pointer.is_file():
236
+ target = pointer.read_text(encoding="utf-8", errors="replace")
237
+ if target.strip():
238
+ return (git_dir / target.strip()).resolve()
239
+ return git_dir
240
+
241
+
242
+ def _tag_of(git_dir: Path, commit: str | None) -> str | None:
243
+ """A tag pointing at ``commit`` exactly, if one can be proved.
244
+
245
+ A lightweight tag *is* the ref; an annotated one is a tag object
246
+ whose ``object`` line is the commit. A tag whose object sits in a
247
+ packfile is not claimed: proving that needs a packfile reader, and
248
+ an unproved tag is worse than a missing one.
249
+ """
250
+ if commit is None:
251
+ return None
252
+ for sha, name in _tag_refs(git_dir):
253
+ if sha == commit:
254
+ return name
255
+ if _annotated_target(git_dir, sha) == commit:
256
+ return name
257
+ return None
258
+
259
+
260
+ def _tag_refs(git_dir: Path) -> list[tuple[str, str]]:
261
+ """Every tag ref, as ``(sha, name)``, loose and packed."""
262
+ found: list[tuple[str, str]] = []
263
+ refs = _refs_dir(git_dir)
264
+ tags = refs / "refs" / "tags"
265
+ if tags.is_dir():
266
+ for path in sorted(tags.rglob("*")):
267
+ if path.is_file():
268
+ value = path.read_text(
269
+ encoding="utf-8", errors="replace"
270
+ ).strip()
271
+ if value:
272
+ found.append((value, path.relative_to(refs).as_posix()))
273
+ packed = refs / "packed-refs"
274
+ if packed.is_file():
275
+ for line in packed.read_text(
276
+ encoding="utf-8", errors="replace"
277
+ ).splitlines():
278
+ if line.startswith(("#", "^")) or " " not in line:
279
+ continue
280
+ sha, name = line.split(" ", 1)
281
+ if name.strip().startswith("refs/tags/"):
282
+ found.append((sha, name.strip()))
283
+ return found
284
+
285
+
286
+ def _annotated_target(git_dir: Path, sha: str) -> str | None:
287
+ """The commit a loose tag object points at, or ``None``.
288
+
289
+ ``None`` for a sha that is not a loose object at all -- which is
290
+ what a packfile, or any other kind of object, looks like from here.
291
+ """
292
+ obj = _refs_dir(git_dir) / "objects" / sha[:2] / sha[2:]
293
+ if not obj.is_file() or len(sha) < _LOOSE_OBJECT_MINIMUM:
294
+ return None
295
+ try:
296
+ raw = zlib.decompress(obj.read_bytes())
297
+ except zlib.error:
298
+ return None
299
+ # A loose object is ``<type> <size>\0<content>``: the header is not a
300
+ # line, so the content starts after the NUL.
301
+ _, _, content = raw.partition(b"\x00")
302
+ for line in content.split(b"\n"):
303
+ if line.startswith(b"object "):
304
+ return line.removeprefix(b"object ").decode("ascii", "replace")
305
+ return None
@@ -28,15 +28,10 @@ from __future__ import annotations
28
28
 
29
29
  import argparse
30
30
  from collections.abc import Sequence
31
- from importlib.metadata import (
32
- PackageNotFoundError,
33
- )
34
- from importlib.metadata import (
35
- version as _dist_version,
36
- )
37
31
  from typing import Any
38
32
 
39
33
  from . import spec
34
+ from .build import version_of
40
35
  from .config import MAX_DEVICE_ID, MAX_MIDI_CHANNEL, MIDI_CHANNELS
41
36
 
42
37
  __all__ = [
@@ -101,16 +96,12 @@ def make_parser(
101
96
  def _version_of(distribution: str) -> str | None:
102
97
  """The installed version of ``distribution``, or ``None``.
103
98
 
104
- By name rather than by importing something: every tool in this family
105
- installs a distribution named exactly what the command is called, and a
106
- console script can be run from a virtualenv in which the package is not
107
- importable yet. ``PackageNotFoundError`` is not an error here -- a
108
- checkout that was never installed is a normal way to run these.
99
+ One implementation, in :mod:`vinsynlib.build`, because a help screen
100
+ naming a build must not be able to disagree with the command line
101
+ about which version is installed. Kept here so the command line's
102
+ callers and tests keep the name they use.
109
103
  """
110
- try:
111
- return _dist_version(distribution)
112
- except PackageNotFoundError:
113
- return None
104
+ return version_of(distribution)
114
105
 
115
106
 
116
107
  def add_common_arguments(
@@ -0,0 +1,98 @@
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
+ """A scrollable block of text, as a modal: details, help, a result.
17
+
18
+ The family's read-only dialog. Nine tools wrote their own copy of it --
19
+ eight named ``ReportScreen``, one named it ``HelpScreen`` -- and this is
20
+ the one they share.
21
+
22
+ What the copies agree on is worth having once: a bold title, a body that
23
+ scrolls rather than truncates, and two keys that close it. What they
24
+ disagree on is worth having *there*: an extra ``enter`` to close, a
25
+ footer under the body, a narrower box with ids instead of a class, a
26
+ body assembled from ``BINDINGS``. All of those are subclasses -- a
27
+ binding added, a ``compose`` overridden, a ``DEFAULT_CSS`` re-declared,
28
+ which Textual merges over this one -- so none of them is a reason to
29
+ keep typing this class out.
30
+
31
+ The words are the tool's own and are passed in. The library supplies
32
+ the frame.
33
+
34
+ Which of the two divergences to reach by subclass and which by a
35
+ parameter is settled the same way for all of them: subclass, because
36
+ each one is a *statement about a widget* -- a box's shape, a key that
37
+ exists, a footer under the body -- and a parameter for every one of them
38
+ would be a bag of switches the library would have to keep interpreting.
39
+ Textual already resolves all three the way a subclass wants: it merges
40
+ ``BINDINGS`` per key with the most derived class winning, it merges
41
+ ``DEFAULT_CSS`` down the MRO, and a ``compose`` override replaces the
42
+ inside wholesale. So a subclass that hides eosed's ``q`` declares it
43
+ again as an explicit ``Binding(..., show=False)`` -- the flag is not
44
+ accepted in a tuple -- and the key stays bound while the footer stops
45
+ advertising it.
46
+
47
+ Not :class:`vinsynlib.ui.modals.ReportScreen`, which is the library's
48
+ own result modal: a title and a list of lines, no box around them, and
49
+ ``enter`` closes it too. That one is a screen a tool *composes*; this
50
+ one is the frame a tool *fills with its own words*. They share the
51
+ name because both answer to it, and they share nothing else -- which is
52
+ why they are two classes and not one signature taking both.
53
+ """
54
+
55
+ from __future__ import annotations
56
+
57
+ from typing import Any, ClassVar
58
+
59
+ from textual.app import ComposeResult
60
+ from textual.containers import Vertical, VerticalScroll
61
+ from textual.screen import ModalScreen
62
+ from textual.widgets import Label, Static
63
+
64
+ __all__ = ["ReportScreen"]
65
+
66
+
67
+ class ReportScreen(ModalScreen[None]):
68
+ """One block of text, read-only, over whatever is behind it."""
69
+
70
+ BINDINGS: ClassVar[list[Any]] = [
71
+ ("escape", "close", "Close"),
72
+ ("q", "close", "Close"),
73
+ ]
74
+
75
+ DEFAULT_CSS = """
76
+ ReportScreen { align: center middle; }
77
+ ReportScreen > Vertical {
78
+ width: 84; height: 80%; border: thick $accent;
79
+ background: $surface; padding: 1 2;
80
+ }
81
+ ReportScreen VerticalScroll { height: 1fr; }
82
+ ReportScreen .body { height: auto; }
83
+ """
84
+
85
+ def __init__(self, title: str, body: str) -> None:
86
+ super().__init__()
87
+ self.title_text = title
88
+ self.body = body
89
+
90
+ def compose(self) -> ComposeResult:
91
+ with Vertical():
92
+ yield Label(f"[b]{self.title_text}[/b]")
93
+ with VerticalScroll():
94
+ yield Static(self.body, classes="body")
95
+ yield Static("[dim]esc / q to close[/dim]")
96
+
97
+ def action_close(self) -> None:
98
+ self.dismiss(None)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: vinsynlib
3
- Version: 0.4.1
3
+ Version: 0.4.2
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
@@ -81,12 +81,14 @@ vinsynlib/
81
81
  catalog.py printed names, plus what has been read off the unit
82
82
  livenames.py names read off the unit, kept between runs
83
83
  render.py table and progress rendering, without Textual
84
+ build.py which build is running: version, commit, tag
84
85
  undo.py the undo log: inspectable records, no threads or locks
85
86
  ui/hints.py the legend widget
86
87
  ui/browser.py BrowserApp, the AppProtocol implementation
87
88
  ui/protocol.py the App contract screens depend on
88
89
  ui/work.py worker infrastructure: busy gate, error set, idle-close
89
90
  ui/confirm.py a generic yes/no confirmation modal
91
+ ui/report.py a scrollable block of text, as a modal
90
92
  ui/prompt.py a one-line modal text prompt
91
93
  ui/modals.py the shared modal screens: picker, search, report, rating
92
94
  ```
@@ -131,11 +133,11 @@ unit or emulator **before** connecting irreplaceable equipment.
131
133
  ## Installing it, and who depends on it
132
134
 
133
135
  This is a library, not a program. It is published on PyPI — the current
134
- release is 0.4.1 — so the ordinary way to depend on it is a dependency
136
+ release is 0.4.2 — so the ordinary way to depend on it is a dependency
135
137
  line:
136
138
 
137
139
  ```toml
138
- vinsynlib>=0.4.1
140
+ vinsynlib>=0.4.2
139
141
  ```
140
142
 
141
143
  ```sh
@@ -9,6 +9,7 @@ docs/UX-SPEC.md
9
9
  docs/unify2.md
10
10
  src/vinsynlib/__init__.py
11
11
  src/vinsynlib/backup.py
12
+ src/vinsynlib/build.py
12
13
  src/vinsynlib/catalog.py
13
14
  src/vinsynlib/cli.py
14
15
  src/vinsynlib/config.py
@@ -35,9 +36,11 @@ src/vinsynlib/ui/hints.py
35
36
  src/vinsynlib/ui/modals.py
36
37
  src/vinsynlib/ui/prompt.py
37
38
  src/vinsynlib/ui/protocol.py
39
+ src/vinsynlib/ui/report.py
38
40
  src/vinsynlib/ui/work.py
39
41
  tests/test_app_contract.py
40
42
  tests/test_backup.py
43
+ tests/test_build.py
41
44
  tests/test_catalog.py
42
45
  tests/test_config.py
43
46
  tests/test_confirm_screen.py
@@ -50,6 +53,7 @@ tests/test_prompt_screen.py
50
53
  tests/test_rating_cell.py
51
54
  tests/test_readme_modules.py
52
55
  tests/test_render.py
56
+ tests/test_report.py
53
57
  tests/test_transport.py
54
58
  tests/test_undo.py
55
59
  tests/test_ux.py