chore: bump minimum Python from 3.8 to 3.9 (#336)

* chore: bump minimum Python from 3.8 to 3.9 and adopt PEP 585 builtins

- Update requires-python, classifiers, mypy target, and black target in pyproject.toml
- Replace Python 3.8 with 3.9 in CI matrix and nightly workflow
- Replace typing.List/Dict/Tuple/Set with builtin list/dict/tuple/set (PEP 585)
- Update all 3.8 version references in README, docs, website, and install.sh
- Fix test_wayland_no_tools to mock IBus availability
- Remove List from common_types re-exports and update test

* style: reformat test files for Black 26.x and pin version in CI

Black 26.x introduced parenthesized context manager formatting.
Reformat 7 test files and pin Black>=26,<27 in CI to prevent
future drift from unpinned version upgrades.
This commit is contained in:
Jatin K Malik
2026-03-21 12:15:10 -07:00
committed by GitHub
parent e478f7e353
commit fa17b88f74
27 changed files with 265 additions and 227 deletions
+1 -1
View File
@@ -175,7 +175,7 @@ jobs:
| **Version** | ${{ steps.version.outputs.version }} |
| **Base Version** | ${{ steps.version.outputs.base_version }} |
| **Build Date** | ${{ steps.version.outputs.date }} |
| **Python** | 3.8+ |
| **Python** | 3.9+ |
| **OS** | Linux (X11 & Wayland) |
---
+2 -2
View File
@@ -79,7 +79,7 @@ jobs:
- name: Install linting tools
run: |
python -m pip install --upgrade pip
pip install flake8 black isort
pip install flake8 "black>=26,<27" isort
- name: Lint with flake8 (critical errors)
run: |
@@ -102,7 +102,7 @@ jobs:
strategy:
fail-fast: false
matrix:
python-version: ['3.8', '3.10', '3.11', '3.13']
python-version: ['3.9', '3.10', '3.11', '3.13']
steps:
- uses: actions/checkout@v4
+2 -2
View File
@@ -11,7 +11,7 @@
<!-- Build & Quality -->
[![Vocalinux CI](https://github.com/jatinkrmalik/vocalinux/workflows/Vocalinux%20CI/badge.svg)](https://github.com/jatinkrmalik/vocalinux/actions)
[![Platform: Linux](https://img.shields.io/badge/platform-Linux-lightgrey)](https://github.com/jatinkrmalik/vocalinux)
[![Python 3.8+](https://img.shields.io/badge/python-3.8+-blue.svg)](https://www.python.org/downloads/)
[![Python 3.9+](https://img.shields.io/badge/python-3.9+-blue.svg)](https://www.python.org/downloads/)
[![Made with GTK](https://img.shields.io/badge/Made%20with-GTK-green)](https://www.gtk.org/)
[![codecov](https://codecov.io/gh/jatinkrmalik/vocalinux/branch/main/graph/badge.svg)](https://codecov.io/gh/jatinkrmalik/vocalinux)
@@ -204,7 +204,7 @@ Or launch it from your application menu!
## 📋 Requirements
- **OS**: Linux (tested on Ubuntu 22.04+, Debian 11+, Fedora 39+, Arch Linux, openSUSE Tumbleweed)
- **Python**: 3.8 or newer
- **Python**: 3.9 or newer
- **Display**: X11 or Wayland
- **Hardware**: Microphone for voice input
+1 -1
View File
@@ -112,7 +112,7 @@ sudo zypper install -y python3-gobject python3-gobject-cairo gtk3 \
### Gentoo
```bash
sudo emerge dev-python/pygobject:3 x11-libs/gtk+:3 dev-libs/libappindicator:3 \
media-libs/portaudio dev-lang/python:3.8
media-libs/portaudio dev-lang/python:3.9
```
**Note:** Gentoo compiles packages from source, which will take longer.
+1 -1
View File
@@ -31,7 +31,7 @@ git clone https://github.com/jatinkrmalik/vocalinux.git && cd vocalinux && ./ins
| Requirement | Details |
|-------------|---------|
| **Operating System** | Ubuntu 22.04+ (recommended), Debian 11+, Fedora 38+, Arch Linux |
| **Python** | 3.8 or newer |
| **Python** | 3.9 or newer |
| **Display Server** | X11 or Wayland |
| **Hardware** | Microphone for speech input |
| **Disk Space** | ~200MB (including whisper.cpp model) |
+2 -2
View File
@@ -1152,7 +1152,7 @@ install_system_dependencies() {
local PACMAN_PACKAGES="python-pip python-gobject gtk3 libappindicator-gtk3 ibus gobject-introspection python-cairo portaudio python-virtualenv pkg-config wget curl unzip base-devel vulkan-tools vulkan-headers glslang xclip wl-clipboard"
local ZYPPER_PACKAGES="python3-pip python3-gobject python3-gobject-cairo gtk3 libappindicator-gtk3 ibus-devel gobject-introspection-devel python3-devel portaudio-devel python3-virtualenv pkg-config wget curl unzip vulkan-tools vulkan-devel glslang xclip wl-clipboard"
# Gentoo uses Portage and different package naming convention
local EMERGE_PACKAGES="dev-python/pygobject:3 x11-libs/gtk+:3 dev-libs/libayatana-appindicator media-libs/portaudio dev-lang/python:3.8 pkgconf dev-util/glslang x11-misc/xclip gui-apps/wl-clipboard"
local EMERGE_PACKAGES="dev-python/pygobject:3 x11-libs/gtk+:3 dev-libs/libayatana-appindicator media-libs/portaudio dev-lang/python:3.9 pkgconf dev-util/glslang x11-misc/xclip gui-apps/wl-clipboard"
# Alpine Linux uses apk and has musl libc
local APK_PACKAGES="py3-gobject3 py3-pip gtk+3.0 py3-cairo portaudio-dev py3-virtualenv pkgconf wget curl unzip glslang vulkan-tools xclip wl-clipboard"
# Void Linux uses xbps
@@ -1721,7 +1721,7 @@ mkdir -p "$ICON_DIR"
# Check Python version
check_python_version() {
local MIN_VERSION="3.8"
local MIN_VERSION="3.9"
local PYTHON_CMD="python3"
# Check if python3 command exists
+3 -4
View File
@@ -14,7 +14,7 @@ authors = [
maintainers = [
{name = "Jatin K Malik", email = "jatinkrmalik@gmail.com"}
]
requires-python = ">=3.8"
requires-python = ">=3.9"
keywords = [
"voice", "dictation", "speech", "recognition",
"linux", "accessibility", "voice-typing", "speech-to-text"
@@ -26,7 +26,6 @@ classifiers = [
"License :: OSI Approved :: GNU General Public License v3 (GPLv3)",
"Operating System :: POSIX :: Linux",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.8",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
@@ -99,7 +98,7 @@ version = {attr = "vocalinux.version.__version__"}
[tool.black]
line-length = 100
target-version = ['py38', 'py39', 'py310', 'py311', 'py312', 'py313']
target-version = ['py39', 'py310', 'py311', 'py312', 'py313']
include = '\.pyi?$'
exclude = '''
/(
@@ -157,7 +156,7 @@ exclude_lines = [
show_missing = true
[tool.mypy]
python_version = "3.8"
python_version = "3.9"
warn_return_any = true
warn_unused_configs = true
ignore_missing_imports = true
+1 -1
View File
@@ -4,7 +4,7 @@ This module provides type definitions to avoid circular imports.
"""
from enum import Enum, auto
from typing import Callable, List, Optional, Protocol # noqa: F401
from typing import Callable, Optional, Protocol # noqa: F401
class RecognitionState(Enum):
@@ -7,7 +7,6 @@ This module processes text commands from speech recognition, such as
import logging
import re
from typing import List, Tuple
logger = logging.getLogger(__name__)
@@ -99,7 +98,7 @@ class CommandProcessor:
)
self.format_cmd_regex = re.compile(format_cmd_pattern, re.IGNORECASE)
def process_text(self, text: str) -> Tuple[str, List[str]]:
def process_text(self, text: str) -> tuple[str, list[str]]:
"""
Process text commands in the recognized text.
@@ -13,7 +13,7 @@ import queue
import sys
import threading
import time
from typing import Callable, List, Optional
from typing import Callable, Optional
from ..common_types import RecognitionState
from ..ui.audio_feedback import play_error_sound, play_start_sound, play_stop_sound
@@ -518,9 +518,9 @@ class SpeechRecognitionManager:
self._voice_commands_preference = kwargs.get("voice_commands_enabled")
self._voice_commands_enabled = self._resolve_voice_commands_enabled()
self.text_callbacks: List[Callable[[str], None]] = []
self.state_callbacks: List[Callable[[RecognitionState], None]] = []
self.action_callbacks: List[Callable[[str], None]] = []
self.text_callbacks: list[Callable[[str], None]] = []
self.state_callbacks: list[Callable[[RecognitionState], None]] = []
self.action_callbacks: list[Callable[[str], None]] = []
# Download progress tracking
self._download_progress_callback: Optional[Callable[[float, float, str], None]] = None
@@ -537,7 +537,7 @@ class SpeechRecognitionManager:
# Audio diagnostics tracking
self._last_audio_level = 0.0
self._audio_level_callbacks: List[Callable[[float], None]] = []
self._audio_level_callbacks: list[Callable[[float], None]] = []
# Recording control flags
self.should_record = False
@@ -699,7 +699,7 @@ class SpeechRecognitionManager:
self.state = RecognitionState.ERROR
raise
def _transcribe_with_whisper(self, audio_buffer: List[bytes]) -> str:
def _transcribe_with_whisper(self, audio_buffer: list[bytes]) -> str:
"""
Transcribe audio buffer using Whisper.
@@ -911,7 +911,7 @@ class SpeechRecognitionManager:
self.state = RecognitionState.ERROR
raise
def _transcribe_with_whispercpp(self, audio_buffer: List[bytes]) -> str:
def _transcribe_with_whispercpp(self, audio_buffer: list[bytes]) -> str:
"""
Transcribe audio buffer using whisper.cpp.
@@ -1397,11 +1397,11 @@ class SpeechRecognitionManager:
except ValueError:
logger.warning(f"Callback {callback} not found in text_callbacks.")
def get_text_callbacks(self) -> List[Callable[[str], None]]:
def get_text_callbacks(self) -> list[Callable[[str], None]]:
"""Get a copy of the current text callbacks list."""
return list(self.text_callbacks)
def set_text_callbacks(self, callbacks: List[Callable[[str], None]]):
def set_text_callbacks(self, callbacks: list[Callable[[str], None]]):
"""Set the text callbacks list (used for temporarily replacing callbacks)."""
self.text_callbacks = list(callbacks)
@@ -1829,7 +1829,7 @@ class SpeechRecognitionManager:
self._process_audio_buffer(audio_buffer)
def _process_audio_buffer(self, audio_buffer: List[bytes]):
def _process_audio_buffer(self, audio_buffer: list[bytes]):
"""Process an immutable audio segment for transcription and commands."""
if not audio_buffer:
return
@@ -1959,7 +1959,7 @@ class SpeechRecognitionManager:
self._update_state(RecognitionState.LISTENING)
logger.info("DEBUG: _perform_recognition thread exiting")
def _enqueue_audio_segment(self, audio_buffer: List[bytes]):
def _enqueue_audio_segment(self, audio_buffer: list[bytes]):
"""Queue an audio segment for asynchronous transcription."""
segment = audio_buffer.copy()
if not segment:
+6 -6
View File
@@ -7,7 +7,7 @@ This module handles loading, saving, and accessing user preferences.
import json
import logging
import os
from typing import Any, Dict
from typing import Any
logger = logging.getLogger(__name__)
@@ -109,7 +109,7 @@ class ConfigManager:
except Exception as e:
logger.error(f"Failed to load config: {e}")
def _check_needs_migration(self, user_config: Dict) -> bool:
def _check_needs_migration(self, user_config: dict) -> bool:
"""Check if the user config needs migration to add per-engine model sizes."""
sr_config = user_config.get("speech_recognition", {})
# Need migration if we have model_size but not the per-engine keys
@@ -117,7 +117,7 @@ class ConfigManager:
"vosk_model_size" not in sr_config or "whisper_model_size" not in sr_config
)
def _migrate_config(self, user_config: Dict):
def _migrate_config(self, user_config: dict):
"""Migrate old config formats to the current format."""
sr_config = self.config.get("speech_recognition", {})
user_sr_config = user_config.get("speech_recognition", {})
@@ -210,7 +210,7 @@ class ConfigManager:
logger.error(f"Failed to set config value: {e}")
return False
def get_settings(self) -> Dict[str, Any]:
def get_settings(self) -> dict[str, Any]:
"""Get the entire configuration dictionary."""
return self.config
@@ -267,7 +267,7 @@ class ConfigManager:
return enabled
def update_speech_recognition_settings(self, settings: Dict[str, Any]):
def update_speech_recognition_settings(self, settings: dict[str, Any]):
"""Update multiple speech recognition settings at once."""
if "speech_recognition" not in self.config:
self.config["speech_recognition"] = {}
@@ -293,7 +293,7 @@ class ConfigManager:
self.config["sound_effects"] = {}
self.config["sound_effects"]["enabled"] = enabled
def _update_dict_recursive(self, target: Dict, source: Dict):
def _update_dict_recursive(self, target: dict, source: dict):
"""
Update a dictionary recursively.
@@ -11,7 +11,7 @@ import os
import select
import threading
import time
from typing import Dict, List, Optional, Set
from typing import Optional
# Try to import evdev
try:
@@ -41,7 +41,7 @@ KEY_LEFTMETA = 125 # Super/Windows key
KEY_RIGHTMETA = 126
# Map modifier key names to evdev key codes
MODIFIER_KEY_CODES: Dict[str, Set[int]] = {
MODIFIER_KEY_CODES: dict[str, set[int]] = {
"ctrl": {KEY_LEFTCTRL, KEY_RIGHTCTRL},
"alt": {KEY_LEFTALT, KEY_RIGHTALT},
"shift": {KEY_LEFTSHIFT, KEY_RIGHTSHIFT},
@@ -55,7 +55,7 @@ MODIFIER_KEY_CODES: Dict[str, Set[int]] = {
}
def find_keyboard_devices() -> List[str]:
def find_keyboard_devices() -> list[str]:
"""
Find all keyboard input devices.
@@ -149,22 +149,22 @@ class EvdevKeyboardBackend(KeyboardBackend):
mode: The shortcut mode ("toggle" or "push_to_talk")
"""
super().__init__(shortcut, mode)
self.devices: List[InputDevice] = []
self.device_fds: List[int] = []
self.devices: list[InputDevice] = []
self.device_fds: list[int] = []
self.running = False
self.monitor_thread: Optional[threading.Thread] = None
self.last_trigger_time = 0
self.last_key_press_time = 0
self.double_tap_threshold = 0.3 # seconds
self.key_pressed_devices: Set[int] = set()
self.key_pressed_devices: set[int] = set()
self._devices_lock = threading.Lock()
if not EVDEV_AVAILABLE:
logger.error("python-evdev not available")
def _get_target_key_codes(self) -> Set[int]:
def _get_target_key_codes(self) -> set[int]:
"""Get the evdev key codes for the configured modifier."""
return MODIFIER_KEY_CODES.get(self._modifier_key, set())
+4 -4
View File
@@ -9,7 +9,7 @@ import logging
import threading
from datetime import datetime
from pathlib import Path
from typing import Callable, List, Optional
from typing import Callable, Optional
logger = logging.getLogger(__name__)
@@ -58,8 +58,8 @@ class LoggingManager:
max_records: Maximum number of log records to keep in memory
"""
self.max_records = max_records
self.log_records: List[LogRecord] = []
self.log_callbacks: List[Callable[[LogRecord], None]] = []
self.log_records: list[LogRecord] = []
self.log_callbacks: list[Callable[[LogRecord], None]] = []
self.lock = threading.Lock()
# Create logs directory
@@ -130,7 +130,7 @@ class LoggingManager:
level_filter: Optional[str] = None,
module_filter: Optional[str] = None,
last_n: Optional[int] = None,
) -> List[LogRecord]:
) -> list[LogRecord]:
"""
Get log records with optional filtering.
+5 -5
View File
@@ -8,7 +8,7 @@ for whisper.cpp, supporting Vulkan, CUDA, and CPU backends.
import logging
import os
import subprocess
from typing import Dict, Optional, Tuple
from typing import Optional
logger = logging.getLogger(__name__)
@@ -60,7 +60,7 @@ class ComputeBackend:
CPU = "cpu"
def detect_vulkan_support() -> Tuple[bool, Optional[str]]:
def detect_vulkan_support() -> tuple[bool, Optional[str]]:
"""
Detect if Vulkan is available and get device info.
@@ -91,7 +91,7 @@ def detect_vulkan_support() -> Tuple[bool, Optional[str]]:
return False, None
def detect_cuda_support() -> Tuple[bool, Optional[str]]:
def detect_cuda_support() -> tuple[bool, Optional[str]]:
"""
Detect if NVIDIA CUDA is available and get device info.
@@ -119,7 +119,7 @@ def detect_cuda_support() -> Tuple[bool, Optional[str]]:
return False, None
def detect_compute_backend() -> Tuple[str, str]:
def detect_compute_backend() -> tuple[str, str]:
"""
Detect the best available compute backend.
@@ -177,7 +177,7 @@ def detect_cpu_info() -> str:
return "CPU"
def get_recommended_model() -> Tuple[str, str]:
def get_recommended_model() -> tuple[str, str]:
"""
Get the recommended whisper.cpp model based on system configuration.
+61 -36
View File
@@ -175,8 +175,9 @@ class TestAudioFeedback(unittest.TestCase):
# Import the module first
import vocalinux.ui.audio_feedback as audio_feedback
with patch.object(audio_feedback.os.path, "exists", return_value=True), patch.object(
audio_feedback, "_get_audio_player", return_value=(None, [])
with (
patch.object(audio_feedback.os.path, "exists", return_value=True),
patch.object(audio_feedback, "_get_audio_player", return_value=(None, [])),
):
# Call the function
result = audio_feedback._play_sound_file("test.wav")
@@ -189,11 +190,15 @@ class TestAudioFeedback(unittest.TestCase):
# Import the module first
import vocalinux.ui.audio_feedback as audio_feedback
with patch.object(audio_feedback.os.path, "exists", return_value=True), patch.object(
audio_feedback,
"_get_audio_player",
return_value=("paplay", ["wav"]),
), patch.object(audio_feedback.subprocess, "Popen") as mock_popen:
with (
patch.object(audio_feedback.os.path, "exists", return_value=True),
patch.object(
audio_feedback,
"_get_audio_player",
return_value=("paplay", ["wav"]),
),
patch.object(audio_feedback.subprocess, "Popen") as mock_popen,
):
# Call the function
result = audio_feedback._play_sound_file("test.wav")
@@ -209,11 +214,15 @@ class TestAudioFeedback(unittest.TestCase):
# Import the module first
import vocalinux.ui.audio_feedback as audio_feedback
with patch.object(audio_feedback.os.path, "exists", return_value=True), patch.object(
audio_feedback,
"_get_audio_player",
return_value=("aplay", ["wav"]),
), patch.object(audio_feedback.subprocess, "Popen") as mock_popen:
with (
patch.object(audio_feedback.os.path, "exists", return_value=True),
patch.object(
audio_feedback,
"_get_audio_player",
return_value=("aplay", ["wav"]),
),
patch.object(audio_feedback.subprocess, "Popen") as mock_popen,
):
# Call the function
result = audio_feedback._play_sound_file("test.wav")
@@ -230,11 +239,15 @@ class TestAudioFeedback(unittest.TestCase):
# Import the module first
import vocalinux.ui.audio_feedback as audio_feedback
with patch.object(audio_feedback.os.path, "exists", return_value=True), patch.object(
audio_feedback,
"_get_audio_player",
return_value=("mplayer", ["wav"]),
), patch.object(audio_feedback.subprocess, "Popen") as mock_popen:
with (
patch.object(audio_feedback.os.path, "exists", return_value=True),
patch.object(
audio_feedback,
"_get_audio_player",
return_value=("mplayer", ["wav"]),
),
patch.object(audio_feedback.subprocess, "Popen") as mock_popen,
):
# Call the function
result = audio_feedback._play_sound_file("test.wav")
@@ -251,9 +264,11 @@ class TestAudioFeedback(unittest.TestCase):
# Import the module first
import vocalinux.ui.audio_feedback as audio_feedback
with patch.object(audio_feedback.os.path, "exists", return_value=True), patch.object(
audio_feedback, "_get_audio_player", return_value=("play", ["wav"])
), patch.object(audio_feedback.subprocess, "Popen") as mock_popen:
with (
patch.object(audio_feedback.os.path, "exists", return_value=True),
patch.object(audio_feedback, "_get_audio_player", return_value=("play", ["wav"])),
patch.object(audio_feedback.subprocess, "Popen") as mock_popen,
):
# Call the function
result = audio_feedback._play_sound_file("test.wav")
@@ -270,14 +285,18 @@ class TestAudioFeedback(unittest.TestCase):
# Import the module first
import vocalinux.ui.audio_feedback as audio_feedback
with patch.object(audio_feedback.os.path, "exists", return_value=True), patch.object(
audio_feedback,
"_get_audio_player",
return_value=("paplay", ["wav"]),
), patch.object(
audio_feedback.subprocess,
"Popen",
side_effect=Exception("Mock error"),
with (
patch.object(audio_feedback.os.path, "exists", return_value=True),
patch.object(
audio_feedback,
"_get_audio_player",
return_value=("paplay", ["wav"]),
),
patch.object(
audio_feedback.subprocess,
"Popen",
side_effect=Exception("Mock error"),
),
):
# Call the function
result = audio_feedback._play_sound_file("test.wav")
@@ -326,11 +345,15 @@ class TestAudioFeedback(unittest.TestCase):
# Import the module first
import vocalinux.ui.audio_feedback as audio_feedback
with patch.object(audio_feedback.os.path, "exists", return_value=True), patch.object(
audio_feedback,
"_get_audio_player",
return_value=("ci_test_player", ["wav"]),
), patch.object(audio_feedback.subprocess, "Popen") as mock_popen:
with (
patch.object(audio_feedback.os.path, "exists", return_value=True),
patch.object(
audio_feedback,
"_get_audio_player",
return_value=("ci_test_player", ["wav"]),
),
patch.object(audio_feedback.subprocess, "Popen") as mock_popen,
):
# Call the function
result = audio_feedback._play_sound_file("test.wav")
@@ -346,9 +369,11 @@ class TestAudioFeedback(unittest.TestCase):
# Import the module first
import vocalinux.ui.audio_feedback as audio_feedback
with patch.object(audio_feedback.shutil, "which", return_value=None), patch.object(
audio_feedback.os.path, "exists", return_value=True
), patch.dict("os.environ", {"GITHUB_ACTIONS": "true"}):
with (
patch.object(audio_feedback.shutil, "which", return_value=None),
patch.object(audio_feedback.os.path, "exists", return_value=True),
patch.dict("os.environ", {"GITHUB_ACTIONS": "true"}),
):
# First verify _get_audio_player returns None
player, formats = audio_feedback._get_audio_player()
self.assertIsNone(player)
+11 -6
View File
@@ -21,17 +21,22 @@ class TestAutostartManager(unittest.TestCase):
self.assertEqual(command, "/usr/bin/vocalinux --start-minimized")
def test_get_exec_command_falls_back_to_python_module(self):
with patch("vocalinux.ui.autostart_manager.shutil.which", return_value=None), patch(
"vocalinux.ui.autostart_manager.sys.executable", "/usr/bin/python3"
), patch("vocalinux.ui.autostart_manager.sys.frozen", new=False, create=True):
with (
patch("vocalinux.ui.autostart_manager.shutil.which", return_value=None),
patch("vocalinux.ui.autostart_manager.sys.executable", "/usr/bin/python3"),
patch("vocalinux.ui.autostart_manager.sys.frozen", new=False, create=True),
):
command = autostart_manager.get_exec_command()
self.assertEqual(command, "/usr/bin/python3 -m vocalinux.main --start-minimized")
def test_enable_disable_autostart_creates_and_removes_entry(self):
with tempfile.TemporaryDirectory() as tmp_dir:
with patch.dict("os.environ", {"XDG_CONFIG_HOME": tmp_dir}, clear=False), patch(
"vocalinux.ui.autostart_manager.shutil.which",
return_value="/usr/bin/vocalinux",
with (
patch.dict("os.environ", {"XDG_CONFIG_HOME": tmp_dir}, clear=False),
patch(
"vocalinux.ui.autostart_manager.shutil.which",
return_value="/usr/bin/vocalinux",
),
):
enabled = autostart_manager.enable_autostart()
self.assertTrue(enabled)
-2
View File
@@ -106,7 +106,6 @@ class TestImports:
"""Test that all types can be imported."""
from vocalinux.common_types import (
Callable,
List,
Optional,
Protocol,
RecognitionState,
@@ -120,7 +119,6 @@ class TestImports:
SpeechRecognitionManagerProtocol,
TextInjectorProtocol,
Callable,
List,
Optional,
Protocol,
]
+16 -10
View File
@@ -803,11 +803,14 @@ class TestIBusEngineMainEntrypoint(unittest.TestCase):
"""Test --xml path prints engines XML and exits early."""
from vocalinux.text_injection import ibus_engine
with patch.object(sys, "argv", ["ibus_engine.py", "--xml"]), patch.object(
ibus_engine, "_get_engines_xml", return_value="<engines></engines>"
) as mock_get_xml, patch("builtins.print") as mock_print, patch.object(
ibus_engine.IBus, "init"
) as mock_init:
with (
patch.object(sys, "argv", ["ibus_engine.py", "--xml"]),
patch.object(
ibus_engine, "_get_engines_xml", return_value="<engines></engines>"
) as mock_get_xml,
patch("builtins.print") as mock_print,
patch.object(ibus_engine.IBus, "init") as mock_init,
):
result = ibus_engine.main()
self.assertEqual(result, 0)
@@ -820,11 +823,14 @@ class TestIBusEngineMainEntrypoint(unittest.TestCase):
from vocalinux.text_injection import ibus_engine
mock_app = MagicMock()
with patch.object(sys, "argv", ["ibus_engine.py", "--ibus"]), patch.object(
ibus_engine, "IBUS_AVAILABLE", True
), patch.object(ibus_engine.IBus, "init") as mock_init, patch.object(
ibus_engine, "VocalinuxEngineApplication", return_value=mock_app
) as mock_app_cls:
with (
patch.object(sys, "argv", ["ibus_engine.py", "--ibus"]),
patch.object(ibus_engine, "IBUS_AVAILABLE", True),
patch.object(ibus_engine.IBus, "init") as mock_init,
patch.object(
ibus_engine, "VocalinuxEngineApplication", return_value=mock_app
) as mock_app_cls,
):
result = ibus_engine.main()
self.assertEqual(result, 0)
+8 -6
View File
@@ -583,9 +583,10 @@ class TestBackendFactory(unittest.TestCase):
"""Test forcing pynput backend."""
from vocalinux.ui.keyboard_backends import create_backend
with patch("vocalinux.ui.keyboard_backends.PYNPUT_AVAILABLE", True), patch(
"vocalinux.ui.keyboard_backends.PynputKeyboardBackend"
) as MockPynput:
with (
patch("vocalinux.ui.keyboard_backends.PYNPUT_AVAILABLE", True),
patch("vocalinux.ui.keyboard_backends.PynputKeyboardBackend") as MockPynput,
):
mock_backend = MagicMock()
MockPynput.return_value = mock_backend
@@ -598,9 +599,10 @@ class TestBackendFactory(unittest.TestCase):
"""Test forcing evdev backend."""
from vocalinux.ui.keyboard_backends import create_backend
with patch("vocalinux.ui.keyboard_backends.EVDEV_AVAILABLE", True), patch(
"vocalinux.ui.keyboard_backends.EvdevKeyboardBackend"
) as MockEvdev:
with (
patch("vocalinux.ui.keyboard_backends.EVDEV_AVAILABLE", True),
patch("vocalinux.ui.keyboard_backends.EvdevKeyboardBackend") as MockEvdev,
):
mock_backend = MagicMock()
MockEvdev.return_value = mock_backend
+5 -2
View File
@@ -109,8 +109,11 @@ class TestCheckDependencies(unittest.TestCase):
obj = _make_injector(DesktopEnvironment.WAYLAND)
with patch("shutil.which", return_value=None):
with self.assertRaises(RuntimeError):
obj._check_dependencies()
with patch(
"vocalinux.text_injection.text_injector.is_ibus_available", return_value=False
):
with self.assertRaises(RuntimeError):
obj._check_dependencies()
class TestInjectText(unittest.TestCase):
+54 -63
View File
@@ -69,13 +69,12 @@ class TestTrayIndicatorInitialization(unittest.TestCase):
from vocalinux.common_types import RecognitionState
from vocalinux.ui.tray_indicator import TrayIndicator
with patch("vocalinux.ui.tray_indicator.logging"), patch(
"vocalinux.ui.tray_indicator._resource_manager"
) as mock_resource_manager, patch(
"vocalinux.ui.tray_indicator.ConfigManager"
) as mock_config_manager, patch(
"vocalinux.ui.tray_indicator.KeyboardShortcutManager"
) as mock_keyboard_manager:
with (
patch("vocalinux.ui.tray_indicator.logging"),
patch("vocalinux.ui.tray_indicator._resource_manager") as mock_resource_manager,
patch("vocalinux.ui.tray_indicator.ConfigManager") as mock_config_manager,
patch("vocalinux.ui.tray_indicator.KeyboardShortcutManager") as mock_keyboard_manager,
):
# Set up mocks
mock_speech_engine = MagicMock()
@@ -124,13 +123,12 @@ class TestTrayIndicatorInitialization(unittest.TestCase):
from vocalinux.common_types import RecognitionState
from vocalinux.ui.tray_indicator import TrayIndicator
with patch("vocalinux.ui.tray_indicator.logging"), patch(
"vocalinux.ui.tray_indicator._resource_manager"
) as mock_resource_manager, patch(
"vocalinux.ui.tray_indicator.ConfigManager"
) as mock_config_manager, patch(
"vocalinux.ui.tray_indicator.KeyboardShortcutManager"
) as mock_keyboard_manager:
with (
patch("vocalinux.ui.tray_indicator.logging"),
patch("vocalinux.ui.tray_indicator._resource_manager") as mock_resource_manager,
patch("vocalinux.ui.tray_indicator.ConfigManager") as mock_config_manager,
patch("vocalinux.ui.tray_indicator.KeyboardShortcutManager") as mock_keyboard_manager,
):
from vocalinux.ui.tray_indicator import TrayIndicator
@@ -192,13 +190,12 @@ class TestTrayIndicatorMenuOperations(unittest.TestCase):
from vocalinux.common_types import RecognitionState
from vocalinux.ui.tray_indicator import TrayIndicator
with patch("vocalinux.ui.tray_indicator.logging"), patch(
"vocalinux.ui.tray_indicator._resource_manager"
) as mock_resource_manager, patch(
"vocalinux.ui.tray_indicator.ConfigManager"
) as mock_config_manager, patch(
"vocalinux.ui.tray_indicator.KeyboardShortcutManager"
) as mock_keyboard_manager:
with (
patch("vocalinux.ui.tray_indicator.logging"),
patch("vocalinux.ui.tray_indicator._resource_manager") as mock_resource_manager,
patch("vocalinux.ui.tray_indicator.ConfigManager") as mock_config_manager,
patch("vocalinux.ui.tray_indicator.KeyboardShortcutManager") as mock_keyboard_manager,
):
from vocalinux.ui.tray_indicator import TrayIndicator
@@ -232,13 +229,12 @@ class TestTrayIndicatorMenuOperations(unittest.TestCase):
from vocalinux.common_types import RecognitionState
from vocalinux.ui.tray_indicator import TrayIndicator
with patch("vocalinux.ui.tray_indicator.logging"), patch(
"vocalinux.ui.tray_indicator._resource_manager"
) as mock_resource_manager, patch(
"vocalinux.ui.tray_indicator.ConfigManager"
) as mock_config_manager, patch(
"vocalinux.ui.tray_indicator.KeyboardShortcutManager"
) as mock_keyboard_manager:
with (
patch("vocalinux.ui.tray_indicator.logging"),
patch("vocalinux.ui.tray_indicator._resource_manager") as mock_resource_manager,
patch("vocalinux.ui.tray_indicator.ConfigManager") as mock_config_manager,
patch("vocalinux.ui.tray_indicator.KeyboardShortcutManager") as mock_keyboard_manager,
):
from vocalinux.ui.tray_indicator import TrayIndicator
@@ -294,13 +290,12 @@ class TestTrayIndicatorStateHandling(unittest.TestCase):
from vocalinux.common_types import RecognitionState
from vocalinux.ui.tray_indicator import TrayIndicator
with patch("vocalinux.ui.tray_indicator.logging"), patch(
"vocalinux.ui.tray_indicator._resource_manager"
) as mock_resource_manager, patch(
"vocalinux.ui.tray_indicator.ConfigManager"
) as mock_config_manager, patch(
"vocalinux.ui.tray_indicator.KeyboardShortcutManager"
) as mock_keyboard_manager:
with (
patch("vocalinux.ui.tray_indicator.logging"),
patch("vocalinux.ui.tray_indicator._resource_manager") as mock_resource_manager,
patch("vocalinux.ui.tray_indicator.ConfigManager") as mock_config_manager,
patch("vocalinux.ui.tray_indicator.KeyboardShortcutManager") as mock_keyboard_manager,
):
from vocalinux.ui.tray_indicator import TrayIndicator
@@ -334,13 +329,12 @@ class TestTrayIndicatorStateHandling(unittest.TestCase):
from vocalinux.common_types import RecognitionState
from vocalinux.ui.tray_indicator import TrayIndicator
with patch("vocalinux.ui.tray_indicator.logging"), patch(
"vocalinux.ui.tray_indicator._resource_manager"
) as mock_resource_manager, patch(
"vocalinux.ui.tray_indicator.ConfigManager"
) as mock_config_manager, patch(
"vocalinux.ui.tray_indicator.KeyboardShortcutManager"
) as mock_keyboard_manager:
with (
patch("vocalinux.ui.tray_indicator.logging"),
patch("vocalinux.ui.tray_indicator._resource_manager") as mock_resource_manager,
patch("vocalinux.ui.tray_indicator.ConfigManager") as mock_config_manager,
patch("vocalinux.ui.tray_indicator.KeyboardShortcutManager") as mock_keyboard_manager,
):
from vocalinux.ui.tray_indicator import TrayIndicator
@@ -374,13 +368,12 @@ class TestTrayIndicatorStateHandling(unittest.TestCase):
from vocalinux.common_types import RecognitionState
from vocalinux.ui.tray_indicator import TrayIndicator
with patch("vocalinux.ui.tray_indicator.logging"), patch(
"vocalinux.ui.tray_indicator._resource_manager"
) as mock_resource_manager, patch(
"vocalinux.ui.tray_indicator.ConfigManager"
) as mock_config_manager, patch(
"vocalinux.ui.tray_indicator.KeyboardShortcutManager"
) as mock_keyboard_manager:
with (
patch("vocalinux.ui.tray_indicator.logging"),
patch("vocalinux.ui.tray_indicator._resource_manager") as mock_resource_manager,
patch("vocalinux.ui.tray_indicator.ConfigManager") as mock_config_manager,
patch("vocalinux.ui.tray_indicator.KeyboardShortcutManager") as mock_keyboard_manager,
):
from vocalinux.ui.tray_indicator import TrayIndicator
@@ -436,13 +429,12 @@ class TestTrayIndicatorSignalHandling(unittest.TestCase):
from vocalinux.common_types import RecognitionState
from vocalinux.ui.tray_indicator import TrayIndicator
with patch("vocalinux.ui.tray_indicator.logging"), patch(
"vocalinux.ui.tray_indicator._resource_manager"
) as mock_resource_manager, patch(
"vocalinux.ui.tray_indicator.ConfigManager"
) as mock_config_manager, patch(
"vocalinux.ui.tray_indicator.KeyboardShortcutManager"
) as mock_keyboard_manager:
with (
patch("vocalinux.ui.tray_indicator.logging"),
patch("vocalinux.ui.tray_indicator._resource_manager") as mock_resource_manager,
patch("vocalinux.ui.tray_indicator.ConfigManager") as mock_config_manager,
patch("vocalinux.ui.tray_indicator.KeyboardShortcutManager") as mock_keyboard_manager,
):
from vocalinux.ui.tray_indicator import TrayIndicator
@@ -480,13 +472,12 @@ class TestTrayIndicatorSignalHandling(unittest.TestCase):
from vocalinux.common_types import RecognitionState
from vocalinux.ui.tray_indicator import TrayIndicator
with patch("vocalinux.ui.tray_indicator.logging"), patch(
"vocalinux.ui.tray_indicator._resource_manager"
) as mock_resource_manager, patch(
"vocalinux.ui.tray_indicator.ConfigManager"
) as mock_config_manager, patch(
"vocalinux.ui.tray_indicator.KeyboardShortcutManager"
) as mock_keyboard_manager:
with (
patch("vocalinux.ui.tray_indicator.logging"),
patch("vocalinux.ui.tray_indicator._resource_manager") as mock_resource_manager,
patch("vocalinux.ui.tray_indicator.ConfigManager") as mock_config_manager,
patch("vocalinux.ui.tray_indicator.KeyboardShortcutManager") as mock_keyboard_manager,
):
from vocalinux.ui.tray_indicator import TrayIndicator
+33 -21
View File
@@ -90,10 +90,14 @@ class TestWhisperSupport:
whisper_mock.load_model.return_value = model_mock
# Patch modules and mock the download method to avoid network/file operations
with patch.dict(sys.modules, {"whisper": whisper_mock, "torch": torch_mock}), patch(
"vocalinux.speech_recognition.recognition_manager."
"SpeechRecognitionManager._download_whisper_model"
) as _mock_download, patch("os.path.exists", return_value=True):
with (
patch.dict(sys.modules, {"whisper": whisper_mock, "torch": torch_mock}),
patch(
"vocalinux.speech_recognition.recognition_manager."
"SpeechRecognitionManager._download_whisper_model"
) as _mock_download,
patch("os.path.exists", return_value=True),
):
from vocalinux.speech_recognition.recognition_manager import SpeechRecognitionManager
# Create manager with Whisper engine and defer_download=False to trigger model loading
@@ -132,23 +136,31 @@ class TestWhisperSupport:
):
from vocalinux.speech_recognition import recognition_manager as rm
with patch("os.makedirs"), patch("os.path.exists", return_value=True), patch(
"os.path.getsize", return_value=40 * 1024 * 1024
), patch("multiprocessing.cpu_count", return_value=4), patch(
"vocalinux.utils.whispercpp_model_info.detect_compute_backend",
return_value=("vulkan", "Intel GPU"),
), patch(
"vocalinux.utils.whispercpp_model_info.get_backend_display_name",
return_value="Vulkan",
), patch(
"vocalinux.speech_recognition.recognition_manager.get_model_path",
return_value="/tmp/mock-ggml-tiny.bin",
), patch(
"vocalinux.speech_recognition.recognition_manager._show_notification"
) as notify_mock, patch.dict(
os.environ,
{"GGML_VULKAN": "1", "GGML_CUDA": "1"},
clear=False,
with (
patch("os.makedirs"),
patch("os.path.exists", return_value=True),
patch("os.path.getsize", return_value=40 * 1024 * 1024),
patch("multiprocessing.cpu_count", return_value=4),
patch(
"vocalinux.utils.whispercpp_model_info.detect_compute_backend",
return_value=("vulkan", "Intel GPU"),
),
patch(
"vocalinux.utils.whispercpp_model_info.get_backend_display_name",
return_value="Vulkan",
),
patch(
"vocalinux.speech_recognition.recognition_manager.get_model_path",
return_value="/tmp/mock-ggml-tiny.bin",
),
patch(
"vocalinux.speech_recognition.recognition_manager._show_notification"
) as notify_mock,
patch.dict(
os.environ,
{"GGML_VULKAN": "1", "GGML_CUDA": "1"},
clear=False,
),
):
manager = rm.SpeechRecognitionManager(
engine="whisper_cpp", model_size="tiny", defer_download=False
+20 -22
View File
@@ -261,9 +261,10 @@ class TestDetectCPUInfo(unittest.TestCase):
def test_detect_cpu_info_fallback_to_nproc(self):
"""Test CPU detection falls back to nproc."""
with patch("builtins.open", create=True, side_effect=Exception("No /proc/cpuinfo")), patch(
"subprocess.run"
) as mock_run:
with (
patch("builtins.open", create=True, side_effect=Exception("No /proc/cpuinfo")),
patch("subprocess.run") as mock_run,
):
mock_run.return_value = MagicMock(returncode=0, stdout="8\n")
@@ -275,8 +276,9 @@ class TestDetectCPUInfo(unittest.TestCase):
def test_detect_cpu_info_default_fallback(self):
"""Test CPU detection returns default when all methods fail."""
with patch("builtins.open", create=True, side_effect=Exception()), patch(
"subprocess.run", side_effect=Exception()
with (
patch("builtins.open", create=True, side_effect=Exception()),
patch("subprocess.run", side_effect=Exception()),
):
from vocalinux.utils.whispercpp_model_info import detect_cpu_info
@@ -299,11 +301,10 @@ class TestDetectComputeBackend(unittest.TestCase):
def test_detect_compute_backend_prefers_vulkan(self):
"""Test that detect_compute_backend prefers Vulkan."""
with patch(
"vocalinux.utils.whispercpp_model_info.detect_vulkan_support"
) as mock_vulkan, patch(
"vocalinux.utils.whispercpp_model_info.detect_cuda_support"
) as mock_cuda:
with (
patch("vocalinux.utils.whispercpp_model_info.detect_vulkan_support") as mock_vulkan,
patch("vocalinux.utils.whispercpp_model_info.detect_cuda_support") as mock_cuda,
):
mock_vulkan.return_value = (True, "Intel Arc GPU")
mock_cuda.return_value = (True, "NVIDIA RTX 3080 (10GB)")
@@ -319,11 +320,10 @@ class TestDetectComputeBackend(unittest.TestCase):
def test_detect_compute_backend_falls_back_to_cuda(self):
"""Test that detect_compute_backend falls back to CUDA."""
with patch(
"vocalinux.utils.whispercpp_model_info.detect_vulkan_support"
) as mock_vulkan, patch(
"vocalinux.utils.whispercpp_model_info.detect_cuda_support"
) as mock_cuda:
with (
patch("vocalinux.utils.whispercpp_model_info.detect_vulkan_support") as mock_vulkan,
patch("vocalinux.utils.whispercpp_model_info.detect_cuda_support") as mock_cuda,
):
mock_vulkan.return_value = (False, None)
mock_cuda.return_value = (True, "NVIDIA RTX 3080 (10GB)")
@@ -339,13 +339,11 @@ class TestDetectComputeBackend(unittest.TestCase):
def test_detect_compute_backend_falls_back_to_cpu(self):
"""Test that detect_compute_backend falls back to CPU."""
with patch(
"vocalinux.utils.whispercpp_model_info.detect_vulkan_support"
) as mock_vulkan, patch(
"vocalinux.utils.whispercpp_model_info.detect_cuda_support"
) as mock_cuda, patch(
"vocalinux.utils.whispercpp_model_info.detect_cpu_info"
) as mock_cpu:
with (
patch("vocalinux.utils.whispercpp_model_info.detect_vulkan_support") as mock_vulkan,
patch("vocalinux.utils.whispercpp_model_info.detect_cuda_support") as mock_cuda,
patch("vocalinux.utils.whispercpp_model_info.detect_cpu_info") as mock_cpu,
):
mock_vulkan.return_value = (False, None)
mock_cuda.return_value = (False, None)
+1 -1
View File
@@ -20,7 +20,7 @@ const faqCategories = [
},
{
q: "What are the system requirements?",
a: "You need Python 3.8+, a working microphone, and about 500MB of disk space. For GPU acceleration with whisper.cpp, you need Vulkan support (AMD, Intel, or NVIDIA).",
a: "You need Python 3.9+, a working microphone, and about 500MB of disk space. For GPU acceleration with whisper.cpp, you need Vulkan support (AMD, Intel, or NVIDIA).",
},
],
},
+6 -6
View File
@@ -37,8 +37,8 @@ const distroConfig: Record<DistroSlug, DistroConfig> = {
intro:
"This Ubuntu guide is tuned for practical desktop use: terminals, browsers, IDEs, office suites, and chat apps. Vocalinux runs locally so your voice data never leaves your machine.",
prerequisites: [
"Ubuntu 22.04+ (or Ubuntu-based distro)",
"Python 3.8+",
"Ubuntu 22.04+ (or Ubuntu-based distro)",
"Python 3.9+",
"Working microphone",
"curl installed (sudo apt update && sudo apt install -y curl)",
],
@@ -68,8 +68,8 @@ const distroConfig: Record<DistroSlug, DistroConfig> = {
intro:
"Fedora is an excellent match for Vocalinux thanks to current Python stacks and up-to-date system libraries. This guide focuses on reliable setup and minimal friction.",
prerequisites: [
"Fedora Workstation 39+ (or Fedora-based distro)",
"Python 3.8+",
"Fedora Workstation 39+ (or Fedora-based distro)",
"Python 3.9+",
"Working microphone",
"curl installed (sudo dnf install -y curl)",
],
@@ -99,8 +99,8 @@ const distroConfig: Record<DistroSlug, DistroConfig> = {
intro:
"Arch users usually care about control and performance. This guide keeps setup lean while preserving compatibility across rolling-release desktop environments.",
prerequisites: [
"Arch Linux / Manjaro / EndeavourOS",
"Python 3.8+",
"Arch Linux / Manjaro / EndeavourOS",
"Python 3.9+",
"Working microphone",
"curl installed (sudo pacman -Sy --needed curl)",
],
+2 -2
View File
@@ -858,7 +858,7 @@ export default function HomePage() {
</h4>
<ul className="space-y-2 text-sm text-muted-foreground">
<li>• Ubuntu 22.04+ (or equivalent)</li>
<li>• Python 3.8 or newer</li>
<li>• Python 3.9 or newer</li>
<li>• 4GB RAM (8GB for large models)</li>
<li>• Microphone</li>
<li>• X11 or Wayland display server</li>
@@ -1134,7 +1134,7 @@ export default function HomePage() {
question: "What are the system requirements?",
answer: (
<>
Minimum: 4GB RAM, Python 3.8+, ~200MB disk space. 8GB+ RAM recommended for larger Whisper models. The default whisper.cpp tiny model (~39MB) works great on modest hardware. GPU acceleration is available via Vulkan for AMD, Intel, and NVIDIA GPUs.{" "}
Minimum: 4GB RAM, Python 3.9+, ~200MB disk space. 8GB+ RAM recommended for larger Whisper models. The default whisper.cpp tiny model (~39MB) works great on modest hardware. GPU acceleration is available via Vulkan for AMD, Intel, and NVIDIA GPUs.{" "}
<Link href="/install/" className="text-primary hover:underline">View full installation requirements</Link>.
</>
),
+1 -1
View File
@@ -11,7 +11,7 @@ const troubleshootingItems = [
solutions: [
"Ensure you ran the installer: curl -fsSL https://raw.githubusercontent.com/jatinkrmalik/vocalinux/main/install.sh | bash",
"Check if the virtual environment is activated: source ~/.local/share/vocalinux/venv/bin/activate",
"Verify Python version (3.8+ required): python3 --version",
"Verify Python version (3.9+ required): python3 --version",
"Try reinstalling: ./uninstall.sh && ./install.sh",
],
},