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.
- {vinsynlib-0.4.1/src/vinsynlib.egg-info → vinsynlib-0.4.2}/PKG-INFO +5 -3
- {vinsynlib-0.4.1 → vinsynlib-0.4.2}/README.md +4 -2
- {vinsynlib-0.4.1 → vinsynlib-0.4.2}/src/vinsynlib/__init__.py +1 -1
- vinsynlib-0.4.2/src/vinsynlib/build.py +305 -0
- {vinsynlib-0.4.1 → vinsynlib-0.4.2}/src/vinsynlib/cli.py +6 -15
- vinsynlib-0.4.2/src/vinsynlib/ui/report.py +98 -0
- {vinsynlib-0.4.1 → vinsynlib-0.4.2/src/vinsynlib.egg-info}/PKG-INFO +5 -3
- {vinsynlib-0.4.1 → vinsynlib-0.4.2}/src/vinsynlib.egg-info/SOURCES.txt +4 -0
- vinsynlib-0.4.2/tests/test_build.py +317 -0
- {vinsynlib-0.4.1 → vinsynlib-0.4.2}/tests/test_config.py +50 -1
- vinsynlib-0.4.2/tests/test_report.py +177 -0
- {vinsynlib-0.4.1 → vinsynlib-0.4.2}/COPYING +0 -0
- {vinsynlib-0.4.1 → vinsynlib-0.4.2}/DISCLAIMER.md +0 -0
- {vinsynlib-0.4.1 → vinsynlib-0.4.2}/LICENSE +0 -0
- {vinsynlib-0.4.1 → vinsynlib-0.4.2}/MANIFEST.in +0 -0
- {vinsynlib-0.4.1 → vinsynlib-0.4.2}/Makefile +0 -0
- {vinsynlib-0.4.1 → vinsynlib-0.4.2}/docs/UX-SPEC.md +0 -0
- {vinsynlib-0.4.1 → vinsynlib-0.4.2}/docs/unify2.md +0 -0
- {vinsynlib-0.4.1 → vinsynlib-0.4.2}/pyproject.toml +0 -0
- {vinsynlib-0.4.1 → vinsynlib-0.4.2}/setup.cfg +0 -0
- {vinsynlib-0.4.1 → vinsynlib-0.4.2}/src/vinsynlib/backup.py +0 -0
- {vinsynlib-0.4.1 → vinsynlib-0.4.2}/src/vinsynlib/catalog.py +0 -0
- {vinsynlib-0.4.1 → vinsynlib-0.4.2}/src/vinsynlib/config.py +0 -0
- {vinsynlib-0.4.1 → vinsynlib-0.4.2}/src/vinsynlib/conformance.py +0 -0
- {vinsynlib-0.4.1 → vinsynlib-0.4.2}/src/vinsynlib/devchecks.py +0 -0
- {vinsynlib-0.4.1 → vinsynlib-0.4.2}/src/vinsynlib/favorites.py +0 -0
- {vinsynlib-0.4.1 → vinsynlib-0.4.2}/src/vinsynlib/keys.py +0 -0
- {vinsynlib-0.4.1 → vinsynlib-0.4.2}/src/vinsynlib/livenames.py +0 -0
- {vinsynlib-0.4.1 → vinsynlib-0.4.2}/src/vinsynlib/midi.py +0 -0
- {vinsynlib-0.4.1 → vinsynlib-0.4.2}/src/vinsynlib/py.typed +0 -0
- {vinsynlib-0.4.1 → vinsynlib-0.4.2}/src/vinsynlib/render.py +0 -0
- {vinsynlib-0.4.1 → vinsynlib-0.4.2}/src/vinsynlib/spec.py +0 -0
- {vinsynlib-0.4.1 → vinsynlib-0.4.2}/src/vinsynlib/terms.py +0 -0
- {vinsynlib-0.4.1 → vinsynlib-0.4.2}/src/vinsynlib/ui/__init__.py +0 -0
- {vinsynlib-0.4.1 → vinsynlib-0.4.2}/src/vinsynlib/ui/browser.py +0 -0
- {vinsynlib-0.4.1 → vinsynlib-0.4.2}/src/vinsynlib/ui/confirm.py +0 -0
- {vinsynlib-0.4.1 → vinsynlib-0.4.2}/src/vinsynlib/ui/hints.py +0 -0
- {vinsynlib-0.4.1 → vinsynlib-0.4.2}/src/vinsynlib/ui/modals.py +0 -0
- {vinsynlib-0.4.1 → vinsynlib-0.4.2}/src/vinsynlib/ui/prompt.py +0 -0
- {vinsynlib-0.4.1 → vinsynlib-0.4.2}/src/vinsynlib/ui/protocol.py +0 -0
- {vinsynlib-0.4.1 → vinsynlib-0.4.2}/src/vinsynlib/ui/work.py +0 -0
- {vinsynlib-0.4.1 → vinsynlib-0.4.2}/src/vinsynlib/undo.py +0 -0
- {vinsynlib-0.4.1 → vinsynlib-0.4.2}/src/vinsynlib.egg-info/dependency_links.txt +0 -0
- {vinsynlib-0.4.1 → vinsynlib-0.4.2}/src/vinsynlib.egg-info/requires.txt +0 -0
- {vinsynlib-0.4.1 → vinsynlib-0.4.2}/src/vinsynlib.egg-info/top_level.txt +0 -0
- {vinsynlib-0.4.1 → vinsynlib-0.4.2}/tests/test_app_contract.py +0 -0
- {vinsynlib-0.4.1 → vinsynlib-0.4.2}/tests/test_backup.py +0 -0
- {vinsynlib-0.4.1 → vinsynlib-0.4.2}/tests/test_catalog.py +0 -0
- {vinsynlib-0.4.1 → vinsynlib-0.4.2}/tests/test_confirm_screen.py +0 -0
- {vinsynlib-0.4.1 → vinsynlib-0.4.2}/tests/test_favorites.py +0 -0
- {vinsynlib-0.4.1 → vinsynlib-0.4.2}/tests/test_hints.py +0 -0
- {vinsynlib-0.4.1 → vinsynlib-0.4.2}/tests/test_identity.py +0 -0
- {vinsynlib-0.4.1 → vinsynlib-0.4.2}/tests/test_midi_and_checks.py +0 -0
- {vinsynlib-0.4.1 → vinsynlib-0.4.2}/tests/test_modals.py +0 -0
- {vinsynlib-0.4.1 → vinsynlib-0.4.2}/tests/test_prompt_screen.py +0 -0
- {vinsynlib-0.4.1 → vinsynlib-0.4.2}/tests/test_rating_cell.py +0 -0
- {vinsynlib-0.4.1 → vinsynlib-0.4.2}/tests/test_readme_modules.py +0 -0
- {vinsynlib-0.4.1 → vinsynlib-0.4.2}/tests/test_render.py +0 -0
- {vinsynlib-0.4.1 → vinsynlib-0.4.2}/tests/test_transport.py +0 -0
- {vinsynlib-0.4.1 → vinsynlib-0.4.2}/tests/test_undo.py +0 -0
- {vinsynlib-0.4.1 → vinsynlib-0.4.2}/tests/test_ux.py +0 -0
- {vinsynlib-0.4.1 → vinsynlib-0.4.2}/tests/test_version.py +0 -0
- {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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
114
|
+
vinsynlib>=0.4.2
|
|
113
115
|
```
|
|
114
116
|
|
|
115
117
|
```sh
|
|
@@ -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
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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
|