fix(app): CUDA toolkit detection, niri IBus pin warning, Gitea update checks, legacy dictionary path #36
+3
-1
@@ -4,7 +4,9 @@ How to upgrade an existing install, plus release notes for the current series.
|
||||
|
||||
## Quick update
|
||||
|
||||
Voculinux checks GitHub Releases in the background about every six hours (and shortly after startup). When a newer build is on your selected channel, the tray menu gains an **Update Available...** entry and Settings -> About shows a green **New** badge; open that page for release notes and download links. Updates are not installed automatically. Re-run the installer (or your package manager) as below.
|
||||
Voculinux checks its own release server, the Gitea repository at `git.bongbetic.com/xavierk/voculinux`, in the background about every six hours (and shortly after startup). Upstream VocaHQ is never queried. When a newer build is on your selected channel, the tray menu gains an **Update Available...** entry and Settings -> About shows a green **New** badge; open that page for release notes and download links. Updates are not installed automatically. Re-run the installer (or your package manager) as below.
|
||||
|
||||
A package-managed install (the Void `voculinux` package) makes no update check at all. Its updates arrive with `sudo xbps-install -Su`, and Settings -> About says so.
|
||||
|
||||
### Official installer
|
||||
|
||||
|
||||
@@ -8,11 +8,19 @@ always wins: no merging, no write-back to the legacy location.
|
||||
|
||||
Exactly-once falls out of existence checks — a copied file is never copied
|
||||
again, and a run that already has new locations imports nothing.
|
||||
|
||||
A stored custom-dictionary path that names an importer-owned legacy file (one of
|
||||
the payload files above) is rewritten in the copied configuration to that file's
|
||||
new location, whether or not this run copied it. Edits then never land in the
|
||||
legacy file. Any other path, including a user-chosen file elsewhere or another
|
||||
file inside the legacy directory, is kept as stored.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
from typing import Optional
|
||||
|
||||
from .app_identity import APP_DIR_NAME, LEGACY_APP_DIR_NAME
|
||||
|
||||
@@ -77,6 +85,50 @@ def _copy_once(src: str, dst: str) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
def _payload_destination(stored: str, payload: dict[str, str]) -> Optional[str]:
|
||||
"""Return the new location of the importer-owned legacy file ``stored`` names."""
|
||||
try:
|
||||
target = os.path.realpath(os.path.expanduser(stored))
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
for source, destination in payload.items():
|
||||
if target == os.path.realpath(source):
|
||||
return destination
|
||||
return None
|
||||
|
||||
|
||||
def _repoint_dictionary_path(config_file: str, payload: dict[str, str]) -> None:
|
||||
"""Rewrite the copied config's dictionary path when it names a payload file.
|
||||
|
||||
Only the new configuration copy is written; the legacy file is only read.
|
||||
"""
|
||||
try:
|
||||
with open(config_file, encoding="utf-8") as handle:
|
||||
config = json.load(handle)
|
||||
except (OSError, ValueError) as error:
|
||||
logger.warning("Legacy import could not read the copied configuration: %s", error)
|
||||
return
|
||||
dictionary = config.get("dictionary") if isinstance(config, dict) else None
|
||||
if not isinstance(dictionary, dict):
|
||||
return
|
||||
stored = dictionary.get("file_path")
|
||||
if not isinstance(stored, str) or not stored.strip():
|
||||
return
|
||||
destination = _payload_destination(stored, payload)
|
||||
if destination is None:
|
||||
return
|
||||
dictionary["file_path"] = destination
|
||||
temporary = config_file + ".tmp"
|
||||
try:
|
||||
with open(temporary, "w", encoding="utf-8") as handle:
|
||||
json.dump(config, handle, indent=4)
|
||||
os.replace(temporary, config_file)
|
||||
except OSError as error:
|
||||
logger.warning("Legacy import could not point the dictionary at %s: %s", destination, error)
|
||||
return
|
||||
logger.info("Legacy import pointed the custom dictionary at %s", destination)
|
||||
|
||||
|
||||
def import_legacy_once() -> list[str]:
|
||||
"""Copy legacy Vocalinux user data into the new locations, once.
|
||||
|
||||
@@ -96,18 +148,25 @@ def import_legacy_once() -> list[str]:
|
||||
return []
|
||||
|
||||
imported: list[str] = []
|
||||
# Every importer-owned legacy file and its new location, copied this run or
|
||||
# not: a stored path to one of them must never resolve to the legacy file.
|
||||
payload: dict[str, str] = {}
|
||||
for filename in CONFIG_PAYLOAD:
|
||||
src = os.path.join(legacy_config, filename)
|
||||
dst = os.path.join(new_config_dir(), filename)
|
||||
payload[src] = dst
|
||||
if _copy_once(src, dst):
|
||||
imported.append(dst)
|
||||
legacy_data = legacy_data_dir()
|
||||
if os.path.isdir(legacy_data):
|
||||
for filename in DATA_PAYLOAD:
|
||||
src = os.path.join(legacy_data, filename)
|
||||
dst = os.path.join(new_data_dir(), filename)
|
||||
if _copy_once(src, dst):
|
||||
imported.append(dst)
|
||||
for filename in DATA_PAYLOAD:
|
||||
src = os.path.join(legacy_data, filename)
|
||||
dst = os.path.join(new_data_dir(), filename)
|
||||
payload[src] = dst
|
||||
if os.path.isdir(legacy_data) and _copy_once(src, dst):
|
||||
imported.append(dst)
|
||||
copied_config = os.path.join(new_config_dir(), CONFIG_FILENAME)
|
||||
if copied_config in imported:
|
||||
_repoint_dictionary_path(copied_config, payload)
|
||||
if imported:
|
||||
logger.info("Legacy Vocalinux import complete: %d file(s)", len(imported))
|
||||
return imported
|
||||
|
||||
@@ -19,14 +19,20 @@ that change is deliberate.
|
||||
|
||||
Backend order follows the same readiness rule: CUDA ranks before Vulkan
|
||||
only when a complete architecture-matching CUDA toolkit is installed
|
||||
(``nvidia-smi`` sees a GPU, ``nvcc`` answers, compute capability >= 7.0);
|
||||
otherwise the order is Vulkan, then CPU. A driver without the toolkit is
|
||||
(``nvidia-smi`` sees a GPU, compute capability >= 7.0, and nvcc answers on
|
||||
PATH or a complete toolkit root under ``CUDA_HOME``, ``CUDA_PATH`` or
|
||||
``/usr/local/cuda*``); otherwise the order is Vulkan, then CPU. The order only
|
||||
names backends the installed build compiled in. A driver without the toolkit is
|
||||
exactly what made the old VRAM-aware recommendation unreachable.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import platform
|
||||
import shutil
|
||||
import subprocess
|
||||
from collections.abc import Mapping
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -59,6 +65,14 @@ MIN_CUDA_COMPUTE_CAPABILITY: tuple[int, int] = (7, 0)
|
||||
#: discrete card. A CPU build keeps its model in system RAM.
|
||||
GPU_BACKENDS: tuple[str, ...] = ("vulkan", "cuda")
|
||||
|
||||
#: Environment variables that may name a CUDA toolkit root, in probe order.
|
||||
_CUDA_ROOT_ENV_VARS: tuple[str, ...] = ("CUDA_HOME", "CUDA_PATH")
|
||||
#: Directory whose ``cuda*`` entries are probed as toolkit roots. Tests redirect it.
|
||||
_CUDA_USR_LOCAL_ROOT: Path = Path("/usr/local")
|
||||
#: Toolkit ``targets/`` directory for each host machine. Voculinux publishes
|
||||
#: only glibc x86_64, so any other machine has no matching toolkit target.
|
||||
_CUDA_TARGET_FOR_MACHINE: dict[str, str] = {"x86_64": "x86_64-linux"}
|
||||
|
||||
|
||||
def _floor_rung(language_is_english: bool) -> str:
|
||||
"""Return the floor rung for the language: English-only pairs with ``.en``."""
|
||||
@@ -168,6 +182,67 @@ def cuda_toolkit_ready_from_probe(
|
||||
return compute_cap >= MIN_CUDA_COMPUTE_CAPABILITY
|
||||
|
||||
|
||||
def _toolkit_is_complete(root: Path, machine: str) -> bool:
|
||||
"""Return whether ``root`` holds an nvcc, the runtime header and libcudart.
|
||||
|
||||
``machine`` must have a toolkit target: the libraries are looked up in that
|
||||
target's ``lib`` directory (and ``lib64`` on x86_64, where it is a link to
|
||||
the same place in the runfile layout).
|
||||
"""
|
||||
target = _CUDA_TARGET_FOR_MACHINE.get(machine)
|
||||
if target is None:
|
||||
return False
|
||||
nvcc = root / "bin" / "nvcc"
|
||||
if not (nvcc.is_file() and os.access(nvcc, os.X_OK)):
|
||||
return False
|
||||
if not (root / "include" / "cuda_runtime.h").is_file():
|
||||
return False
|
||||
lib_dirs = [root / "targets" / target / "lib"]
|
||||
if machine == "x86_64":
|
||||
lib_dirs.append(root / "lib64")
|
||||
return any(lib.is_dir() and any(lib.glob("libcudart.so*")) for lib in lib_dirs)
|
||||
|
||||
|
||||
def _cuda_toolkit_roots(environ: Mapping[str, str]) -> list[Path]:
|
||||
"""Return candidate toolkit roots: CUDA_HOME, CUDA_PATH, then /usr/local/cuda*."""
|
||||
roots: list[Path] = []
|
||||
for name in _CUDA_ROOT_ENV_VARS:
|
||||
value = environ.get(name, "").strip()
|
||||
if value:
|
||||
roots.append(Path(value))
|
||||
if _CUDA_USR_LOCAL_ROOT.is_dir():
|
||||
roots.extend(
|
||||
sorted(
|
||||
(path for path in _CUDA_USR_LOCAL_ROOT.glob("cuda*") if path.is_dir()),
|
||||
reverse=True,
|
||||
)
|
||||
)
|
||||
return roots
|
||||
|
||||
|
||||
def _toolkit_root_is_complete(environ: Mapping[str, str], machine: str) -> bool:
|
||||
"""Return whether any candidate root is a complete toolkit for ``machine``."""
|
||||
checked: set[Path] = set()
|
||||
for root in _cuda_toolkit_roots(environ):
|
||||
try:
|
||||
resolved = root.resolve()
|
||||
except OSError:
|
||||
continue
|
||||
if resolved in checked:
|
||||
continue
|
||||
checked.add(resolved)
|
||||
if _toolkit_is_complete(resolved, machine):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _cuda_toolkit_installed() -> bool:
|
||||
"""Return whether a CUDA toolkit is usable: nvcc on PATH, or a complete root."""
|
||||
if _run_probe(["nvcc", "--version"]) is not None:
|
||||
return True
|
||||
return _toolkit_root_is_complete(os.environ, platform.machine())
|
||||
|
||||
|
||||
def cuda_toolkit_ready() -> bool:
|
||||
"""Probe the host for a complete architecture-matching CUDA toolkit."""
|
||||
smi = _run_probe(
|
||||
@@ -175,10 +250,9 @@ def cuda_toolkit_ready() -> bool:
|
||||
)
|
||||
if not smi:
|
||||
return False
|
||||
nvcc = _run_probe(["nvcc", "--version"])
|
||||
return cuda_toolkit_ready_from_probe(
|
||||
has_nvidia_gpu=True,
|
||||
nvcc_ok=nvcc is not None,
|
||||
nvcc_ok=_cuda_toolkit_installed(),
|
||||
compute_cap=_parse_first_compute_cap(smi),
|
||||
)
|
||||
|
||||
@@ -232,15 +306,27 @@ def free_memory_mib() -> float | None:
|
||||
return free_system_ram_mib()
|
||||
|
||||
|
||||
def preferred_backend_order() -> tuple[str, ...]:
|
||||
"""Return the backend attempt order for this host.
|
||||
def preferred_backend_order(build_backend: str | None = None) -> tuple[str, ...]:
|
||||
"""Return the backend attempt order for this host and installed build.
|
||||
|
||||
CUDA ranks before Vulkan only when a complete architecture-matching
|
||||
toolkit is installed; otherwise Vulkan, then CPU.
|
||||
``build_backend`` is the GPU backend the installed pywhispercpp build
|
||||
compiled in (``vulkan``, ``cuda`` or ``cpu``). The runtime can only use
|
||||
those, so the order names only them: CUDA first when a complete
|
||||
architecture-matching toolkit is installed, then Vulkan, then CPU. A
|
||||
Vulkan-only build never lists CUDA. ``None`` (build unknown) keeps the
|
||||
unfiltered order: CUDA, Vulkan, CPU with a toolkit, otherwise Vulkan, CPU.
|
||||
"""
|
||||
if cuda_toolkit_ready():
|
||||
return ("cuda", "vulkan", "cpu")
|
||||
return ("vulkan", "cpu")
|
||||
if build_backend is None:
|
||||
if cuda_toolkit_ready():
|
||||
return ("cuda", "vulkan", "cpu")
|
||||
return ("vulkan", "cpu")
|
||||
order: list[str] = []
|
||||
if build_backend == "cuda" and cuda_toolkit_ready():
|
||||
order.append("cuda")
|
||||
if build_backend == "vulkan":
|
||||
order.append("vulkan")
|
||||
order.append("cpu")
|
||||
return tuple(order)
|
||||
|
||||
|
||||
def describe_environment() -> str:
|
||||
|
||||
@@ -1801,7 +1801,7 @@ class SpeechRecognitionManager:
|
||||
ggml_devices = detect_ggml_gpu_devices()
|
||||
logger.info(
|
||||
"whisper.cpp backend selection priority: %s",
|
||||
" -> ".join(preferred_backend_order()),
|
||||
" -> ".join(preferred_backend_order(actual_gpu_backend)),
|
||||
)
|
||||
|
||||
selected_gpu_device = None
|
||||
|
||||
@@ -117,6 +117,14 @@ def _is_niri_session() -> bool:
|
||||
return any("niri" in value.lower() for value in desktop_values)
|
||||
|
||||
|
||||
#: Logged once when an explicit ibus pin is honoured on niri (spec #16).
|
||||
_NIRI_IBUS_PIN_WARNING = (
|
||||
"IBus on niri can report success while text is silently dropped: niri does not "
|
||||
"relay IBus commits to native Wayland windows. The explicit ibus pin is honoured; "
|
||||
"remove it to type directly with wtype or ydotool instead."
|
||||
)
|
||||
|
||||
|
||||
def _kde_wayland_ibus_hint() -> str:
|
||||
"""Return the KDE Plasma Wayland IBus setup hint."""
|
||||
return (
|
||||
@@ -989,13 +997,16 @@ class TextInjector:
|
||||
# reports the commit as delivered and the text never arrives.
|
||||
# The other two guards are out of scope for this warning.
|
||||
if force_ibus and not self._wayland_compositor_bridges_ibus():
|
||||
logger.warning(
|
||||
"Compositor '%s' does not bridge IBus to native Wayland apps, but an "
|
||||
"explicit ibus pin overrides that check. Dictation may silently do "
|
||||
"nothing in native Wayland windows; remove the pin to fall back to "
|
||||
"wtype/ydotool.",
|
||||
os.environ.get("XDG_CURRENT_DESKTOP", "unknown"),
|
||||
)
|
||||
if _is_niri_session():
|
||||
logger.warning(_NIRI_IBUS_PIN_WARNING)
|
||||
else:
|
||||
logger.warning(
|
||||
"Compositor '%s' does not bridge IBus to native Wayland apps, but an "
|
||||
"explicit ibus pin overrides that check. Dictation may silently do "
|
||||
"nothing in native Wayland windows; remove the pin to fall back to "
|
||||
"wtype/ydotool.",
|
||||
os.environ.get("XDG_CURRENT_DESKTOP", "unknown"),
|
||||
)
|
||||
try:
|
||||
if wayland_scoped_ibus and not ibus_active:
|
||||
logger.info(
|
||||
|
||||
@@ -31,7 +31,7 @@ VOCAHQ_DISCORD_URL = "https://discord.gg/t6muquAJbm"
|
||||
VOCAHQ_X_URL = "https://x.com/vocahq"
|
||||
VOCAHQ_MAILTO_URL = "mailto:hello@vocahq.com"
|
||||
|
||||
# About page links that are not GitHub release URLs.
|
||||
# About page links that are not release URLs.
|
||||
_ABOUT_OPEN_URLS = frozenset(
|
||||
{
|
||||
VOCULINUX_SITE_URL,
|
||||
@@ -169,7 +169,7 @@ _VOCAHQ_FAMILY_LINKS = (
|
||||
|
||||
|
||||
def _can_open_url(url: str) -> bool:
|
||||
"""Return True for GitHub project URLs or the About page allowlist."""
|
||||
"""Return True for this project's release URLs or the About page allowlist."""
|
||||
return bool(url) and (url in _ABOUT_OPEN_URLS or is_trusted_release_url(url))
|
||||
|
||||
|
||||
@@ -472,7 +472,7 @@ class AboutSectionMixin(Gtk.Dialog):
|
||||
self.check_updates_btn.get_style_context().add_class("suffix-button")
|
||||
_style_action_button(self.check_updates_btn, width=80)
|
||||
self.check_updates_btn.set_tooltip_text(
|
||||
"Look up the latest release for this channel on GitHub"
|
||||
"Look up the latest Voculinux release for this channel"
|
||||
)
|
||||
self.check_updates_btn.connect("clicked", on_check_updates_clicked)
|
||||
|
||||
|
||||
@@ -70,6 +70,10 @@ from ..utils.model_choice import (
|
||||
priority_for_size,
|
||||
size_for_priority,
|
||||
)
|
||||
from ..utils.packaging import ( # noqa: E402
|
||||
PACKAGED_UPDATE_HINT,
|
||||
is_packaged_install,
|
||||
)
|
||||
from ..utils.paths import models_dir # noqa: E402
|
||||
from ..utils.proxy import ( # noqa: E402
|
||||
DEFAULT_PROXY_MODE,
|
||||
@@ -6542,13 +6546,17 @@ class SettingsDialog(AboutSectionMixin):
|
||||
self._open_web_url(self._about_release_url)
|
||||
|
||||
def _start_update_check(self):
|
||||
"""Kick off a background GitHub release lookup."""
|
||||
"""Kick off a background release lookup, or explain packaged updates."""
|
||||
channel = self._current_update_channel()
|
||||
if is_packaged_install():
|
||||
# Packaged installs never query a release server (ticket #27).
|
||||
self.update_status_row.set_subtitle(PACKAGED_UPDATE_HINT)
|
||||
return
|
||||
self._update_check_generation += 1
|
||||
generation = self._update_check_generation
|
||||
self.check_updates_btn.set_sensitive(False)
|
||||
self.check_updates_btn.set_label("…")
|
||||
self.update_status_row.set_subtitle(f"Checking GitHub ({channel})…")
|
||||
self.update_status_row.set_subtitle(f"Checking Voculinux releases ({channel})…")
|
||||
|
||||
# Invalidate any in-flight result; that worker will restart on completion.
|
||||
if self._update_check_in_progress:
|
||||
|
||||
@@ -335,7 +335,7 @@ class TrayIndicator:
|
||||
self._model_keepalive.start()
|
||||
self._model_keepalive.bump()
|
||||
|
||||
# Background GitHub update checks (tray menu + optional notification)
|
||||
# Background update checks (tray menu + optional notification)
|
||||
self._update_monitor = UpdateMonitor(
|
||||
get_enabled=self._update_checks_enabled,
|
||||
on_result=self._on_update_check_result,
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
"""Facts about installs managed by a system package manager.
|
||||
|
||||
Ticket #27 (Void app template): the xbps template installs an empty
|
||||
``usr/share/voculinux/packaged`` marker. When it exists, background
|
||||
update checks stay available as a signal, but every user-visible update
|
||||
affordance presents the package-manager path instead of release
|
||||
downloads, so packaged users are never misdirected away from
|
||||
package-manager updates.
|
||||
``usr/share/voculinux/packaged`` marker. When it exists, the install never
|
||||
queries a release server (``fetch_latest_release`` returns None without a
|
||||
network request), and the update affordances say that updates arrive via
|
||||
``xbps-install -Su``, never a release download.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
@@ -14,10 +13,12 @@ from typing import Optional
|
||||
#: Installed by the xbps template's post_install; absent everywhere else.
|
||||
PACKAGED_MARKER_PATH = Path("/usr/share/voculinux/packaged")
|
||||
|
||||
#: The Void command that delivers updates to a packaged install.
|
||||
PACKAGED_UPDATE_COMMAND = "sudo xbps-install -Su"
|
||||
|
||||
#: Presented wherever a packaged install would otherwise offer a download.
|
||||
PACKAGED_UPDATE_HINT = (
|
||||
"Installed from a system package: update with your package manager "
|
||||
"(on Void: sudo xbps-install -Syu voculinux)."
|
||||
f"Installed from a system package: updates arrive via {PACKAGED_UPDATE_COMMAND}."
|
||||
)
|
||||
|
||||
|
||||
@@ -38,7 +39,7 @@ def update_menu_tooltip(*, packaged: bool) -> str:
|
||||
if packaged:
|
||||
return (
|
||||
"Open Settings → About for release notes; "
|
||||
"update with your package manager (on Void: sudo xbps-install -Syu voculinux)"
|
||||
f"updates arrive via {PACKAGED_UPDATE_COMMAND}"
|
||||
)
|
||||
return "Open Settings → About for release notes and download links"
|
||||
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
"""Check GitHub Releases for newer Voculinux versions."""
|
||||
"""Check the Voculinux release server (Gitea) for newer versions.
|
||||
|
||||
Voculinux is a fork: its releases live on the personal Gitea instance. The
|
||||
upstream VocaHQ repository is never queried, and packaged installs never query
|
||||
a release server at all (their updates arrive through xbps).
|
||||
"""
|
||||
|
||||
import logging
|
||||
import re
|
||||
@@ -8,7 +13,7 @@ from urllib.parse import urlparse
|
||||
|
||||
from .. import version as _version_module
|
||||
from ..app_identity import UPDATE_CHECKER_USER_AGENT
|
||||
from ..version import __url__
|
||||
from .packaging import is_packaged_install
|
||||
from .proxy import requests_proxies
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -17,7 +22,12 @@ UPDATE_CHANNELS = ("stable", "nightly")
|
||||
DEFAULT_UPDATE_CHANNEL = "stable"
|
||||
DEFAULT_CHECK_AUTOMATICALLY = True
|
||||
|
||||
_GITHUB_REPO_RE = re.compile(r"https?://github\.com/(?P<owner>[^/]+)/(?P<repo>[^/]+?)(?:\.git)?/?$")
|
||||
#: Release source for source installs: this fork's repository on Gitea.
|
||||
UPDATE_REPO_URL = "https://git.bongbetic.com/xavierk/voculinux"
|
||||
|
||||
_REPO_URL_RE = re.compile(
|
||||
r"^https://(?P<host>[^/]+)/(?P<owner>[^/]+)/(?P<repo>[^/]+?)(?:\.git)?/?$"
|
||||
)
|
||||
_PRE_RELEASE_SUFFIX_RE = re.compile(
|
||||
r"^(?P<main>\d+(?:\.\d+)*)(?:[-.](?P<pre>(?:alpha|beta|rc|pre|dev)\.?\d*))?$",
|
||||
re.IGNORECASE,
|
||||
@@ -29,7 +39,7 @@ _DEV_DATE_RE = re.compile(r"(?:^|[.+-])dev(\d{8})(?:[.+-]|$)", re.IGNORECASE)
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ReleaseInfo:
|
||||
"""Metadata for a GitHub release."""
|
||||
"""Metadata for a release published on the release server."""
|
||||
|
||||
tag_name: str
|
||||
version: str
|
||||
@@ -47,16 +57,17 @@ def normalize_channel(channel: Optional[str]) -> str:
|
||||
return value if value in UPDATE_CHANNELS else DEFAULT_UPDATE_CHANNEL
|
||||
|
||||
|
||||
def _repo_parts(repo_url: str = __url__) -> tuple[str, str]:
|
||||
match = _GITHUB_REPO_RE.match(repo_url.strip())
|
||||
def _repo_parts(repo_url: str = UPDATE_REPO_URL) -> tuple[str, str, str]:
|
||||
"""Return ``(host, owner, repo)`` for an https Gitea repository URL."""
|
||||
match = _REPO_URL_RE.match(repo_url.strip())
|
||||
if not match:
|
||||
raise ValueError(f"Unsupported repository URL: {repo_url}")
|
||||
return match.group("owner"), match.group("repo")
|
||||
return match.group("host"), match.group("owner"), match.group("repo")
|
||||
|
||||
|
||||
def _repo_api_base(repo_url: str = __url__) -> str:
|
||||
owner, repo = _repo_parts(repo_url)
|
||||
return f"https://api.github.com/repos/{owner}/{repo}"
|
||||
def _repo_api_base(repo_url: str = UPDATE_REPO_URL) -> str:
|
||||
host, owner, repo = _repo_parts(repo_url)
|
||||
return f"https://{host}/api/v1/repos/{owner}/{repo}"
|
||||
|
||||
|
||||
def normalize_version(version: str) -> str:
|
||||
@@ -139,44 +150,24 @@ def is_update_available(
|
||||
return is_newer_version(current, latest.version)
|
||||
|
||||
|
||||
# github.com/jatinkrmalik/vocalinux redirects here after the org transfer.
|
||||
_VOCULINUX_GITHUB_OWNERS = frozenset({"VocaHQ", "jatinkrmalik"})
|
||||
|
||||
|
||||
def _trusted_path_prefixes(repo_url: str = __url__) -> tuple[str, ...]:
|
||||
"""Return GitHub path prefixes that belong to this project."""
|
||||
owner, repo = _repo_parts(repo_url)
|
||||
prefixes = [f"/{owner}/{repo}"]
|
||||
known_owners = {name.casefold() for name in _VOCULINUX_GITHUB_OWNERS}
|
||||
if repo.casefold() == "vocalinux" and owner.casefold() in known_owners:
|
||||
prefixes.extend(f"/{name}/vocalinux" for name in sorted(_VOCULINUX_GITHUB_OWNERS))
|
||||
seen: set[str] = set()
|
||||
unique: list[str] = []
|
||||
for prefix in prefixes:
|
||||
key = prefix.casefold()
|
||||
if key not in seen:
|
||||
seen.add(key)
|
||||
unique.append(prefix)
|
||||
return tuple(unique)
|
||||
|
||||
|
||||
def is_trusted_release_url(url: str, repo_url: str = __url__) -> bool:
|
||||
"""Return True when ``url`` points at this project's GitHub pages."""
|
||||
def is_trusted_release_url(url: str, repo_url: str = UPDATE_REPO_URL) -> bool:
|
||||
"""Return True when ``url`` points at this project's release pages on Gitea."""
|
||||
if not url:
|
||||
return False
|
||||
try:
|
||||
host, owner, repo = _repo_parts(repo_url)
|
||||
parsed = urlparse(url)
|
||||
prefixes = _trusted_path_prefixes(repo_url)
|
||||
except ValueError:
|
||||
return False
|
||||
if parsed.scheme != "https" or parsed.netloc.lower() != "github.com":
|
||||
if parsed.scheme != "https" or parsed.netloc.lower() != host.lower():
|
||||
return False
|
||||
path = parsed.path.rstrip("/")
|
||||
return any(path == prefix or path.startswith(prefix + "/") for prefix in prefixes)
|
||||
path = parsed.path.rstrip("/").casefold()
|
||||
prefix = f"/{owner}/{repo}".casefold()
|
||||
return path == prefix or path.startswith(prefix + "/")
|
||||
|
||||
|
||||
def _release_from_payload(
|
||||
data: dict, channel: str, repo_url: str = __url__
|
||||
data: dict, channel: str, repo_url: str = UPDATE_REPO_URL
|
||||
) -> Optional[ReleaseInfo]:
|
||||
tag_name = data.get("tag_name") or ""
|
||||
if not tag_name:
|
||||
@@ -199,71 +190,26 @@ def _release_from_payload(
|
||||
)
|
||||
|
||||
|
||||
def _html_url_from_relative_update_url(update_url: object, repo_url: str) -> str:
|
||||
"""Return a trusted tag URL from a relative GitHub ``update_url``, or empty."""
|
||||
if not isinstance(update_url, str):
|
||||
return ""
|
||||
if not update_url.startswith("/") or update_url.startswith("//"):
|
||||
return ""
|
||||
if ".." in update_url or "\\" in update_url or "?" in update_url or "#" in update_url:
|
||||
return ""
|
||||
if ":" in update_url or any(ch.isspace() for ch in update_url):
|
||||
return ""
|
||||
candidate = "https://github.com" + update_url
|
||||
if not is_trusted_release_url(candidate, repo_url):
|
||||
return ""
|
||||
return candidate
|
||||
|
||||
|
||||
def _fetch_stable_from_releases_page(
|
||||
requests, repo_url: str, timeout: float
|
||||
) -> Optional[ReleaseInfo]:
|
||||
"""Look up the latest stable tag from github.com when the REST API is blocked."""
|
||||
owner, repo = _repo_parts(repo_url)
|
||||
url = f"https://github.com/{owner}/{repo}/releases/latest"
|
||||
headers = {
|
||||
"Accept": "application/json",
|
||||
"User-Agent": UPDATE_CHECKER_USER_AGENT,
|
||||
}
|
||||
try:
|
||||
response = requests.get(url, headers=headers, timeout=timeout, proxies=requests_proxies())
|
||||
final_url = getattr(response, "url", "") or ""
|
||||
parsed = urlparse(final_url)
|
||||
if (
|
||||
parsed.scheme != "https"
|
||||
or parsed.netloc.lower() != "github.com"
|
||||
or not is_trusted_release_url(final_url, repo_url)
|
||||
or response.status_code != 200
|
||||
):
|
||||
logger.debug("Stable release HTML fallback unsuccessful for %s", url)
|
||||
return None
|
||||
data = response.json()
|
||||
if not isinstance(data, dict) or not (data.get("tag_name") or ""):
|
||||
logger.debug("Stable release HTML fallback unsuccessful for %s", url)
|
||||
return None
|
||||
html_url = _html_url_from_relative_update_url(data.get("update_url"), repo_url)
|
||||
return _release_from_payload(
|
||||
{"tag_name": data.get("tag_name"), "html_url": html_url},
|
||||
"stable",
|
||||
repo_url,
|
||||
)
|
||||
except (requests.exceptions.RequestException, ValueError, TypeError) as exc:
|
||||
logger.debug("Stable release HTML fallback failed for %s: %s", url, exc)
|
||||
return None
|
||||
|
||||
|
||||
def fetch_latest_release(
|
||||
repo_url: str = __url__,
|
||||
repo_url: str = UPDATE_REPO_URL,
|
||||
timeout: float = 10.0,
|
||||
channel: str = DEFAULT_UPDATE_CHANNEL,
|
||||
) -> Optional[ReleaseInfo]:
|
||||
"""Fetch the newest GitHub release for ``channel`` (``stable`` or ``nightly``)."""
|
||||
"""Fetch the newest Gitea release for ``channel`` (``stable`` or ``nightly``).
|
||||
|
||||
A packaged install returns None without importing ``requests`` or opening a
|
||||
connection: its updates arrive through the system package manager.
|
||||
"""
|
||||
if is_packaged_install():
|
||||
logger.debug("Packaged install: release check skipped, updates come from xbps")
|
||||
return None
|
||||
|
||||
import requests
|
||||
|
||||
channel = normalize_channel(channel)
|
||||
api_base = _repo_api_base(repo_url)
|
||||
headers = {
|
||||
"Accept": "application/vnd.github+json",
|
||||
"Accept": "application/json",
|
||||
"User-Agent": UPDATE_CHECKER_USER_AGENT,
|
||||
}
|
||||
|
||||
@@ -279,7 +225,8 @@ def fetch_latest_release(
|
||||
logger.warning("No latest release found for %s", api_base)
|
||||
return None
|
||||
if response.status_code in (403, 429):
|
||||
return _fetch_stable_from_releases_page(requests, repo_url, timeout)
|
||||
logger.debug("Release lookup rate-limited or forbidden for %s", api_base)
|
||||
return None
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
if not isinstance(data, dict):
|
||||
@@ -293,12 +240,12 @@ def fetch_latest_release(
|
||||
response = requests.get(
|
||||
f"{api_base}/releases",
|
||||
headers=headers,
|
||||
params={"per_page": 30, "page": page},
|
||||
params={"limit": 30, "page": page},
|
||||
timeout=timeout,
|
||||
proxies=requests_proxies(),
|
||||
)
|
||||
if response.status_code in (403, 429):
|
||||
logger.debug("GitHub API rate-limited or forbidden for %s", api_base)
|
||||
logger.debug("Release lookup rate-limited or forbidden for %s", api_base)
|
||||
return None
|
||||
response.raise_for_status()
|
||||
releases = response.json()
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Background GitHub release checks for Voculinux.
|
||||
"""Background release checks for Voculinux.
|
||||
|
||||
Schedules a delayed first check, then repeats on an interval. Networking runs on
|
||||
a worker thread; the result callback is delivered on the GLib main loop.
|
||||
|
||||
@@ -11,8 +11,11 @@ Seams under test (all offline, no hardware):
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from vocalinux import legacy_import
|
||||
from vocalinux.legacy_import import import_legacy_once
|
||||
|
||||
@@ -145,3 +148,132 @@ def test_new_dictionary_never_overwritten_by_legacy(monkeypatch, tmp_path) -> No
|
||||
# dictionary file must survive untouched.
|
||||
assert (new_config / "dictionary.txt").read_text() == "Mine\n"
|
||||
assert legacy_import.CONFIG_FILENAME == "config.json"
|
||||
|
||||
|
||||
#: A fixed old timestamp, so any write to the legacy file moves its mtime visibly.
|
||||
_OLD_MTIME_NS = 1_000_000_000_000_000_000
|
||||
|
||||
|
||||
def _import_with_stored_dictionary_path(
|
||||
monkeypatch, config_root: Path, data_root: Path, stored_path: str
|
||||
) -> Path:
|
||||
"""Seed a legacy install whose config stores ``stored_path``, then import it.
|
||||
|
||||
Returns the legacy dictionary file. The ConfigManager paths are pointed at
|
||||
the copied configuration, so nothing outside the scratch directories is read
|
||||
or written.
|
||||
"""
|
||||
seeded = _seed_legacy(config_root, data_root)
|
||||
seeded["config.json"].write_text(
|
||||
json.dumps({"dictionary": {"enabled": True, "file_path": stored_path}}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
import_legacy_once()
|
||||
_point_config_manager_at(monkeypatch, config_root / "voculinux")
|
||||
return seeded["dictionary.txt"]
|
||||
|
||||
|
||||
def _point_config_manager_at(monkeypatch, new_config_dir: Path) -> None:
|
||||
"""Make ConfigManager read and write only the given (scratch) configuration dir."""
|
||||
from vocalinux.ui import config_manager
|
||||
|
||||
monkeypatch.setattr(config_manager, "CONFIG_DIR", str(new_config_dir))
|
||||
monkeypatch.setattr(config_manager, "CONFIG_FILE", str(new_config_dir / "config.json"))
|
||||
|
||||
|
||||
def _add_term_through_migrated_config(monkeypatch, new_config_dir: Path, term: str) -> None:
|
||||
"""Add a dictionary term exactly as the app does, using the migrated configuration."""
|
||||
_point_config_manager_at(monkeypatch, new_config_dir)
|
||||
from vocalinux.custom_dictionary import CustomDictionaryManager
|
||||
from vocalinux.ui.config_manager import ConfigManager
|
||||
|
||||
assert CustomDictionaryManager(ConfigManager()).add_term(term) is True
|
||||
|
||||
|
||||
@pytest.mark.parametrize("form", ["absolute-legacy", "tilde-legacy"])
|
||||
def test_migrated_dictionary_edit_never_writes_the_legacy_file(monkeypatch, tmp_path, form) -> None:
|
||||
"""The copied config points the dictionary at the copy, so an edit leaves legacy alone."""
|
||||
if form == "tilde-legacy":
|
||||
# The tilde form names the legacy file only when XDG_CONFIG_HOME is ~/.config.
|
||||
home = tmp_path / "home"
|
||||
monkeypatch.setenv("HOME", str(home))
|
||||
config_root = home / ".config"
|
||||
monkeypatch.setenv("XDG_CONFIG_HOME", str(config_root))
|
||||
data_root = tmp_path / "data"
|
||||
monkeypatch.setenv("XDG_DATA_HOME", str(data_root))
|
||||
stored = "~/.config/vocalinux/dictionary.txt"
|
||||
else:
|
||||
config_root, data_root = _isolate(monkeypatch, tmp_path)
|
||||
stored = str(config_root / "vocalinux" / "dictionary.txt")
|
||||
legacy_terms = _import_with_stored_dictionary_path(monkeypatch, config_root, data_root, stored)
|
||||
os.utime(legacy_terms, ns=(_OLD_MTIME_NS, _OLD_MTIME_NS))
|
||||
legacy_bytes = legacy_terms.read_bytes()
|
||||
legacy_mtime = legacy_terms.stat().st_mtime_ns
|
||||
|
||||
from vocalinux.custom_dictionary import CustomDictionaryManager
|
||||
from vocalinux.ui.config_manager import ConfigManager
|
||||
|
||||
dictionary = CustomDictionaryManager(ConfigManager())
|
||||
assert dictionary.add_term("Kubernetes") is True
|
||||
|
||||
assert legacy_terms.read_bytes() == legacy_bytes
|
||||
assert legacy_terms.stat().st_mtime_ns == legacy_mtime
|
||||
new_terms = config_root / "voculinux" / "dictionary.txt"
|
||||
assert "Kubernetes" in new_terms.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_dictionary_path_outside_the_legacy_directory_is_kept(monkeypatch, tmp_path) -> None:
|
||||
"""A path the user chose outside the legacy directory is not rewritten."""
|
||||
chosen = tmp_path / "shared" / "terms.txt"
|
||||
chosen.parent.mkdir(parents=True)
|
||||
chosen.write_text("Chosen\n", encoding="utf-8")
|
||||
config_root, data_root = _isolate(monkeypatch, tmp_path)
|
||||
_import_with_stored_dictionary_path(monkeypatch, config_root, data_root, str(chosen))
|
||||
|
||||
migrated = json.loads((config_root / "voculinux" / "config.json").read_text(encoding="utf-8"))
|
||||
assert migrated["dictionary"]["file_path"] == str(chosen)
|
||||
assert chosen.read_text(encoding="utf-8") == "Chosen\n"
|
||||
|
||||
|
||||
def test_absent_legacy_dictionary_edit_lands_in_the_new_file(monkeypatch, tmp_path) -> None:
|
||||
"""The default dictionary path was saved but the file was never created."""
|
||||
config_root, _data_root = _isolate(monkeypatch, tmp_path)
|
||||
legacy_config = config_root / "vocalinux"
|
||||
legacy_config.mkdir(parents=True)
|
||||
legacy_terms = legacy_config / "dictionary.txt"
|
||||
(legacy_config / "config.json").write_text(
|
||||
json.dumps({"dictionary": {"enabled": True, "file_path": str(legacy_terms)}}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
import_legacy_once()
|
||||
|
||||
_add_term_through_migrated_config(monkeypatch, config_root / "voculinux", "Kubernetes")
|
||||
|
||||
assert not legacy_terms.exists()
|
||||
assert "Kubernetes" in (config_root / "voculinux" / "dictionary.txt").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
|
||||
|
||||
def test_existing_new_dictionary_takes_the_edit_and_legacy_stays_put(monkeypatch, tmp_path) -> None:
|
||||
"""A new dictionary already present wins; the legacy file is never written."""
|
||||
config_root, data_root = _isolate(monkeypatch, tmp_path)
|
||||
seeded = _seed_legacy(config_root, data_root)
|
||||
legacy_terms = seeded["dictionary.txt"]
|
||||
seeded["config.json"].write_text(
|
||||
json.dumps({"dictionary": {"enabled": True, "file_path": str(legacy_terms)}}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
new_config_dir = config_root / "voculinux"
|
||||
new_config_dir.mkdir(parents=True)
|
||||
(new_config_dir / "dictionary.txt").write_text("Mine\n", encoding="utf-8")
|
||||
os.utime(legacy_terms, ns=(_OLD_MTIME_NS, _OLD_MTIME_NS))
|
||||
legacy_bytes = legacy_terms.read_bytes()
|
||||
legacy_mtime = legacy_terms.stat().st_mtime_ns
|
||||
import_legacy_once()
|
||||
|
||||
_add_term_through_migrated_config(monkeypatch, new_config_dir, "Kubernetes")
|
||||
|
||||
assert legacy_terms.read_bytes() == legacy_bytes
|
||||
assert legacy_terms.stat().st_mtime_ns == legacy_mtime
|
||||
assert (new_config_dir / "dictionary.txt").read_text(encoding="utf-8") == "Mine\nKubernetes\n"
|
||||
|
||||
@@ -8,8 +8,12 @@ protocol headroom. Replacing them with a recorded measurement is a
|
||||
deliberate change to this test.
|
||||
"""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from vocalinux.speech_recognition import model_ladder
|
||||
from vocalinux.speech_recognition.model_ladder import (
|
||||
DEFAULT_RUNG,
|
||||
@@ -130,6 +134,142 @@ class TestCudaToolkitReadiness:
|
||||
assert cuda_toolkit_ready_from_probe(True, True, None) is False
|
||||
|
||||
|
||||
NVCC_VERSION_OUTPUT = "Cuda compilation tools, release 13.2, V13.2.51\n"
|
||||
NVIDIA_SMI_OUTPUT = "3711, 8.6\n"
|
||||
|
||||
|
||||
def _write_toolkit(
|
||||
root: Path,
|
||||
*,
|
||||
nvcc: bool = True,
|
||||
header: bool = True,
|
||||
libcudart: bool = True,
|
||||
target: str = "x86_64-linux",
|
||||
) -> Path:
|
||||
"""Lay out a CUDA toolkit tree shaped like the NVIDIA runfile install."""
|
||||
if nvcc:
|
||||
(root / "bin").mkdir(parents=True, exist_ok=True)
|
||||
binary = root / "bin" / "nvcc"
|
||||
binary.write_text("#!/bin/sh\n")
|
||||
binary.chmod(0o755)
|
||||
if header:
|
||||
(root / "include").mkdir(parents=True, exist_ok=True)
|
||||
(root / "include" / "cuda_runtime.h").write_text("/* header */\n")
|
||||
if libcudart:
|
||||
lib = root / "targets" / target / "lib"
|
||||
lib.mkdir(parents=True, exist_ok=True)
|
||||
(lib / "libcudart.so.13").write_text("")
|
||||
if target == "x86_64-linux":
|
||||
(root / "lib64").symlink_to(lib, target_is_directory=True)
|
||||
return root
|
||||
|
||||
|
||||
def _probe_stub(nvcc_output: str | None):
|
||||
"""Stand in for ``_run_probe``: nvidia-smi sees an RTX-class GPU."""
|
||||
|
||||
def probe(argv, timeout=10):
|
||||
if argv[0] == "nvidia-smi":
|
||||
return NVIDIA_SMI_OUTPUT
|
||||
if argv[0] == "nvcc":
|
||||
return nvcc_output
|
||||
return None
|
||||
|
||||
return probe
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def no_env_toolkit(tmp_path: Path):
|
||||
"""Isolate detection from the host: no CUDA env vars, empty /usr/local."""
|
||||
usr_local = tmp_path / "usr-local"
|
||||
usr_local.mkdir()
|
||||
with (
|
||||
patch.dict(os.environ, {}, clear=True),
|
||||
patch.object(model_ladder, "_CUDA_USR_LOCAL_ROOT", usr_local),
|
||||
patch.object(model_ladder.platform, "machine", return_value="x86_64"),
|
||||
):
|
||||
yield usr_local
|
||||
|
||||
|
||||
class TestCudaToolkitDetection:
|
||||
"""A toolkit counts from PATH, CUDA_HOME, CUDA_PATH or /usr/local/cuda*/."""
|
||||
|
||||
def test_toolkit_found_via_path(self, no_env_toolkit: Path) -> None:
|
||||
with patch.object(model_ladder, "_run_probe", side_effect=_probe_stub(NVCC_VERSION_OUTPUT)):
|
||||
assert model_ladder.cuda_toolkit_ready() is True
|
||||
|
||||
@pytest.mark.parametrize("variable", ["CUDA_HOME", "CUDA_PATH"])
|
||||
def test_toolkit_found_via_cuda_env_root(
|
||||
self, tmp_path: Path, no_env_toolkit: Path, variable: str
|
||||
) -> None:
|
||||
root = _write_toolkit(tmp_path / "opt" / "cuda")
|
||||
with (
|
||||
patch.dict(os.environ, {variable: str(root)}, clear=True),
|
||||
patch.object(model_ladder, "_run_probe", side_effect=_probe_stub(None)),
|
||||
):
|
||||
assert model_ladder.cuda_toolkit_ready() is True
|
||||
|
||||
def test_toolkit_found_only_under_usr_local_cuda(self, no_env_toolkit: Path) -> None:
|
||||
"""The real target: /usr/local/cuda -> cuda-13.2 with nvcc off PATH."""
|
||||
real = _write_toolkit(no_env_toolkit / "cuda-13.2")
|
||||
(no_env_toolkit / "cuda").symlink_to(real, target_is_directory=True)
|
||||
with patch.object(model_ladder, "_run_probe", side_effect=_probe_stub(None)):
|
||||
assert model_ladder.cuda_toolkit_ready() is True
|
||||
|
||||
def test_no_toolkit_anywhere_is_not_ready(self, no_env_toolkit: Path) -> None:
|
||||
with patch.object(model_ladder, "_run_probe", side_effect=_probe_stub(None)):
|
||||
assert model_ladder.cuda_toolkit_ready() is False
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"layout",
|
||||
[
|
||||
{"nvcc": False},
|
||||
{"header": False},
|
||||
{"libcudart": False},
|
||||
{"target": "sbsa-linux"},
|
||||
],
|
||||
ids=["no-nvcc", "no-cuda-runtime-header", "no-libcudart", "other-architecture"],
|
||||
)
|
||||
def test_incomplete_toolkit_is_rejected(
|
||||
self, tmp_path: Path, no_env_toolkit: Path, layout: dict
|
||||
) -> None:
|
||||
root = _write_toolkit(tmp_path / "partial", **layout)
|
||||
with (
|
||||
patch.dict(os.environ, {"CUDA_HOME": str(root)}, clear=True),
|
||||
patch.object(model_ladder, "_run_probe", side_effect=_probe_stub(None)),
|
||||
):
|
||||
assert model_ladder.cuda_toolkit_ready() is False
|
||||
|
||||
def test_driver_without_toolkit_stays_not_ready_with_partial_tree(
|
||||
self, no_env_toolkit: Path
|
||||
) -> None:
|
||||
"""A /usr/local/cuda with headers only is not a toolkit."""
|
||||
_write_toolkit(no_env_toolkit / "cuda-13.2", nvcc=False, libcudart=False)
|
||||
with patch.object(model_ladder, "_run_probe", side_effect=_probe_stub(None)):
|
||||
assert model_ladder.cuda_toolkit_ready() is False
|
||||
|
||||
|
||||
class TestBuildAwareBackendOrder:
|
||||
"""The order names only backends the installed build compiled in."""
|
||||
|
||||
def test_vulkan_only_build_never_picks_cuda_even_with_toolkit(
|
||||
self, no_env_toolkit: Path
|
||||
) -> None:
|
||||
_write_toolkit(no_env_toolkit / "cuda-13.2")
|
||||
(no_env_toolkit / "cuda").symlink_to(no_env_toolkit / "cuda-13.2")
|
||||
with patch.object(model_ladder, "_run_probe", side_effect=_probe_stub(None)):
|
||||
assert model_ladder.cuda_toolkit_ready() is True
|
||||
assert preferred_backend_order("vulkan") == ("vulkan", "cpu")
|
||||
|
||||
def test_cuda_build_picks_cuda_with_nvcc_off_path(self, no_env_toolkit: Path) -> None:
|
||||
_write_toolkit(no_env_toolkit / "cuda-13.2")
|
||||
(no_env_toolkit / "cuda").symlink_to(no_env_toolkit / "cuda-13.2")
|
||||
with patch.object(model_ladder, "_run_probe", side_effect=_probe_stub(None)):
|
||||
assert preferred_backend_order("cuda") == ("cuda", "cpu")
|
||||
|
||||
def test_cpu_build_is_cpu_only(self) -> None:
|
||||
assert preferred_backend_order("cpu") == ("cpu",)
|
||||
|
||||
|
||||
class TestPreferredBackendOrder:
|
||||
"""CUDA ranks first only with a complete toolkit."""
|
||||
|
||||
|
||||
@@ -38,8 +38,8 @@ def test_packaged_install_when_the_marker_exists(tmp_path: Path) -> None:
|
||||
|
||||
def test_packaged_hint_names_the_package_manager_path() -> None:
|
||||
"""The presented path is xbps, not a release download."""
|
||||
assert "package manager" in PACKAGED_UPDATE_HINT
|
||||
assert "xbps-install -Syu voculinux" in PACKAGED_UPDATE_HINT
|
||||
assert "xbps-install -Su" in PACKAGED_UPDATE_HINT
|
||||
assert "updates arrive via sudo xbps-install -Su" in PACKAGED_UPDATE_HINT
|
||||
|
||||
|
||||
def test_unpackaged_menu_label_is_unchanged() -> None:
|
||||
@@ -90,4 +90,4 @@ def test_packaged_updates_description_presents_the_package_manager() -> None:
|
||||
"""The About page tells packaged users to update through xbps."""
|
||||
description = updates_description("stable", packaged=True)
|
||||
assert "stable" in description
|
||||
assert "package manager" in description
|
||||
assert "xbps-install -Su" in description
|
||||
|
||||
@@ -1476,9 +1476,10 @@ class TestAboutPage(unittest.TestCase):
|
||||
VOCAHQ_DISCORD_URL,
|
||||
VOCAHQ_X_URL,
|
||||
VOCAHQ_MAILTO_URL,
|
||||
"https://github.com/VocaHQ/vocalinux/releases/tag/v0.15.0",
|
||||
"https://git.bongbetic.com/xavierk/voculinux/releases/tag/v0.15.0",
|
||||
):
|
||||
self.assertTrue(_can_open_url(url), url)
|
||||
self.assertFalse(_can_open_url("https://github.com/VocaHQ/vocalinux/releases/tag/v0.15.0"))
|
||||
self.assertFalse(_can_open_url("https://evil.example/payload"))
|
||||
self.assertFalse(_can_open_url("mailto:evil@example.com"))
|
||||
self.assertFalse(_can_open_url(""))
|
||||
@@ -1644,6 +1645,15 @@ class TestUpdatePreferences(unittest.TestCase):
|
||||
self.assertIn("self._start_update_check()", method)
|
||||
self.assertIn("Automatic checks are off. Press Check to look now.", method)
|
||||
|
||||
def test_packaged_check_now_explains_xbps_before_any_lookup(self) -> None:
|
||||
"""A packaged install's About page shows the xbps path and starts no thread."""
|
||||
method = self._method_source(self.settings_source, "_start_update_check")
|
||||
guard = method.index("if is_packaged_install():")
|
||||
packaged_branch = method[guard : method.index("return", guard)]
|
||||
|
||||
self.assertIn("self.update_status_row.set_subtitle(PACKAGED_UPDATE_HINT)", packaged_branch)
|
||||
self.assertLess(guard, method.index("threading.Thread"))
|
||||
|
||||
def test_update_switch_loads_state_before_connecting(self) -> None:
|
||||
switch_start = self.about_source.index("self.update_checks_switch = Gtk.Switch()")
|
||||
active_position = self.about_source.index(
|
||||
|
||||
@@ -497,6 +497,7 @@ class TestCheckDependencies(unittest.TestCase):
|
||||
desktop: str = "Hyprland",
|
||||
env: Any = None,
|
||||
config_pin: bool = False,
|
||||
ibus_class: Any = None,
|
||||
) -> tuple[list[str], int]:
|
||||
"""Drive _check_dependencies with an ibus pin; return (logs, bridge_call_count)."""
|
||||
from vocalinux.text_injection.text_injector import DesktopEnvironment, TextInjector
|
||||
@@ -536,7 +537,10 @@ class TestCheckDependencies(unittest.TestCase):
|
||||
patch(
|
||||
"vocalinux.text_injection.text_injector.is_ibus_daemon_running", return_value=True
|
||||
),
|
||||
patch("vocalinux.text_injection.text_injector.IBusTextInjector"),
|
||||
patch(
|
||||
"vocalinux.text_injection.text_injector.IBusTextInjector",
|
||||
new=ibus_class if ibus_class is not None else MagicMock(),
|
||||
),
|
||||
patch.object(TextInjector, "_start_ibus_initialization", lambda s: None),
|
||||
# shutil.which is patched to claim every tool exists, so without this
|
||||
# the real probe shells out to a ydotoold that may not be installed
|
||||
@@ -595,6 +599,28 @@ class TestCheckDependencies(unittest.TestCase):
|
||||
_, call_count = self._run_ibus_pin(bridges=bridges)
|
||||
self.assertEqual(call_count, 1, f"bridges={bridges}: expected 1 call, got {call_count}")
|
||||
|
||||
def test_ibus_pin_on_niri_is_honoured_with_one_silent_drop_warning(self):
|
||||
"""An explicit IBus pin stays an escape hatch on niri, with one clear warning.
|
||||
|
||||
Both pin sources must construct the IBus injector (the pin is honoured)
|
||||
and log exactly one WARNING saying IBus on niri can report success while
|
||||
text is silently dropped.
|
||||
"""
|
||||
for config_pin in (False, True):
|
||||
with self.subTest(config_pin=config_pin):
|
||||
ibus_class = MagicMock()
|
||||
output, _ = self._run_ibus_pin(
|
||||
bridges=False,
|
||||
desktop="niri",
|
||||
config_pin=config_pin,
|
||||
ibus_class=ibus_class,
|
||||
)
|
||||
ibus_class.assert_called_once()
|
||||
warnings = [line for line in output if line.startswith("WARNING:")]
|
||||
self.assertEqual(len(warnings), 1, f"expected one WARNING, got: {warnings}")
|
||||
self.assertIn("niri", warnings[0])
|
||||
self.assertIn("can report success while text is silently dropped", warnings[0])
|
||||
|
||||
def test_ibus_pin_on_x11_does_not_warn(self):
|
||||
"""The denylist is a Wayland concern; X11 reaches apps through XIM."""
|
||||
from vocalinux.text_injection.text_injector import DesktopEnvironment
|
||||
|
||||
+107
-130
@@ -1,8 +1,10 @@
|
||||
"""Tests for the GitHub Releases update checker."""
|
||||
"""Tests for the Voculinux release checker (Gitea, never upstream VocaHQ)."""
|
||||
|
||||
import logging
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from vocalinux.utils.update_checker import (
|
||||
ReleaseInfo,
|
||||
build_channel,
|
||||
@@ -16,6 +18,12 @@ from vocalinux.utils.update_checker import (
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _unpackaged_install(monkeypatch):
|
||||
"""Keep these tests independent of a packaged marker on the host (ticket #27)."""
|
||||
monkeypatch.setattr("vocalinux.utils.update_checker.is_packaged_install", lambda: False)
|
||||
|
||||
|
||||
class TestNormalizeAndCompare:
|
||||
def test_normalize_version_strips_v_prefix(self):
|
||||
assert normalize_version("v0.15.0") == "0.15.0"
|
||||
@@ -72,7 +80,7 @@ class TestChannels:
|
||||
tag_name="v0.15.0",
|
||||
version="0.15.0",
|
||||
name="v0.15.0",
|
||||
html_url="https://github.com/VocaHQ/vocalinux/releases/tag/v0.15.0",
|
||||
html_url="https://git.bongbetic.com/xavierk/voculinux/releases/tag/v0.15.0",
|
||||
body="",
|
||||
published_at="2026-08-02T00:00:00Z",
|
||||
prerelease=False,
|
||||
@@ -86,7 +94,7 @@ class TestChannels:
|
||||
tag_name="nightly-2026-08-02",
|
||||
version="nightly-2026-08-02",
|
||||
name="Nightly Build - 0.14.2.dev20260802+27ecf88",
|
||||
html_url="https://github.com/VocaHQ/vocalinux/releases/tag/nightly-2026-08-02",
|
||||
html_url="https://git.bongbetic.com/xavierk/voculinux/releases/tag/nightly-2026-08-02",
|
||||
body="",
|
||||
published_at="2026-08-02T00:00:00Z",
|
||||
prerelease=True,
|
||||
@@ -103,20 +111,22 @@ class TestChannels:
|
||||
|
||||
class TestTrustedUrls:
|
||||
def test_accepts_project_urls(self):
|
||||
assert is_trusted_release_url("https://github.com/VocaHQ/vocalinux")
|
||||
assert is_trusted_release_url("https://github.com/VocaHQ/vocalinux/releases/tag/v0.15.0")
|
||||
# Pre-transfer URLs still redirect and must stay trusted.
|
||||
assert is_trusted_release_url("https://github.com/jatinkrmalik/vocalinux")
|
||||
assert is_trusted_release_url("https://git.bongbetic.com/xavierk/voculinux")
|
||||
assert is_trusted_release_url(
|
||||
"https://github.com/jatinkrmalik/vocalinux/releases/tag/v0.15.0"
|
||||
"https://git.bongbetic.com/xavierk/voculinux/releases/tag/v0.15.0"
|
||||
)
|
||||
|
||||
def test_rejects_untrusted_urls(self):
|
||||
assert not is_trusted_release_url("https://evil.example/payload")
|
||||
assert not is_trusted_release_url("http://github.com/VocaHQ/vocalinux")
|
||||
assert not is_trusted_release_url("https://github.com/evil/voculinux")
|
||||
def test_rejects_upstream_and_other_urls(self):
|
||||
assert not is_trusted_release_url("https://github.com/VocaHQ/vocalinux")
|
||||
assert not is_trusted_release_url("https://github.com/VocaHQ/vocalinux/releases/tag/v1")
|
||||
assert not is_trusted_release_url("https://github.com/jatinkrmalik/vocalinux")
|
||||
assert not is_trusted_release_url("https://git.bongbetic.com/xavierk/other")
|
||||
assert not is_trusted_release_url("https://evil.example/xavierk/voculinux")
|
||||
assert not is_trusted_release_url("")
|
||||
|
||||
def test_rejects_non_https_urls(self):
|
||||
assert not is_trusted_release_url("http://git.bongbetic.com/xavierk/voculinux")
|
||||
|
||||
|
||||
class TestFormatReleaseNotes:
|
||||
def test_empty_body(self):
|
||||
@@ -150,7 +160,7 @@ class TestFetchLatestRelease:
|
||||
mock_response.json.return_value = {
|
||||
"tag_name": "v0.15.0",
|
||||
"name": "v0.15.0",
|
||||
"html_url": "https://github.com/VocaHQ/vocalinux/releases/tag/v0.15.0",
|
||||
"html_url": "https://git.bongbetic.com/xavierk/voculinux/releases/tag/v0.15.0",
|
||||
"body": "Notes",
|
||||
"published_at": "2026-08-02T22:29:58Z",
|
||||
"prerelease": False,
|
||||
@@ -167,7 +177,7 @@ class TestFetchLatestRelease:
|
||||
assert release.html_url.endswith("/v0.15.0")
|
||||
mock_requests.get.assert_called_once()
|
||||
api_url = mock_requests.get.call_args[0][0]
|
||||
assert api_url == "https://api.github.com/repos/VocaHQ/vocalinux/releases/latest"
|
||||
assert api_url == "https://git.bongbetic.com/api/v1/repos/xavierk/voculinux/releases/latest"
|
||||
|
||||
def test_drops_untrusted_html_url(self):
|
||||
mock_response = MagicMock()
|
||||
@@ -197,7 +207,7 @@ class TestFetchLatestRelease:
|
||||
{
|
||||
"tag_name": "v0.15.0",
|
||||
"name": "v0.15.0",
|
||||
"html_url": "https://github.com/VocaHQ/vocalinux/releases/tag/v0.15.0",
|
||||
"html_url": "https://git.bongbetic.com/xavierk/voculinux/releases/tag/v0.15.0",
|
||||
"body": "Stable",
|
||||
"published_at": "2026-08-02T22:29:58Z",
|
||||
"prerelease": False,
|
||||
@@ -206,7 +216,7 @@ class TestFetchLatestRelease:
|
||||
{
|
||||
"tag_name": "nightly-2026-08-02",
|
||||
"name": "nightly-2026-08-02",
|
||||
"html_url": "https://github.com/VocaHQ/vocalinux/releases/tag/nightly-2026-08-02",
|
||||
"html_url": "https://git.bongbetic.com/xavierk/voculinux/releases/tag/nightly-2026-08-02",
|
||||
"body": "Nightly",
|
||||
"published_at": "2026-08-02T05:00:00Z",
|
||||
"prerelease": True,
|
||||
@@ -232,7 +242,7 @@ class TestFetchLatestRelease:
|
||||
{
|
||||
"tag_name": f"v0.15.{i}",
|
||||
"name": f"v0.15.{i}",
|
||||
"html_url": f"https://github.com/VocaHQ/vocalinux/releases/tag/v0.15.{i}",
|
||||
"html_url": f"https://git.bongbetic.com/xavierk/voculinux/releases/tag/v0.15.{i}",
|
||||
"body": "",
|
||||
"published_at": "2026-08-03T00:00:00Z",
|
||||
"prerelease": False,
|
||||
@@ -244,7 +254,9 @@ class TestFetchLatestRelease:
|
||||
{
|
||||
"tag_name": "nightly-2026-08-01",
|
||||
"name": "nightly-2026-08-01",
|
||||
"html_url": ("https://github.com/VocaHQ/vocalinux/releases/tag/nightly-2026-08-01"),
|
||||
"html_url": (
|
||||
"https://git.bongbetic.com/xavierk/voculinux/releases/tag/nightly-2026-08-01"
|
||||
),
|
||||
"body": "Nightly",
|
||||
"published_at": "2026-08-01T05:00:00Z",
|
||||
"prerelease": True,
|
||||
@@ -271,92 +283,45 @@ class TestFetchLatestRelease:
|
||||
assert mock_requests.get.call_args_list[0].kwargs["params"]["page"] == 1
|
||||
assert mock_requests.get.call_args_list[1].kwargs["params"]["page"] == 2
|
||||
|
||||
def test_stable_api_403_uses_html_fallback(self, caplog):
|
||||
release = self._fetch_stable_with_rate_limit(403, caplog)
|
||||
assert isinstance(release, ReleaseInfo)
|
||||
assert release.version == "0.17.0"
|
||||
assert release.channel == "stable"
|
||||
assert release.html_url == "https://github.com/VocaHQ/vocalinux/releases/tag/v0.17.0"
|
||||
|
||||
def test_stable_api_429_uses_html_fallback(self, caplog):
|
||||
release = self._fetch_stable_with_rate_limit(429, caplog)
|
||||
assert isinstance(release, ReleaseInfo)
|
||||
assert release.version == "0.17.0"
|
||||
assert release.channel == "stable"
|
||||
assert release.html_url == "https://github.com/VocaHQ/vocalinux/releases/tag/v0.17.0"
|
||||
|
||||
def test_stable_api_403_fallback_failures_return_none_quietly(self, caplog):
|
||||
api_response = _mock_response(403)
|
||||
failures = [
|
||||
_mock_response(
|
||||
500,
|
||||
url="https://github.com/VocaHQ/vocalinux/releases/tag/v0.17.0",
|
||||
),
|
||||
Exception("offline"),
|
||||
_mock_response(
|
||||
200,
|
||||
json_value=["not", "a", "dict"],
|
||||
url="https://github.com/VocaHQ/vocalinux/releases/tag/v0.17.0",
|
||||
),
|
||||
_mock_response(
|
||||
200,
|
||||
json_value={"update_url": "/VocaHQ/vocalinux/releases/tag/v0.17.0"},
|
||||
url="https://github.com/VocaHQ/vocalinux/releases/tag/v0.17.0",
|
||||
),
|
||||
]
|
||||
def test_stable_404_means_no_release_and_warns(self, caplog):
|
||||
"""A missing or private repo answers 404 anonymously: no release, one warning."""
|
||||
mock_requests = _requests_mock([_mock_response(404)])
|
||||
caplog.set_level(logging.WARNING, logger="vocalinux.utils.update_checker")
|
||||
for failure in failures:
|
||||
mock_requests = _requests_mock([api_response, failure])
|
||||
with patch.dict("sys.modules", {"requests": mock_requests}):
|
||||
assert fetch_latest_release() is None
|
||||
assert _checker_warnings(caplog) == []
|
||||
caplog.clear()
|
||||
with patch.dict("sys.modules", {"requests": mock_requests}):
|
||||
assert fetch_latest_release() is None
|
||||
mock_requests.get.assert_called_once()
|
||||
assert len(_checker_warnings(caplog)) == 1
|
||||
|
||||
def test_evil_update_url_does_not_become_html_url(self, caplog):
|
||||
tag_url = "https://github.com/VocaHQ/vocalinux/releases/tag/v0.17.0"
|
||||
evil_paths = [
|
||||
"/evil/voculinux/releases/tag/v9",
|
||||
"https://evil.example/x",
|
||||
"/VocaHQ/vocalinux/releases/../evil",
|
||||
]
|
||||
@pytest.mark.parametrize("status_code", [403, 429])
|
||||
def test_stable_403_and_429_return_none_without_a_second_request(self, caplog, status_code):
|
||||
mock_requests = _requests_mock([_mock_response(status_code)])
|
||||
caplog.set_level(logging.WARNING, logger="vocalinux.utils.update_checker")
|
||||
for update_url in evil_paths:
|
||||
api_response = _mock_response(403)
|
||||
fallback = _mock_response(
|
||||
200,
|
||||
json_value={"tag_name": "v0.17.0", "update_url": update_url},
|
||||
url=tag_url,
|
||||
)
|
||||
mock_requests = _requests_mock([api_response, fallback])
|
||||
with patch.dict("sys.modules", {"requests": mock_requests}):
|
||||
release = fetch_latest_release()
|
||||
assert release is not None
|
||||
assert release.version == "0.17.0"
|
||||
assert release.html_url == ""
|
||||
assert _checker_warnings(caplog) == []
|
||||
caplog.clear()
|
||||
with patch.dict("sys.modules", {"requests": mock_requests}):
|
||||
assert fetch_latest_release() is None
|
||||
mock_requests.get.assert_called_once()
|
||||
assert _checker_warnings(caplog) == []
|
||||
|
||||
def test_untrusted_fallback_final_url_is_rejected(self, caplog):
|
||||
def test_prerelease_tag_is_compared_not_filtered(self):
|
||||
"""Existing comparison rules apply to a pre-release tag: no new filter."""
|
||||
payload = {
|
||||
"tag_name": "v0.17.0",
|
||||
"update_url": "/VocaHQ/vocalinux/releases/tag/v0.17.0",
|
||||
"tag_name": "v0.18.0-beta.1",
|
||||
"name": "v0.18.0-beta.1",
|
||||
"html_url": "https://git.bongbetic.com/xavierk/voculinux/releases/tag/v0.18.0-beta.1",
|
||||
"body": "Beta",
|
||||
"published_at": "2026-08-02T22:29:58Z",
|
||||
"prerelease": True,
|
||||
}
|
||||
untrusted_urls = (
|
||||
"https://evil.example/VocaHQ/vocalinux/releases/tag/v0.17.0",
|
||||
"https://github.com/evil/voculinux/releases/tag/v0.17.0",
|
||||
"http://github.com/VocaHQ/vocalinux/releases/tag/v0.17.0",
|
||||
)
|
||||
caplog.set_level(logging.WARNING, logger="vocalinux.utils.update_checker")
|
||||
for final_url in untrusted_urls:
|
||||
api_response = _mock_response(403)
|
||||
fallback = _mock_response(200, json_value=payload, url=final_url)
|
||||
mock_requests = _requests_mock([api_response, fallback])
|
||||
with patch.dict("sys.modules", {"requests": mock_requests}):
|
||||
assert fetch_latest_release() is None
|
||||
assert _checker_warnings(caplog) == []
|
||||
caplog.clear()
|
||||
mock_requests = _requests_mock([_mock_response(200, json_value=payload)])
|
||||
with patch.dict("sys.modules", {"requests": mock_requests}):
|
||||
release = fetch_latest_release()
|
||||
|
||||
def test_nightly_api_403_returns_none_without_html_fallback(self, caplog):
|
||||
assert release is not None
|
||||
assert release.prerelease is True
|
||||
assert release.version == "0.18.0-beta.1"
|
||||
assert is_update_available("0.17.0", release) is True
|
||||
assert is_update_available("0.18.0", release) is False
|
||||
|
||||
def test_nightly_api_403_returns_none(self, caplog):
|
||||
api_response = _mock_response(403)
|
||||
mock_requests = _requests_mock([api_response])
|
||||
caplog.set_level(logging.WARNING, logger="vocalinux.utils.update_checker")
|
||||
@@ -364,46 +329,58 @@ class TestFetchLatestRelease:
|
||||
assert fetch_latest_release(channel="nightly") is None
|
||||
mock_requests.get.assert_called_once()
|
||||
requested = mock_requests.get.call_args[0][0]
|
||||
assert requested == "https://api.github.com/repos/VocaHQ/vocalinux/releases"
|
||||
assert requested == "https://git.bongbetic.com/api/v1/repos/xavierk/voculinux/releases"
|
||||
assert _checker_warnings(caplog) == []
|
||||
|
||||
def _fetch_stable_with_rate_limit(self, status_code: int, caplog):
|
||||
tag_url = "https://github.com/VocaHQ/vocalinux/releases/tag/v0.17.0"
|
||||
api_response = _mock_response(status_code)
|
||||
fallback = _mock_response(
|
||||
200,
|
||||
json_value={
|
||||
"tag_name": "v0.17.0",
|
||||
"update_url": "/VocaHQ/vocalinux/releases/tag/v0.17.0",
|
||||
"id": 1,
|
||||
"edit_url": "/VocaHQ/vocalinux/releases/tag/v0.17.0/edit",
|
||||
"delete_url": "/VocaHQ/vocalinux/releases/tag/v0.17.0",
|
||||
"update_authenticity_token": "csrf-update",
|
||||
"delete_authenticity_token": "csrf-delete",
|
||||
},
|
||||
url=tag_url,
|
||||
)
|
||||
mock_requests = _requests_mock([api_response, fallback])
|
||||
caplog.set_level(logging.WARNING, logger="vocalinux.utils.update_checker")
|
||||
def test_stable_lookup_parses_gitea_payload_from_gitea_only(self, caplog):
|
||||
"""The stable lookup reads Gitea's JSON and never contacts GitHub."""
|
||||
payload = {
|
||||
"tag_name": "v0.18.6",
|
||||
"name": "v0.18.6",
|
||||
"html_url": "https://git.bongbetic.com/xavierk/voculinux/releases/tag/v0.18.6",
|
||||
"body": "Notes",
|
||||
"published_at": "2026-11-06T10:00:00Z",
|
||||
"prerelease": False,
|
||||
}
|
||||
mock_requests = _requests_mock([_mock_response(200, json_value=payload)])
|
||||
with patch.dict("sys.modules", {"requests": mock_requests}):
|
||||
release = fetch_latest_release()
|
||||
|
||||
assert mock_requests.get.call_count == 2
|
||||
first, second = mock_requests.get.call_args_list
|
||||
assert first[0][0] == "https://api.github.com/repos/VocaHQ/vocalinux/releases/latest"
|
||||
assert first.kwargs["headers"]["Accept"] == "application/vnd.github+json"
|
||||
assert release is not None
|
||||
assert release.tag_name == "v0.18.6"
|
||||
assert release.html_url == payload["html_url"]
|
||||
assert release.body == "Notes"
|
||||
mock_requests.get.assert_called_once()
|
||||
first = mock_requests.get.call_args
|
||||
assert (
|
||||
first[0][0]
|
||||
== "https://git.bongbetic.com/api/v1/repos/xavierk/voculinux/releases/latest"
|
||||
)
|
||||
assert first.kwargs["headers"]["Accept"] == "application/json"
|
||||
assert first.kwargs["headers"]["User-Agent"] == "Voculinux-UpdateChecker"
|
||||
assert "Authorization" not in first.kwargs["headers"]
|
||||
assert second[0][0] == "https://github.com/VocaHQ/vocalinux/releases/latest"
|
||||
assert second.kwargs["headers"]["Accept"] == "application/json"
|
||||
assert second.kwargs["headers"]["User-Agent"] == "Voculinux-UpdateChecker"
|
||||
assert "Authorization" not in second.kwargs["headers"]
|
||||
assert fallback.url == tag_url
|
||||
assert _checker_warnings(caplog) == []
|
||||
assert release is not None
|
||||
assert "csrf-update" not in repr(release)
|
||||
assert "csrf-delete" not in repr(release)
|
||||
return release
|
||||
|
||||
@pytest.mark.parametrize("channel", ["stable", "nightly"])
|
||||
def test_source_install_never_queries_upstream(self, channel):
|
||||
payload = [] if channel == "nightly" else {"tag_name": "v0.18.6"}
|
||||
mock_requests = _requests_mock([_mock_response(200, json_value=payload)])
|
||||
with patch.dict("sys.modules", {"requests": mock_requests}):
|
||||
fetch_latest_release(channel=channel)
|
||||
for call in mock_requests.get.call_args_list:
|
||||
assert call[0][0].startswith(
|
||||
"https://git.bongbetic.com/api/v1/repos/xavierk/voculinux/"
|
||||
)
|
||||
|
||||
def test_packaged_install_makes_no_network_request(self):
|
||||
"""Packaged installs get updates from xbps, so the checker stays off the network."""
|
||||
mock_requests = _requests_mock([_mock_response(200, json_value={"tag_name": "v9.9.9"})])
|
||||
with (
|
||||
patch("vocalinux.utils.update_checker.is_packaged_install", return_value=True),
|
||||
patch.dict("sys.modules", {"requests": mock_requests}),
|
||||
):
|
||||
assert fetch_latest_release() is None
|
||||
assert fetch_latest_release(channel="nightly") is None
|
||||
mock_requests.get.assert_not_called()
|
||||
|
||||
|
||||
def _mock_response(status_code, json_value=None, url=""):
|
||||
|
||||
Reference in New Issue
Block a user