diff --git a/src/qtxterm/about_dialog.py b/src/qtxterm/about_dialog.py new file mode 100644 index 0000000..3e6ac47 --- /dev/null +++ b/src/qtxterm/about_dialog.py @@ -0,0 +1,73 @@ +"""A small About box: which build is running, and a way to copy that.""" + +from __future__ import annotations + +from PySide6.QtCore import QSize, Qt +from PySide6.QtGui import QGuiApplication, QIcon +from PySide6.QtWidgets import ( + QDialog, + QDialogButtonBox, + QHBoxLayout, + QLabel, + QPushButton, + QVBoxLayout, + QWidget, +) + +from qtxterm.branding import LOGO_PATH +from qtxterm.version_info import version_string + +LOGO_SIZE = 64 + +COPIED_TEXT = "Copied" + + +class AboutDialog(QDialog): + """Shows the running build's version, commit and source location.""" + + def __init__(self, parent: QWidget | None = None) -> None: + super().__init__(parent) + self.setWindowTitle("About qtxterm") + + self.details = version_string() + + layout = QHBoxLayout(self) + + self.logo = QLabel(self) + if LOGO_PATH.is_file(): + self.logo.setPixmap( + QIcon(str(LOGO_PATH)).pixmap(QSize(LOGO_SIZE, LOGO_SIZE)) + ) + self.logo.setAlignment(Qt.AlignmentFlag.AlignTop) + layout.addWidget(self.logo) + + right = QVBoxLayout() + layout.addLayout(right) + + self.text = QLabel(self.details, self) + # Selectable so the version can be dragged out even without the button, + # and monospaced so the aligned labels in version_lines() stay aligned. + self.text.setTextInteractionFlags( + Qt.TextInteractionFlag.TextSelectableByMouse + | Qt.TextInteractionFlag.TextSelectableByKeyboard + ) + self.text.setStyleSheet("font-family: monospace;") + right.addWidget(self.text) + + buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Close, parent=self) + self.copy_button = QPushButton("Copy", self) + self.copy_button.setDefault(True) + buttons.addButton(self.copy_button, QDialogButtonBox.ButtonRole.ActionRole) + self.copy_button.clicked.connect(self.copy_details) + buttons.rejected.connect(self.reject) + right.addWidget(buttons) + + def copy_details(self) -> None: + """Put the version block on the clipboard, ready to paste into an issue.""" + clipboard = QGuiApplication.clipboard() + if clipboard is None: + return + clipboard.setText(self.details) + # Confirm in place. A dialog raising another dialog to say "done" is + # more interruption than a copy button deserves. + self.copy_button.setText(COPIED_TEXT) diff --git a/src/qtxterm/main_window.py b/src/qtxterm/main_window.py index 7b1a677..fcfa542 100644 --- a/src/qtxterm/main_window.py +++ b/src/qtxterm/main_window.py @@ -4,6 +4,7 @@ from PySide6.QtGui import QAction from PySide6.QtWidgets import QApplication, QDockWidget, QMainWindow, QStyle +from qtxterm.about_dialog import AboutDialog from qtxterm.appearance import AppearanceStore from qtxterm.branding import app_icon from qtxterm.cron import CronStore @@ -182,10 +183,19 @@ def _build_help_menu(self) -> None: self.style().standardIcon(QStyle.StandardPixmap.SP_MessageBoxQuestion) ) self._usage_action.triggered.connect(self.show_usage) + self._help_menu.addSeparator() + self._about_action = self._help_menu.addAction("About qtxterm") + self._about_action.setIcon( + self.style().standardIcon(QStyle.StandardPixmap.SP_MessageBoxInformation) + ) + self._about_action.triggered.connect(self.show_about) def show_usage(self) -> None: HelpDialog(self).exec() + def show_about(self) -> None: + AboutDialog(self).exec() + def _build_file_menu(self) -> None: # QMenu.addMenu() parents the submenu in C++, but without a Python-side # reference kept alive too, PySide6 can garbage-collect the wrapper (and diff --git a/src/qtxterm/version_info.py b/src/qtxterm/version_info.py new file mode 100644 index 0000000..691f9c0 --- /dev/null +++ b/src/qtxterm/version_info.py @@ -0,0 +1,152 @@ +"""Where the running code came from, for the About dialog. + +The packaged version alone cannot answer "which build am I looking at?" - +`pyproject.toml` carries a static version that only moves on a release, so +every commit between two releases reports the same string. An editable +install makes that worse: the code is read live from a checkout, so the +version is fixed while the code underneath it changes with every branch +switch. + +Git knows the answer and is right there in a checkout, so ask it, and fall +back to the packaged metadata when it is not - an installed wheel has no +`.git`, and a user machine may have no `git` at all. +""" + +from __future__ import annotations + +import subprocess +from dataclasses import dataclass +from datetime import UTC, datetime +from functools import cache +from importlib.metadata import PackageNotFoundError, version +from pathlib import Path + +PACKAGE_DIR = Path(__file__).resolve().parent + +# src/qtxterm -> src -> repo root. Absent from an installed wheel, which is +# exactly the signal that this is not a development build. +REPO_ROOT = PACKAGE_DIR.parent.parent + +# Git is normally instant, but it takes locks and can block on a busy repo. +# The About dialog is not worth hanging the UI for, so cap the wait and +# degrade to the packaged version instead. +GIT_TIMEOUT_SECONDS = 2.0 + +UNKNOWN_VERSION = "unknown" + + +@dataclass(frozen=True) +class BuildInfo: + """Identity of the running build, however much of it could be determined.""" + + version: str + describe: str | None = None + commit_datetime: datetime | None = None + source: Path | None = None + installed_datetime: datetime | None = None + + @property + def is_development(self) -> bool: + """Whether this is running from a checkout rather than an installed copy.""" + return self.describe is not None + + +def _run_git(*args: str) -> str | None: + """Run a git command in the repo, or return None if git cannot answer. + + Covers the three ways this fails in the wild: no git on PATH + (FileNotFoundError), no repository to read (non-zero exit), and a repo + that does not answer promptly (TimeoutExpired). + """ + if not (REPO_ROOT / ".git").exists(): + return None + try: + result = subprocess.run( + ["git", *args], + cwd=REPO_ROOT, + capture_output=True, + text=True, + timeout=GIT_TIMEOUT_SECONDS, + check=False, + ) + except (OSError, subprocess.SubprocessError): + return None + if result.returncode != 0: + return None + return result.stdout.strip() or None + + +def packaged_version() -> str: + """The version recorded at install time, or a placeholder if unreadable.""" + try: + return version("qtxterm") + except PackageNotFoundError: + return UNKNOWN_VERSION + + +def _commit_datetime() -> datetime | None: + raw = _run_git("log", "-1", "--format=%cI") + if raw is None: + return None + try: + return datetime.fromisoformat(raw) + except ValueError: + return None + + +def _installed_datetime() -> datetime | None: + """When the package landed on disk. + + The package directory's mtime is written when the wheel is unpacked, so + for an installed copy it dates the install. It is meaningless for an + editable install, where the directory is the checkout - hence only used + when git found nothing. + """ + try: + stamp = PACKAGE_DIR.stat().st_mtime + except OSError: + return None + # Read as UTC then converted to local, so the result is timezone-aware + # and prints an offset like the git commit date does. + return datetime.fromtimestamp(stamp, tz=UTC).astimezone() + + +@cache +def build_info() -> BuildInfo: + """Identify the running build. Cached - the answer cannot change mid-run.""" + describe = _run_git("describe", "--always", "--dirty", "--tags") + if describe is None: + return BuildInfo( + version=packaged_version(), + installed_datetime=_installed_datetime(), + ) + return BuildInfo( + version=packaged_version(), + describe=describe, + commit_datetime=_commit_datetime(), + source=REPO_ROOT, + ) + + +def _format(moment: datetime) -> str: + return moment.strftime("%Y-%m-%d %H:%M:%S %z").strip() + + +def version_lines(info: BuildInfo | None = None) -> list[str]: + """The About dialog's text, one line per fact that could be determined.""" + info = info if info is not None else build_info() + lines = [f"qtxterm {info.version}"] + if info.describe: + lines.append(info.describe) + if info.commit_datetime: + lines.append(f"commit {_format(info.commit_datetime)}") + if info.installed_datetime: + lines.append(f"installed {_format(info.installed_datetime)}") + if info.source: + lines.append(f"source {info.source}") + return lines + + +def version_string(info: BuildInfo | None = None) -> str: + """The About dialog's text as one block, also what Copy puts on the clipboard.""" + return "\n".join(version_lines(info)) diff --git a/tests/test_about_dialog.py b/tests/test_about_dialog.py new file mode 100644 index 0000000..30cb037 --- /dev/null +++ b/tests/test_about_dialog.py @@ -0,0 +1,39 @@ +"""The About dialog shows the build and copies it.""" + +from __future__ import annotations + +from PySide6.QtGui import QGuiApplication + +from qtxterm.about_dialog import COPIED_TEXT, AboutDialog +from qtxterm.version_info import version_string + + +def test_dialog_shows_the_version_block(qtbot) -> None: + dialog = AboutDialog() + qtbot.addWidget(dialog) + + assert dialog.text.text() == version_string() + assert "qtxterm" in dialog.text.text() + + +def test_copy_puts_the_details_on_the_clipboard(qtbot) -> None: + dialog = AboutDialog() + qtbot.addWidget(dialog) + + dialog.copy_details() + + assert QGuiApplication.clipboard().text() == dialog.details + assert dialog.copy_button.text() == COPIED_TEXT + + +def test_dialog_survives_a_missing_logo(qtbot, monkeypatch, tmp_path) -> None: + """Assets can be absent from a broken install; the build info still matters.""" + import qtxterm.about_dialog as about_dialog + + monkeypatch.setattr(about_dialog, "LOGO_PATH", tmp_path / "gone.svg") + + dialog = AboutDialog() + qtbot.addWidget(dialog) + + assert dialog.logo.pixmap().isNull() + assert dialog.text.text() diff --git a/tests/test_main_window.py b/tests/test_main_window.py index befb1cf..12b5c23 100644 --- a/tests/test_main_window.py +++ b/tests/test_main_window.py @@ -164,6 +164,46 @@ def exec(self) -> int: window.close() +def test_help_menu_about_action_opens_the_dialog( + qtbot, monkeypatch, tmp_path: Path +) -> None: + """exec() is monkeypatched - a real modal would block the test run.""" + import qtxterm.main_window as main_window + + opened = [] + + class FakeAboutDialog: + def __init__(self, parent=None) -> None: + opened.append(parent) + + def exec(self) -> int: + return 0 + + monkeypatch.setattr(main_window, "AboutDialog", FakeAboutDialog) + + window = MainWindow(settings=make_settings(tmp_path)) + qtbot.addWidget(window) + window.show() + + assert window._about_action.text() == "About qtxterm" + assert not window._about_action.icon().isNull() + + window._about_action.trigger() + + assert opened == [window] + + window.close() + + +def test_help_menu_lists_usage_then_about(qtbot, tmp_path: Path) -> None: + window = MainWindow(settings=make_settings(tmp_path)) + qtbot.addWidget(window) + + entries = [a.text() for a in window._help_menu.actions() if not a.isSeparator()] + + assert entries == ["Usage", "About qtxterm"] + + def test_menu_bar_order(qtbot, tmp_path: Path) -> None: window = MainWindow(settings=make_settings(tmp_path)) qtbot.addWidget(window) diff --git a/tests/test_version_info.py b/tests/test_version_info.py new file mode 100644 index 0000000..be4451e --- /dev/null +++ b/tests/test_version_info.py @@ -0,0 +1,109 @@ +"""Build identification, including the paths where git cannot answer.""" + +from __future__ import annotations + +import subprocess +from datetime import UTC, datetime + +import pytest + +from qtxterm import version_info +from qtxterm.version_info import BuildInfo, version_lines, version_string + + +@pytest.fixture(autouse=True) +def _clear_cache(): + """build_info() is cached for the process; tests need it recomputed.""" + version_info.build_info.cache_clear() + yield + version_info.build_info.cache_clear() + + +def test_development_build_reports_git_description() -> None: + """The repo under test is a checkout, so git should answer.""" + info = version_info.build_info() + + assert info.is_development + assert info.describe + assert info.source == version_info.REPO_ROOT + assert info.commit_datetime is not None + + +def test_missing_git_falls_back_to_packaged_metadata(monkeypatch) -> None: + """An installed wheel has no .git, and a user machine may have no git.""" + monkeypatch.setattr(version_info, "_run_git", lambda *args: None) + + info = version_info.build_info() + + assert not info.is_development + assert info.describe is None + assert info.version == version_info.packaged_version() + assert info.installed_datetime is not None + + +def test_git_timeout_is_not_fatal(monkeypatch) -> None: + """A slow repo must degrade, not hang the About dialog.""" + + def _hang(*args, **kwargs): + raise subprocess.TimeoutExpired(cmd="git", timeout=2.0) + + monkeypatch.setattr(subprocess, "run", _hang) + + assert version_info._run_git("describe") is None + + +def test_missing_git_binary_is_not_fatal(monkeypatch) -> None: + def _absent(*args, **kwargs): + raise FileNotFoundError("git") + + monkeypatch.setattr(subprocess, "run", _absent) + + assert version_info._run_git("describe") is None + + +def test_non_zero_exit_returns_none(monkeypatch) -> None: + """Outside a repository git exits non-zero rather than raising.""" + + def _fail(*args, **kwargs): + return subprocess.CompletedProcess(args=["git"], returncode=128, stdout="") + + monkeypatch.setattr(subprocess, "run", _fail) + + assert version_info._run_git("describe") is None + + +def test_version_lines_for_a_development_build() -> None: + info = BuildInfo( + version="1.0.0", + describe="v1.0.0-21-gd4bd495", + commit_datetime=datetime(2026, 9, 20, 21, 41, 22, tzinfo=UTC), + source=version_info.REPO_ROOT, + ) + + lines = version_lines(info) + + assert lines[0] == "qtxterm 1.0.0" + assert lines[1] == "v1.0.0-21-gd4bd495" + assert any(line.startswith("commit") for line in lines) + assert any(line.startswith("source") for line in lines) + assert not any(line.startswith("installed") for line in lines) + + +def test_version_lines_for_an_installed_build() -> None: + """No commit or source line when there is no checkout to report.""" + info = BuildInfo( + version="1.0.0", + installed_datetime=datetime(2026, 9, 20, 15, 5, tzinfo=UTC), + ) + + lines = version_lines(info) + + assert lines[0] == "qtxterm 1.0.0" + assert any(line.startswith("installed") for line in lines) + assert not any(line.startswith("commit") for line in lines) + + +def test_version_string_joins_the_lines() -> None: + info = BuildInfo(version="1.0.0", describe="abc1234") + + assert version_string(info) == "qtxterm 1.0.0\nabc1234"