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:
2026-07-03 11:30:37 -04:00
parent 407c5669c5
commit 502a6106f4
7 changed files with 425 additions and 7 deletions

View File

@ -0,0 +1,3 @@
"""LinTunes — iTunes-style music library manager and player for Linux."""
__version__ = "0.1.0"

View File

@ -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)

View 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}")

View File

@ -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
View 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()

View File

@ -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
View 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__