v0.1.0: version in the status bar + one-click self-update via git pull
__version__ in lintunes/__init__.py (setup.py regex-reads it) shows bottom-left via the new VersionButton; the tooltip carries the git short hash so machines on the same version but different commits are distinguishable. The new Updater fetches upstream 10s after launch and every 4h on daemon threads (lastfm.py pattern); commits behind put a * on the button, and clicking it confirms, runs `git pull --ff-only`, quits cleanly (library flush + player shutdown), and re-execs `python -m lintunes.main` on the new code. Positional file args are stripped from the re-exec so they don't re-import as duplicates. Pull failures surface the git error: line in the status bar and leave the running app untouched; outside a git checkout the button is a plain label. Also fixes a Round-5 regression found while verifying: the totals label was a permanent status-bar widget with stretch=1, which squeezed the transient-message area to zero width — every showMessage (scrobbles, tag-write errors, import status) has been invisible since. Both readouts are now non-permanent widgets, so a transient message temporarily replaces them and they return. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@ -0,0 +1,3 @@
|
||||
"""LinTunes — iTunes-style music library manager and player for Linux."""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
|
||||
@ -21,6 +21,8 @@ from lintunes.gui.transport import TransportBar
|
||||
from lintunes.gui.info_dialog import InfoDialog
|
||||
from lintunes.gui.preferences_dialog import PreferencesDialog
|
||||
from lintunes.gui.track_table import format_total_time
|
||||
from lintunes.gui.version_button import VersionButton
|
||||
from lintunes.updater import Updater
|
||||
|
||||
|
||||
def album_tracks(library, artist: str, album: str) -> list:
|
||||
@ -81,11 +83,23 @@ class MainWindow(QMainWindow):
|
||||
self._content.addWidget(self._playlist_view)
|
||||
self._content.setCurrentWidget(self._library_view)
|
||||
|
||||
# Bottom status bar: a permanent, centered totals readout (transient
|
||||
# import/scrobble/error messages still use the left showMessage area).
|
||||
# Bottom status bar, left to right: version/self-update button, then a
|
||||
# centered totals readout. Both are non-permanent widgets, so a
|
||||
# transient showMessage (import/scrobble/error/update text) replaces
|
||||
# them for its duration and they come back — a permanent widget with
|
||||
# stretch would squeeze the message area to zero width and swallow
|
||||
# every message.
|
||||
self.restart_requested = False # run_gui re-execs when True on quit
|
||||
self._updater = Updater(self)
|
||||
self._version_button = VersionButton(self._updater)
|
||||
self.statusBar().addWidget(self._version_button)
|
||||
self._totals_label = QLabel()
|
||||
self._totals_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
self.statusBar().addPermanentWidget(self._totals_label, 1)
|
||||
self.statusBar().addWidget(self._totals_label, 1)
|
||||
self._version_button.status_message.connect(
|
||||
lambda msg: self.statusBar().showMessage(msg, 8000))
|
||||
self._updater.update_applied.connect(self._restart_for_update)
|
||||
self._updater.start_checking()
|
||||
|
||||
# Wiring
|
||||
self._transport.play_clicked.connect(self.play_pause)
|
||||
@ -127,6 +141,12 @@ class MainWindow(QMainWindow):
|
||||
self.apply_ui_metrics()
|
||||
self._update_totals()
|
||||
|
||||
def _restart_for_update(self):
|
||||
# The pull already succeeded; quit through the normal path (flushes
|
||||
# the library, tears down the player) and let main() re-exec us.
|
||||
self.restart_requested = True
|
||||
QApplication.instance().quit()
|
||||
|
||||
# ---- status bar totals ----
|
||||
|
||||
def _update_totals(self):
|
||||
@ -145,6 +165,7 @@ class MainWindow(QMainWindow):
|
||||
theme.apply_theme(QApplication.instance(), self._prefs)
|
||||
self.apply_ui_metrics()
|
||||
self._transport.refresh_theme()
|
||||
self._version_button.refresh_theme()
|
||||
|
||||
def apply_ui_metrics(self):
|
||||
metrics = theme.scale_metrics(self._prefs)
|
||||
|
||||
70
lintunes/gui/version_button.py
Normal file
70
lintunes/gui/version_button.py
Normal file
@ -0,0 +1,70 @@
|
||||
"""The little version readout in the status bar's bottom-left corner.
|
||||
|
||||
Normally an inert label ("v0.1.0"; tooltip shows the git commit so identical
|
||||
versions on different machines are still distinguishable). When the Updater
|
||||
finds upstream commits it grows a "*" and becomes a button: click → confirm →
|
||||
git pull → the main window restarts the app on `update_applied`.
|
||||
"""
|
||||
|
||||
from PyQt6.QtCore import Qt, pyqtSignal
|
||||
from PyQt6.QtGui import QPalette
|
||||
from PyQt6.QtWidgets import QMessageBox, QPushButton
|
||||
|
||||
from lintunes import __version__
|
||||
|
||||
|
||||
class VersionButton(QPushButton):
|
||||
status_message = pyqtSignal(str)
|
||||
|
||||
def __init__(self, updater, parent=None):
|
||||
super().__init__(parent)
|
||||
self._updater = updater
|
||||
self._update_ready = False
|
||||
self.setFlat(True)
|
||||
self.setFocusPolicy(Qt.FocusPolicy.NoFocus)
|
||||
self.refresh_theme()
|
||||
self.setText(f"v{__version__}")
|
||||
commit = updater.short_hash()
|
||||
self._base_tooltip = (f"LinTunes v{__version__} ({commit})" if commit
|
||||
else f"LinTunes v{__version__}")
|
||||
self.setToolTip(self._base_tooltip)
|
||||
|
||||
updater.update_available.connect(self._on_update_available)
|
||||
updater.update_failed.connect(self._on_update_failed)
|
||||
self.clicked.connect(self._on_clicked)
|
||||
|
||||
def refresh_theme(self):
|
||||
# Subtle but readable: the theme's text color at ~55% opacity
|
||||
# (palette(mid) is too close to the light backgrounds to read).
|
||||
text = self.palette().color(QPalette.ColorRole.WindowText)
|
||||
self.setStyleSheet(
|
||||
"QPushButton { border: none; padding: 0 6px; "
|
||||
f"color: rgba({text.red()}, {text.green()}, {text.blue()}, 140); }}")
|
||||
|
||||
def _on_update_available(self, commits: int):
|
||||
self._update_ready = True
|
||||
self.setText(f"v{__version__}*")
|
||||
noun = "commit" if commits == 1 else "commits"
|
||||
self.setToolTip(f"Update available ({commits} {noun}) — "
|
||||
"click to update and restart")
|
||||
self.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||
|
||||
def _on_clicked(self):
|
||||
if not self._update_ready or not self.isEnabled():
|
||||
return
|
||||
if not self._confirm():
|
||||
return
|
||||
self.setEnabled(False)
|
||||
self.setText("updating…")
|
||||
self._updater.update_async()
|
||||
|
||||
def _confirm(self) -> bool:
|
||||
answer = QMessageBox.question(
|
||||
self.window(), "Update LinTunes",
|
||||
"Pull the latest version and restart LinTunes?")
|
||||
return answer == QMessageBox.StandardButton.Yes
|
||||
|
||||
def _on_update_failed(self, message: str):
|
||||
self.setEnabled(True)
|
||||
self.setText(f"v{__version__}*")
|
||||
self.status_message.emit(f"Update failed: {message}")
|
||||
@ -1,4 +1,5 @@
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
@ -58,7 +59,15 @@ def main():
|
||||
if args.import_file:
|
||||
run_import(Path(args.import_file), music_root, data_dir)
|
||||
else:
|
||||
run_gui(data_dir, [Path(f) for f in args.files], qt_args)
|
||||
if run_gui(data_dir, [Path(f) for f in args.files], qt_args):
|
||||
# A self-update just pulled new code; replace this process with a
|
||||
# fresh launch running the updated source. -m works whether we
|
||||
# started as `lintunes` or `python -m lintunes.main`. Positional
|
||||
# files are dropped — they were imported on the first launch and
|
||||
# would be re-imported (duplicated) on every restart otherwise.
|
||||
argv = [a for a in sys.argv[1:] if a not in args.files]
|
||||
os.execv(sys.executable,
|
||||
[sys.executable, "-m", "lintunes.main", *argv])
|
||||
|
||||
|
||||
def run_import(xml_path: Path, music_root: str | None, data_dir: Path):
|
||||
@ -93,7 +102,8 @@ def run_import(xml_path: Path, music_root: str | None, data_dir: Path):
|
||||
print("Import complete!")
|
||||
|
||||
|
||||
def run_gui(data_dir: Path, files: list[Path], qt_args: list[str] | None = None):
|
||||
def run_gui(data_dir: Path, files: list[Path],
|
||||
qt_args: list[str] | None = None) -> bool:
|
||||
import logging
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
|
||||
@ -147,7 +157,10 @@ def run_gui(data_dir: Path, files: list[Path], qt_args: list[str] | None = None)
|
||||
window.show_conflict_summary(startup_conflicts)
|
||||
if files:
|
||||
window.import_files(files)
|
||||
sys.exit(app.exec())
|
||||
exit_code = app.exec()
|
||||
if window.restart_requested:
|
||||
return True # caller re-execs the updated code
|
||||
sys.exit(exit_code)
|
||||
|
||||
|
||||
def _ensure_now_playing_font(prefs):
|
||||
|
||||
108
lintunes/updater.py
Normal file
108
lintunes/updater.py
Normal file
@ -0,0 +1,108 @@
|
||||
"""Self-update via git.
|
||||
|
||||
LinTunes runs straight from its git checkout (editable install), so an update
|
||||
is just a fast-forward `git pull` followed by an app relaunch. The Updater
|
||||
periodically fetches the checkout's upstream and reports how many commits
|
||||
behind we are; `update_async()` applies them with `--ff-only` (never a merge,
|
||||
never a conflicted tree). Network git calls run on daemon threads — same
|
||||
pattern as lastfm.py — and report back through Qt signals.
|
||||
|
||||
When the package isn't running from a git checkout (or has no upstream), the
|
||||
updater disables itself silently and the version display is just a label.
|
||||
"""
|
||||
|
||||
import subprocess
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
from PyQt6.QtCore import QObject, QTimer, pyqtSignal
|
||||
|
||||
FIRST_CHECK_MS = 10 * 1000 # shortly after launch, off the startup path
|
||||
RECHECK_INTERVAL_MS = 4 * 3600 * 1000 # then every 4 hours
|
||||
|
||||
|
||||
class Updater(QObject):
|
||||
update_available = pyqtSignal(int) # commits behind upstream
|
||||
update_applied = pyqtSignal() # pull succeeded; caller restarts the app
|
||||
update_failed = pyqtSignal(str)
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self._root = str(Path(__file__).resolve().parents[1])
|
||||
self._busy = False
|
||||
self._timer = None
|
||||
self.enabled = (self._git("rev-parse", "--show-toplevel") is not None
|
||||
and self._git("rev-parse", "--abbrev-ref", "@{upstream}")
|
||||
is not None)
|
||||
|
||||
def short_hash(self) -> str | None:
|
||||
"""The checkout's current commit, for telling builds apart."""
|
||||
return self._git("rev-parse", "--short", "HEAD")
|
||||
|
||||
def start_checking(self):
|
||||
"""First check shortly after launch, then every few hours."""
|
||||
if not self.enabled:
|
||||
return
|
||||
QTimer.singleShot(FIRST_CHECK_MS, self.check_async)
|
||||
self._timer = QTimer(self)
|
||||
self._timer.setInterval(RECHECK_INTERVAL_MS)
|
||||
self._timer.timeout.connect(self.check_async)
|
||||
self._timer.start()
|
||||
|
||||
def check_async(self):
|
||||
if not self.enabled or self._busy:
|
||||
return
|
||||
self._busy = True
|
||||
threading.Thread(target=self._check, daemon=True).start()
|
||||
|
||||
def update_async(self):
|
||||
if not self.enabled or self._busy:
|
||||
return
|
||||
self._busy = True
|
||||
threading.Thread(target=self._update, daemon=True).start()
|
||||
|
||||
# ---- internals (run on daemon threads) ----
|
||||
|
||||
def _check(self):
|
||||
try:
|
||||
if self._git("fetch", "--quiet", timeout=60) is None:
|
||||
return # offline / host unreachable — perfectly normal, stay quiet
|
||||
behind = self._git("rev-list", "--count", "HEAD..@{upstream}")
|
||||
if behind and behind.isdigit() and int(behind) > 0:
|
||||
self.update_available.emit(int(behind))
|
||||
finally:
|
||||
self._busy = False
|
||||
|
||||
def _update(self):
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
["git", "-C", self._root, "pull", "--ff-only", "--quiet"],
|
||||
capture_output=True, text=True, timeout=120)
|
||||
if proc.returncode == 0:
|
||||
self.update_applied.emit()
|
||||
else:
|
||||
lines = [l.strip() for l in
|
||||
(proc.stderr or proc.stdout).strip().splitlines()
|
||||
if l.strip()]
|
||||
# git's actual reason is the error:/fatal: line, not the
|
||||
# trailing "Aborting"
|
||||
message = next(
|
||||
(l for l in lines if l.startswith(("error:", "fatal:"))),
|
||||
lines[-1] if lines else "git pull failed")
|
||||
self.update_failed.emit(message)
|
||||
except Exception as e:
|
||||
self.update_failed.emit(str(e))
|
||||
finally:
|
||||
self._busy = False
|
||||
|
||||
def _git(self, *args, timeout=15) -> str | None:
|
||||
"""Run git in the repo root; stripped stdout, or None on any failure."""
|
||||
try:
|
||||
proc = subprocess.run(["git", "-C", self._root, *args],
|
||||
capture_output=True, text=True,
|
||||
timeout=timeout)
|
||||
except Exception:
|
||||
return None
|
||||
if proc.returncode != 0:
|
||||
return None
|
||||
return proc.stdout.strip()
|
||||
10
setup.py
10
setup.py
@ -1,8 +1,16 @@
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from setuptools import setup, find_packages
|
||||
|
||||
# Single source of truth for the version is lintunes/__init__.py; read it
|
||||
# textually so setup.py doesn't import the package (and its Qt deps).
|
||||
_init = (Path(__file__).parent / "lintunes" / "__init__.py").read_text()
|
||||
_version = re.search(r'^__version__ = "([^"]+)"', _init, re.MULTILINE)[1]
|
||||
|
||||
setup(
|
||||
name="lintunes",
|
||||
version="0.1.0",
|
||||
version=_version,
|
||||
packages=find_packages(),
|
||||
install_requires=[
|
||||
"PyQt6>=6.8.0", # QAudioBufferOutput (visualizer) needs Qt 6.8+
|
||||
|
||||
195
tests/test_round19.py
Normal file
195
tests/test_round19.py
Normal file
@ -0,0 +1,195 @@
|
||||
"""Round 19: version display + git self-update (updater, version button)."""
|
||||
|
||||
import subprocess
|
||||
|
||||
import pytest
|
||||
from PyQt6.QtCore import QObject, pyqtSignal
|
||||
|
||||
from lintunes import __version__
|
||||
from lintunes.updater import Updater
|
||||
|
||||
|
||||
class FakeCompleted:
|
||||
def __init__(self, returncode=0, stdout="", stderr=""):
|
||||
self.returncode = returncode
|
||||
self.stdout = stdout
|
||||
self.stderr = stderr
|
||||
|
||||
|
||||
def make_updater(monkeypatch, git_responses):
|
||||
"""An Updater whose git calls are served from `git_responses`:
|
||||
{subcommand: FakeCompleted or Exception}. Unlisted subcommands succeed
|
||||
with empty output."""
|
||||
|
||||
def fake_run(cmd, **kwargs):
|
||||
assert cmd[0] == "git" and cmd[1] == "-C"
|
||||
sub = cmd[3]
|
||||
response = git_responses.get(sub, FakeCompleted())
|
||||
if isinstance(response, Exception):
|
||||
raise response
|
||||
return response
|
||||
|
||||
monkeypatch.setattr(subprocess, "run", fake_run)
|
||||
return Updater()
|
||||
|
||||
|
||||
def collect(signal):
|
||||
got = []
|
||||
signal.connect(lambda *args: got.append(args[0] if args else None))
|
||||
return got
|
||||
|
||||
|
||||
def test_updater_disabled_outside_git_checkout(qapp, monkeypatch):
|
||||
updater = make_updater(monkeypatch, {
|
||||
"rev-parse": FakeCompleted(returncode=128, stderr="not a git repo")})
|
||||
assert not updater.enabled
|
||||
# disabled updater never spawns work
|
||||
updater.check_async()
|
||||
updater.update_async()
|
||||
|
||||
|
||||
def test_check_emits_update_available(qapp, monkeypatch):
|
||||
updater = make_updater(monkeypatch, {
|
||||
"rev-parse": FakeCompleted(stdout="/repo\n"),
|
||||
"rev-list": FakeCompleted(stdout="3\n"),
|
||||
})
|
||||
assert updater.enabled
|
||||
got = collect(updater.update_available)
|
||||
updater._check() # run synchronously; check_async just threads this
|
||||
assert got == [3]
|
||||
|
||||
|
||||
def test_check_quiet_when_up_to_date(qapp, monkeypatch):
|
||||
updater = make_updater(monkeypatch, {
|
||||
"rev-list": FakeCompleted(stdout="0\n")})
|
||||
got = collect(updater.update_available)
|
||||
updater._check()
|
||||
assert got == []
|
||||
|
||||
|
||||
def test_check_quiet_when_fetch_fails(qapp, monkeypatch):
|
||||
updater = make_updater(monkeypatch, {
|
||||
"fetch": FakeCompleted(returncode=128, stderr="ssh: no route to host"),
|
||||
"rev-list": FakeCompleted(stdout="3\n"),
|
||||
})
|
||||
got = collect(updater.update_available)
|
||||
updater._check()
|
||||
assert got == [] # offline is normal — no signal, no error
|
||||
|
||||
|
||||
def test_update_applied_on_clean_pull(qapp, monkeypatch):
|
||||
updater = make_updater(monkeypatch, {"pull": FakeCompleted(returncode=0)})
|
||||
applied = collect(updater.update_applied)
|
||||
failed = collect(updater.update_failed)
|
||||
updater._update()
|
||||
assert len(applied) == 1
|
||||
assert failed == []
|
||||
|
||||
|
||||
def test_update_failed_surfaces_git_error(qapp, monkeypatch):
|
||||
# The message is the error:/fatal: line, not git's trailing "Aborting"
|
||||
updater = make_updater(monkeypatch, {
|
||||
"pull": FakeCompleted(returncode=1,
|
||||
stderr="error: Your local changes to the "
|
||||
"following files would be overwritten "
|
||||
"by merge:\n\ttasks-done.md\n"
|
||||
"Aborting")})
|
||||
applied = collect(updater.update_applied)
|
||||
failed = collect(updater.update_failed)
|
||||
updater._update()
|
||||
assert applied == []
|
||||
assert failed == ["error: Your local changes to the following files "
|
||||
"would be overwritten by merge:"]
|
||||
|
||||
|
||||
def test_short_hash(qapp, monkeypatch):
|
||||
updater = make_updater(monkeypatch, {
|
||||
"rev-parse": FakeCompleted(stdout="abc1234\n")})
|
||||
assert updater.short_hash() == "abc1234"
|
||||
|
||||
|
||||
# ---- version button ----
|
||||
|
||||
class FakeUpdater(QObject): # QObject with the Updater's signal surface
|
||||
update_available = pyqtSignal(int)
|
||||
update_applied = pyqtSignal()
|
||||
update_failed = pyqtSignal(str)
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.enabled = True
|
||||
self.update_calls = 0
|
||||
|
||||
def short_hash(self):
|
||||
return "abc1234"
|
||||
|
||||
def update_async(self):
|
||||
self.update_calls += 1
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def button(qapp):
|
||||
from lintunes.gui.version_button import VersionButton
|
||||
fake = FakeUpdater()
|
||||
return VersionButton(fake), fake
|
||||
|
||||
|
||||
def test_button_shows_version_and_hash(button):
|
||||
btn, fake = button
|
||||
assert btn.text() == f"v{__version__}"
|
||||
assert "abc1234" in btn.toolTip()
|
||||
|
||||
|
||||
def test_button_star_on_update_available(button):
|
||||
btn, fake = button
|
||||
fake.update_available.emit(2)
|
||||
assert btn.text() == f"v{__version__}*"
|
||||
assert "click to update" in btn.toolTip()
|
||||
|
||||
|
||||
def test_click_without_update_does_nothing(button):
|
||||
btn, fake = button
|
||||
btn._on_clicked()
|
||||
assert fake.update_calls == 0
|
||||
|
||||
|
||||
def test_click_with_update_confirms_then_pulls(button, monkeypatch):
|
||||
btn, fake = button
|
||||
fake.update_available.emit(1)
|
||||
monkeypatch.setattr(btn, "_confirm", lambda: True)
|
||||
btn._on_clicked()
|
||||
assert fake.update_calls == 1
|
||||
assert btn.text() == "updating…"
|
||||
assert not btn.isEnabled()
|
||||
|
||||
|
||||
def test_click_with_update_declined(button, monkeypatch):
|
||||
btn, fake = button
|
||||
fake.update_available.emit(1)
|
||||
monkeypatch.setattr(btn, "_confirm", lambda: False)
|
||||
btn._on_clicked()
|
||||
assert fake.update_calls == 0
|
||||
assert btn.isEnabled()
|
||||
|
||||
|
||||
def test_failed_update_reenables_button(button, monkeypatch):
|
||||
btn, fake = button
|
||||
fake.update_available.emit(1)
|
||||
monkeypatch.setattr(btn, "_confirm", lambda: True)
|
||||
btn._on_clicked()
|
||||
messages = collect(btn.status_message)
|
||||
fake.update_failed.emit("fatal: no route to host")
|
||||
assert btn.isEnabled()
|
||||
assert btn.text() == f"v{__version__}*"
|
||||
assert messages == ["Update failed: fatal: no route to host"]
|
||||
|
||||
|
||||
def test_version_matches_setup_py():
|
||||
import re
|
||||
from pathlib import Path
|
||||
setup_py = Path(__file__).parents[1] / "setup.py"
|
||||
# setup.py reads the version from lintunes/__init__.py textually
|
||||
assert "__init__.py" in setup_py.read_text()
|
||||
init = Path(__file__).parents[1] / "lintunes" / "__init__.py"
|
||||
found = re.search(r'^__version__ = "([^"]+)"', init.read_text(), re.M)
|
||||
assert found and found[1] == __version__
|
||||
Reference in New Issue
Block a user