fix/refactor: use generic ModuleStatus class for getting status of optional modules (#1427)

* fix/refactor: use generic ModuleStatus class for getting status of optional modules

* fix: add and order macOS bin locations by PATH precedence

* ui: add title for optional modules section in the about window

* fix: revert errant license change

* fix: return which(cmd) instead of the original cmd

* refactor: implement review feedback

* feat(ui): add tooltips for module locations on about screen
This commit is contained in:
Travis Abendshien
2026-07-05 15:25:56 -07:00
committed by GitHub
parent 85a51f8e2b
commit 23ef9964d3
14 changed files with 309 additions and 152 deletions
+59
View File
@@ -0,0 +1,59 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: MIT
import contextlib
from typing import override
import structlog
from tagstudio.core.utils.module_status import ModuleStatus
from tagstudio.core.utils.silent_subprocess import (
silent_run, # pyright: ignore[reportUnknownVariableType]
)
logger = structlog.get_logger(__name__)
class _FfModuleStatus(ModuleStatus):
"""A base class that implements common logic for reading FFmpeg/FFprobe version output."""
_FFMPEG = "ffmpeg"
_FFPROBE = "ffprobe"
@classmethod
def ff_version(cls, command: str):
ff_cmd = cls._which(command)
if ff_cmd:
out = silent_run([ff_cmd, "-version"], shell=False, capture_output=True, text=True)
if out.returncode == 0:
with contextlib.suppress(Exception):
return str(out.stdout).split(" ")[2]
class FfmpegStatus(_FfModuleStatus):
"""Class for getting the location and version of FFmpeg, if it exists."""
@override
@classmethod
def which(cls):
return cls._which(cls._FFMPEG)
@override
@classmethod
def _version(cls):
return cls.ff_version(cls._FFMPEG)
class FfprobeStatus(_FfModuleStatus):
"""Class for getting the location and version of FFprobe, if it exists."""
@override
@classmethod
def which(cls):
return cls._which(cls._FFPROBE)
@override
@classmethod
def _version(cls):
return cls.ff_version(cls._FFPROBE)
+75
View File
@@ -0,0 +1,75 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: MIT
import os
import platform
from shutil import which
import structlog
logger = structlog.get_logger(__name__)
user = os.environ.get("USER", None)
# NOTE: macOS does not make its PATH variable available to processes started outside the terminal.
# The following is a list of common directories to search for binaries in.
# TODO: Use a library for XDG compliance
_MACOS_BIN_LOCATIONS: list[str] = [
"", # Equates to PATH itself
# User level
"~/.local/share/bin/", # XDG-compliant user-created bin
"~/.local/bin/", # Fallback user-created bin
"~/.local/state/nix/profile/bin/", # XDG-compliant home Nix bin
"~/.nix-profile/bin/", # Fallback home Nix bin
# System level
f"/etc/profiles/per-user/{user}/bin/", # Per-user Nix bin
"/nix/var/nix/profiles/default/bin/", # Inherited Nix bin
"/opt/homebrew/bin/", # Homebrew bin
"/usr/local/bin/", # Administrator-configured bin
"/usr/bin/", # System bin
"/bin/", # Core system bin
]
class ModuleStatus:
"""An abstract base class for module status logic including the binary location and version."""
__cached_location: str | None = None
__cached_version: str | None = None
@classmethod
def which(cls) -> str | None:
raise NotImplementedError()
@classmethod
def _cache_location(cls) -> str | None:
raise NotImplementedError()
@classmethod
def version(cls) -> str | None:
if cls.__cached_version is None:
cls.__cached_version = cls._version()
return cls.__cached_version
@classmethod
def _version(cls) -> str | None:
raise NotImplementedError()
@classmethod
def _which(cls, cmd: str) -> str | None:
"""Internal method for determining the correct location for which().
Args:
cmd (str): The process command to search for.
"""
if cls.__cached_location:
return cls.__cached_location
if platform.system() == "Darwin":
for loc in _MACOS_BIN_LOCATIONS:
full_command = which(loc + cmd)
if full_command:
cls.__cached_location = full_command
return full_command
cls.__cached_location = which(cmd)
return cls.__cached_location
@@ -0,0 +1,34 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: MIT
import contextlib
from typing import override
import structlog
from tagstudio.core.utils.module_status import ModuleStatus
from tagstudio.core.utils.silent_subprocess import (
silent_run, # pyright: ignore[reportUnknownVariableType]
)
logger = structlog.get_logger(__name__)
class RipgrepStatus(ModuleStatus):
"""Class for getting the location and version of ripgrep, if it exists."""
@override
@classmethod
def which(cls):
return cls._which("rg")
@override
@classmethod
def _version(cls):
ripgrep_cmd = cls.which()
if ripgrep_cmd:
out = silent_run([ripgrep_cmd, "-V"], shell=False, capture_output=True, text=True)
if out.returncode == 0:
with contextlib.suppress(Exception):
return str(out.stdout).split(" ")[1].rstrip("\n")
@@ -3,14 +3,13 @@
import typing
from shutil import which
from warnings import catch_warnings
from tagstudio.core.library.alchemy.fields import BaseFieldTemplate
from tagstudio.core.library.alchemy.library import Library
from tagstudio.core.utils.ffmpeg_status import FfmpegStatus, FfprobeStatus
from tagstudio.qt.controllers.field_template_search_panel_controller import FieldTemplateSearchModal
from tagstudio.qt.controllers.tag_search_panel_controller import TagSearchModal
from tagstudio.qt.previews.vendored.ffmpeg import FFMPEG_CMD, FFPROBE_CMD
from tagstudio.qt.translations import Translations
from tagstudio.qt.views.preview_panel_view import PreviewPanelView
@@ -59,7 +58,7 @@ class PreviewPanel(PreviewPanelView):
self._containers.update_from_entry(self._selected[0])
def _toggle_ffmpeg_warning(self, enable_warning: bool = True) -> None:
if enable_warning and (not which(FFMPEG_CMD) or not which(FFPROBE_CMD)):
if enable_warning and (not FfmpegStatus.which() or not FfprobeStatus.which()):
self._ffmpeg_warning_widget.show()
return
+3 -3
View File
@@ -6,9 +6,7 @@ from pathlib import Path
import ffmpeg
from tagstudio.qt.previews.vendored.ffmpeg import (
probe, # pyright: ignore[reportUnknownVariableType]
)
from tagstudio.qt.previews.vendored.probe import probe
def is_readable_video(filepath: Path | str):
@@ -21,6 +19,8 @@ def is_readable_video(filepath: Path | str):
"""
try:
result = probe(Path(filepath))
if not result:
return False
for stream in result["streams"]:
# DRM check
if stream.get("codec_tag_string") in [
+56 -33
View File
@@ -4,7 +4,6 @@
import math
from pathlib import Path
from shutil import which
from PIL import ImageQt
from PySide6.QtCore import QSize, Qt
@@ -28,15 +27,16 @@ from tagstudio.core.constants import (
VERSION_BRANCH,
)
from tagstudio.core.ts_core import TagStudioCore
from tagstudio.core.utils.ffmpeg_status import FfmpegStatus, FfprobeStatus
from tagstudio.core.utils.ripgrep_status import RipgrepStatus
from tagstudio.core.utils.str_formatting import is_version_outdated
from tagstudio.core.utils.types import unwrap
from tagstudio.qt.controllers.clickable_label import ClickableLabel
from tagstudio.qt.models.palette import ColorType, UiColor, get_ui_color
from tagstudio.qt.previews.vendored import ffmpeg
from tagstudio.qt.resource_manager import ResourceManager
from tagstudio.qt.translations import Translations
from tagstudio.qt.utils.file_opener import open_file
from tagstudio.qt.views.stylesheets.stylesheets import form_content_style
from tagstudio.qt.views.stylesheets.stylesheets import form_content_style, header
class AboutModal(QWidget):
@@ -66,6 +66,10 @@ class AboutModal(QWidget):
self.content_layout.setContentsMargins(12, 12, 12, 12)
self.content_layout.setSpacing(12)
red = get_ui_color(ColorType.PRIMARY, UiColor.RED)
green = get_ui_color(ColorType.PRIMARY, UiColor.GREEN)
amber = get_ui_color(ColorType.PRIMARY, UiColor.AMBER)
# TagStudio Logo -------------------------------------------------------
self.logo_widget = QLabel()
self.logo_pixmap = QPixmap.fromImage(ImageQt.ImageQt(self.rm.ts_logo_text_color))
@@ -78,7 +82,7 @@ class AboutModal(QWidget):
self.logo_widget.setAlignment(Qt.AlignmentFlag.AlignCenter)
# Version --------------------------------------------------------------
self.version_label = QLabel(f"<h3>{AboutModal.VERSION_STR}</h3>")
self.version_label = QLabel(header(AboutModal.VERSION_STR, 3))
self.version_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
# Copyright ------------------------------------------------------------
@@ -94,31 +98,10 @@ class AboutModal(QWidget):
self.desc_label.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
# System Info ----------------------------------------------------------
ff_version = ffmpeg.version()
red = get_ui_color(ColorType.PRIMARY, UiColor.RED)
green = get_ui_color(ColorType.PRIMARY, UiColor.GREEN)
amber = get_ui_color(ColorType.PRIMARY, UiColor.AMBER)
missing = Translations["generic.missing"]
found = Translations["about.module.found"]
ffmpeg_status = f'<span style="color:{red}">{missing}</span>'
if ff_version["ffmpeg"] is not None:
ffmpeg_status = (
f'<span style="color:{green}">{found}</span> (' + ff_version["ffmpeg"] + ")"
)
ffprobe_status = f'<span style="color:{red}">{missing}</span>'
if ff_version["ffprobe"] is not None:
ffprobe_status = (
f'<span style="color:{green}">{found}</span> (' + ff_version["ffprobe"] + ")"
)
ripgrep_status = f'<span style="color:{amber}">{missing}</span>'
if which("rg") is not None:
ripgrep_status = f'<span style="color:{green}">{found}</span>'
self.system_info_widget = QWidget()
self.system_info_layout = QFormLayout(self.system_info_widget)
self.system_info_layout.setSpacing(4)
self.system_info_layout.setLabelAlignment(Qt.AlignmentFlag.AlignRight)
# Version
@@ -149,46 +132,85 @@ class AboutModal(QWidget):
# TODO: Add row for "App Cache Path" (currently that TagStudio.ini file)
# Optional Modules -----------------------------------------------------
self.parent_optional_modules_widget = QWidget()
self.parent_optional_modules_layout = QVBoxLayout(self.parent_optional_modules_widget)
self.parent_optional_modules_layout.setContentsMargins(0, 0, 0, 0)
self.parent_optional_modules_layout.setSpacing(0)
# Subtitle
self.optional_modules_label = QLabel(header(Translations["about.modules.title"], 4))
self.optional_modules_widget = QWidget()
self.optional_modules_layout = QFormLayout(self.optional_modules_widget)
self.optional_modules_layout.setSpacing(4)
self.optional_modules_layout.setLabelAlignment(Qt.AlignmentFlag.AlignRight)
ffmpeg_ver = FfmpegStatus.version()
ffprobe_ver = FfprobeStatus.version()
ripgrep_ver = RipgrepStatus.version()
missing = Translations["generic.missing"]
found = Translations["about.module.found"]
ffmpeg_status = f'<span style="color:{red}">{missing}</span>'
if ffmpeg_ver is not None:
ffmpeg_status = f'<span style="color:{green}">{found}</span> (' + ffmpeg_ver + ")"
ffprobe_status = f'<span style="color:{red}">{missing}</span>'
if ffprobe_ver is not None:
ffprobe_status = f'<span style="color:{green}">{found}</span> (' + ffprobe_ver + ")"
ripgrep_status = f'<span style="color:{amber}">{missing}</span>'
if ripgrep_ver is not None:
ripgrep_status = f'<span style="color:{green}">{found}</span> (' + ripgrep_ver + ")"
# FFmpeg Status
ffmpeg_path_title = QLabel("FFmpeg")
ffmpeg_path_content = ClickableLabel(f"{ffmpeg_status}")
ffmpeg_location = which(ffmpeg._get_ffmpeg_location()) # pyright: ignore[reportPrivateUsage]
ffmpeg_location = FfmpegStatus.which()
if ffmpeg_location:
ffmpeg_path_content.clicked.connect(
lambda: open_file(ffmpeg_location, file_manager=True)
)
ffmpeg_path_content.setCursor(Qt.CursorShape.PointingHandCursor)
ffmpeg_path_content.setToolTip(ffmpeg_location)
ffmpeg_path_content.setStyleSheet(form_content_style())
self.system_info_layout.addRow(ffmpeg_path_title, ffmpeg_path_content)
self.optional_modules_layout.addRow(ffmpeg_path_title, ffmpeg_path_content)
ffmpeg_path_content.setMaximumWidth(ffmpeg_path_content.sizeHint().width())
# FFprobe Status
ffprobe_path_title = QLabel("FFprobe")
ffprobe_path_content = ClickableLabel(f"{ffprobe_status}")
ffprobe_location = which(ffmpeg._get_ffprobe_location()) # pyright: ignore[reportPrivateUsage]
ffprobe_location = FfprobeStatus.which()
if ffprobe_location:
ffprobe_path_content.clicked.connect(
lambda: open_file(ffprobe_location, file_manager=True)
)
ffprobe_path_content.setCursor(Qt.CursorShape.PointingHandCursor)
ffprobe_path_content.setToolTip(ffprobe_location)
ffprobe_path_content.setStyleSheet(form_content_style())
self.system_info_layout.addRow(ffprobe_path_title, ffprobe_path_content)
self.optional_modules_layout.addRow(ffprobe_path_title, ffprobe_path_content)
ffprobe_path_content.setMaximumWidth(ffprobe_path_content.sizeHint().width())
# ripgrep Status
# TODO: Add a central class to find ripgrep info, similar to ffmpeg
ripgrep_path_title = QLabel("ripgrep") # NOTE: Don't localize
ripgrep_path_content = ClickableLabel()
ripgrep_path_content.setText(f"{ripgrep_status}") # TODO: Pass in constructor after #1386
ripgrep_location = which("rg")
ripgrep_location = RipgrepStatus.which()
if ripgrep_location:
ripgrep_path_content.clicked.connect(
lambda: open_file(ripgrep_location, file_manager=True)
)
ripgrep_path_content.setCursor(Qt.CursorShape.PointingHandCursor)
ripgrep_path_content.setToolTip(ripgrep_location)
ripgrep_path_content.setStyleSheet(form_content_style())
ripgrep_path_content.setMaximumWidth(ripgrep_path_content.sizeHint().width())
self.system_info_layout.addRow(ripgrep_path_title, ripgrep_path_content)
self.optional_modules_layout.addRow(ripgrep_path_title, ripgrep_path_content)
self.parent_optional_modules_layout.addWidget(self.optional_modules_label)
self.parent_optional_modules_layout.addWidget(self.optional_modules_widget)
# Links ----------------------------------------------------------------
@@ -217,6 +239,7 @@ class AboutModal(QWidget):
self.content_layout.addWidget(self.version_label)
self.content_layout.addWidget(self.desc_label)
self.content_layout.addWidget(self.system_info_widget)
self.content_layout.addWidget(self.parent_optional_modules_widget)
self.content_layout.addStretch(1)
self.content_layout.addWidget(self.links_label)
self.content_layout.addWidget(self.copyright_label)
@@ -1,102 +0,0 @@
# SPDX-FileCopyrightText: (c) 2022 Karl Kroening (kkroening)
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
# Vendored from ffmpeg-python and ffmpeg-python PR#790 by amamic1803
import contextlib
import json
import os
import platform
import subprocess
from shutil import which
import ffmpeg
import structlog
from tagstudio.core.utils.silent_subprocess import silent_popen, silent_run
logger = structlog.get_logger(__name__)
user = os.environ.get("USER", None)
FFMPEG_MACOS_LOCATIONS: list[str] = [
"",
"/opt/homebrew/bin/",
"/usr/local/bin/",
f"/etc/profiles/per-user/{user}/bin",
]
# TODO: Make this more intuitive to use in other classes
def _get_ffprobe_location() -> str:
cmd: str = "ffprobe"
if platform.system() == "Darwin":
for loc in FFMPEG_MACOS_LOCATIONS:
if which(loc + cmd):
cmd = loc + cmd
break
logger.info(
f"[FFmpeg] Using FFprobe location: {cmd}{' (Found)' if which(cmd) else ' (Not Found)'}"
)
return cmd
# TODO: Make this more intuitive to use in other classes
def _get_ffmpeg_location() -> str:
cmd: str = "ffmpeg"
if platform.system() == "Darwin":
for loc in FFMPEG_MACOS_LOCATIONS:
if which(loc + cmd):
cmd = loc + cmd
break
logger.info(
f"[FFmpeg] Using FFmpeg location: {cmd}{' (Found)' if which(cmd) else ' (Not Found)'}"
)
return cmd
FFPROBE_CMD = _get_ffprobe_location()
FFMPEG_CMD = _get_ffmpeg_location()
def probe(filename, cmd=FFPROBE_CMD, timeout=None, **kwargs):
"""Run ffprobe on the specified file and return a JSON representation of the output.
Raises:
:class:`ffmpeg.Error`: if ffprobe returns a non-zero exit code,
an :class:`Error` is returned with a generic error message.
The stderr output can be retrieved by accessing the
``stderr`` property of the exception.
"""
args = [cmd, "-show_format", "-show_streams", "-of", "json"]
args += ffmpeg._utils.convert_kwargs_to_cmd_line_args(kwargs) # pyright: ignore[reportAttributeAccessIssue]
args += [filename]
# PATCHED
p = silent_popen(args, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
communicate_kwargs = {}
if timeout is not None:
communicate_kwargs["timeout"] = timeout
out, err = p.communicate(**communicate_kwargs)
if p.returncode != 0:
raise ffmpeg.Error("ffprobe", out, err)
return json.loads(out.decode("utf-8"))
def version():
"""Checks the version of FFmpeg and FFprobe and returns None if they dont exist."""
version: dict[str, str | None] = {"ffmpeg": None, "ffprobe": None}
if which(FFMPEG_CMD):
ret = silent_run([FFMPEG_CMD, "-version"], shell=False, capture_output=True, text=True)
if ret.returncode == 0:
with contextlib.suppress(Exception):
version["ffmpeg"] = str(ret.stdout).split(" ")[2]
if which(FFPROBE_CMD):
ret = silent_run([FFPROBE_CMD, "-version"], shell=False, capture_output=True, text=True)
if ret.returncode == 0:
with contextlib.suppress(Exception):
version["ffprobe"] = str(ret.stdout).split(" ")[2]
return version
@@ -0,0 +1,45 @@
# SPDX-FileCopyrightText: (c) 2022 Karl Kroening (kkroening)
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
# Vendored from ffmpeg-python and ffmpeg-python PR#790 by amamic1803
import json
import subprocess
from pathlib import Path
import ffmpeg
import structlog
from tagstudio.core.utils.ffmpeg_status import FfprobeStatus
from tagstudio.core.utils.silent_subprocess import (
silent_popen, # pyright: ignore[reportUnknownVariableType]
)
logger = structlog.get_logger(__name__)
def probe(filename: Path | str, timeout: int | None = None, **kwargs: ...):
"""Run ffprobe on the specified file and return a JSON representation of the output.
Raises:
Error: If ffprobe returns a non-zero exit code, an Error is raised
with a generic error message. The stderr output can be retrieved
by accessing the `stderr` property of the exception.
"""
ffprobe_cmd: str | None = FfprobeStatus.which()
if not ffprobe_cmd:
return
args: list[str] = [ffprobe_cmd, "-show_format", "-show_streams", "-of", "json"]
args += ffmpeg._utils.convert_kwargs_to_cmd_line_args(kwargs) # pyright: ignore
args += [filename] # pyright: ignore
# PATCHED
p = silent_popen(args, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
communicate_kwargs = {}
if timeout is not None:
communicate_kwargs["timeout"] = timeout
out, err = p.communicate(**communicate_kwargs)
if p.returncode != 0:
raise ffmpeg.Error("ffprobe", out, err)
return json.loads(out.decode("utf-8"))
@@ -18,7 +18,7 @@ from tempfile import NamedTemporaryFile
from pydub.logging_utils import log_conversion, log_subprocess_output
from pydub.utils import fsdecode
from tagstudio.qt.previews.vendored.ffmpeg import FFMPEG_CMD
from tagstudio.core.utils.ffmpeg_status import FfmpegStatus
try:
from itertools import izip
@@ -161,7 +161,7 @@ class _AudioSegment:
slice = a[5000:10000] # get a slice from 5 to 10 seconds of an mp3
"""
converter = FFMPEG_CMD
converter = FfmpegStatus.which()
# TODO: remove in 1.0 release
# maintain backwards compatibility for ffmpeg attr (now called converter)
@@ -727,7 +727,7 @@ class _AudioSegment:
stdin_parameter = None
stdin_data = None
else:
if cls.converter == FFMPEG_CMD:
if cls.converter == FfmpegStatus.which():
conversion_command += [
"-read_ahead_limit",
str(read_ahead_limit),
@@ -14,13 +14,13 @@ from pydub.utils import (
get_extra_info,
)
from tagstudio.core.utils.ffmpeg_status import FfprobeStatus
from tagstudio.core.utils.silent_subprocess import silent_popen
from tagstudio.qt.previews.vendored.ffmpeg import FFPROBE_CMD
def _mediainfo_json(filepath, read_ahead_limit=-1):
"""Return json dictionary with media info(codec, duration, size, bitrate...) from filepath."""
prober = FFPROBE_CMD
prober = FfprobeStatus.which()
command_args = [
"-v",
"info",
+22 -1
View File
@@ -39,7 +39,6 @@ from PySide6.QtGui import (
)
from PySide6.QtWidgets import QApplication, QFileDialog, QMessageBox, QPushButton, QScrollArea
# This import has side-effect of importing PySide resources
import tagstudio.qt.resources_rc # noqa: F401 # pyright: ignore[reportUnusedImport]
from tagstudio.core.constants import TAG_ARCHIVED, TAG_FAVORITE, VERSION, VERSION_BRANCH
from tagstudio.core.driver import DriverMixin
@@ -52,6 +51,11 @@ from tagstudio.core.library.refresh import RefreshTracker
from tagstudio.core.media_types import MediaCategories
from tagstudio.core.query_lang.util import ParsingError
from tagstudio.core.ts_core import TagStudioCore
# This import has side-effect of importing PySide resources
from tagstudio.core.utils.ffmpeg_status import FfmpegStatus, FfprobeStatus
from tagstudio.core.utils.module_status import ModuleStatus
from tagstudio.core.utils.ripgrep_status import RipgrepStatus
from tagstudio.core.utils.str_formatting import is_version_outdated
from tagstudio.core.utils.types import unwrap
from tagstudio.qt.cache_manager import CacheManager
@@ -320,6 +324,9 @@ class QtDriver(DriverMixin, QObject):
timer.start(500)
timer.timeout.connect(lambda: None)
# Detect optional modules and versions for logging
self.log_optional_modules()
# self.main_window = loader.load(home_path)
self.main_window = MainWindow(self)
self.main_window.setWindowTitle(self.base_title)
@@ -1755,3 +1762,17 @@ class QtDriver(DriverMixin, QObject):
def clear_selected(self):
self._selected.clear()
self.main_window.thumb_layout.update_selected()
def log_optional_modules(self) -> None:
"""Logs the status of optional modules."""
status_classes: list[tuple[str, type[ModuleStatus]]] = [
("FFmpeg", FfmpegStatus),
("FFprobe", FfprobeStatus),
("ripgrep", RipgrepStatus),
]
for name, sc in status_classes:
if sc.which():
logger.info(f"[QtDriver] {name} found", which=sc.which(), version=sc.version())
else:
logger.warning(f"[QtDriver] {sc} not found")
@@ -205,14 +205,16 @@ def container_style() -> str:
def form_content_style() -> str:
return f"""
background-color: {
QLabel{{
background-color: {
Theme.COLOR_BG.value
if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark
else Theme.COLOR_BG_LIGHT.value
};
border-radius: 3px;
font-weight: 500;
padding: 1px;
border-radius: 3px;
font-weight: 500;
padding: 1px;
}}
"""
@@ -4,6 +4,7 @@
"about.description": "TagStudio is a photo and file organization application with an underlying tag-based system that focuses on giving freedom and flexibility to the user. No proprietary programs or formats, no sea of sidecar files, and no complete upheaval of your filesystem structure.",
"about.documentation": "Documentation",
"about.module.found": "Found",
"about.modules.title": "Optional Modules",
"about.title": "About TagStudio",
"about.version": "Version",
"about.version.latest": "{built_version} (Latest Release: {latest_version})",
+1 -1
View File
@@ -11,7 +11,7 @@ def test_github_api_unavailable(qtbot: QtBot, mocker) -> None:
mocker.patch(
"requests.get",
side_effect=ConnectionError(
"Failed to resolve 'api.github.com' ([Errno -3] Temporary failure in name resolution)"
"Emulating a failure with 'api.github.com' ([Errno 0] This should be handled)"
),
)
modal = AboutModal("/tmp")