diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0df8891..9a9205d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,6 +9,9 @@ concurrency: group: ci-${{ github.ref }} cancel-in-progress: true +permissions: + contents: read + jobs: # Cross-platform import safety: every module must byte-compile and the # package must import on a non-Windows host (no GUI / Win32 deps needed). @@ -47,3 +50,26 @@ jobs: env: QT_QPA_PLATFORM: offscreen run: python -m unittest discover -s tests -v + + lint: + name: lint (ruff) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - run: pip install ruff==0.15.15 + - run: ruff check src/ tests/ + - run: ruff format --check src/ tests/ + + audit: + name: pip-audit (CVE scan) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - run: pip install pip-audit + - run: pip-audit -r requirements.txt -r requirements-build.txt diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..884ea0f --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,26 @@ +name: CodeQL + +on: + push: + branches: [main] + pull_request: + branches: [main] + schedule: + - cron: "31 4 * * 1" # weekly, Monday 04:31 UTC + +permissions: + contents: read + security-events: write + +jobs: + analyze: + name: analyze (python) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: github/codeql-action/init@v3 + with: + languages: python + - uses: github/codeql-action/analyze@v3 + with: + category: "/language:python" diff --git a/docs/superpowers/plans/2026-06-04-ci-tooling.md b/docs/superpowers/plans/2026-06-04-ci-tooling.md new file mode 100644 index 0000000..50a44d9 --- /dev/null +++ b/docs/superpowers/plans/2026-06-04-ci-tooling.md @@ -0,0 +1,285 @@ +# CI Tooling Implementation Plan (ruff, permissions, pip-audit, CodeQL) + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Strengthen CI with linting/formatting (ruff), least-privilege workflow permissions, supply-chain CVE scanning (pip-audit), and static security analysis (CodeQL). + +**Architecture:** Add a `ruff.toml` config + one-time `ruff format` of the codebase, then extend `ci.yml` with a top-level read-only `permissions` block and two new jobs (`lint`, `audit`), and add a separate `codeql.yml` workflow. No runtime/`requirements.txt` changes — ruff/pip-audit are installed inside CI jobs only. + +**Tech Stack:** GitHub Actions, ruff 0.15.15, pip-audit, `github/codeql-action` v3, Python 3.12 runners. + +--- + +## Context (verified locally) + +- `ruff check src/ tests/` → **6 findings**: 4 unused-import (`F401`, auto-fixable), 2 unused-local (`F841`). +- `F841` at `src/main.py:495` (`tray_app`) is **load-bearing** — the binding keeps the tray app alive during `app.exec()`; it gets `# noqa`, not deletion. +- `ruff format --check --line-length 100` → **11 files** would reformat (line-length 100 chosen to match existing style; default 88 reformats 14). +- `pip-audit -r requirements.txt` → **clean** (no known vulns). `requirements-build.txt` only fails to resolve locally because this dev venv is Python 3.14 and `pyinstaller==6.14.2` caps `<3.14`; it resolves on the CI runner's Python 3.12 (the release workflow installs it there). +- Existing `ci.yml`: jobs `compile` (ubuntu) + `test` (windows matrix 3.11/3.12); no top-level `permissions`. `release.yml` already sets `permissions: contents: write`. + +## File Structure + +- Create: `ruff.toml` — lint/format config. +- Modify: `src/audio.py`, `src/config.py`, `src/hotkey.py`, `src/settings_dialog.py` (remove unused imports); `src/main.py` (`# noqa` on the live binding); `tests/test_tray_menu.py` (drop unused local). Plus whole-repo `ruff format`. +- Modify: `.github/workflows/ci.yml` — top-level permissions + `lint` + `audit` jobs. +- Create: `.github/workflows/codeql.yml`. + +⚠️ **Open-PR interaction:** PR #13 (custom hotkeys) rewrites `hotkey.py`, `config.py`, `settings_dialog.py`, `main.py`. The whole-repo `ruff format` here WILL conflict with #13. Resolution: merge whichever lands second will need a re-format / conflict resolution. Recommended order — merge #13 first, then rebase this branch and re-run `ruff format`. This is an accepted cost of adopting a formatter mid-flight. + +--- + +## Task 1: ruff config + +**Files:** +- Create: `ruff.toml` + +- [ ] **Step 1: Write `ruff.toml`** + +```toml +# Ruff config — lint (pyflakes/pycodestyle defaults) + formatter. +line-length = 100 +target-version = "py311" +``` + +- [ ] **Step 2: Verify ruff picks up the config** + +Run: `.venv/Scripts/python.exe -m ruff check src/ tests/` +Expected: still reports the 6 findings (config doesn't change them yet), and runs without a config error. + +- [ ] **Step 3: Commit** + +```bash +git add ruff.toml +git commit -m "build: add ruff config (lint + format, line-length 100)" +``` + +--- + +## Task 2: Fix ruff lint findings + +**Files:** +- Modify: `src/audio.py:7`, `src/config.py:12`, `src/hotkey.py:42`, `src/settings_dialog.py:13` (auto), `src/main.py:495`, `tests/test_tray_menu.py:14` (manual) + +- [ ] **Step 1a: Guard — confirm `config.APP_NAME` is not imported elsewhere** + +`struct`/`ctypes.wintypes`/`Qt` are third-party imports nobody re-imports from these modules, but `APP_NAME` is a project symbol. Confirm nothing imports it FROM `config` (it's also exported by `utils`, which is the real source): + +Run: `grep -rn "from src.config import" src tests | grep APP_NAME; grep -rn "config.APP_NAME" src tests` +Expected: no output (safe to drop the unused re-import from `config.py`). + +- [ ] **Step 1b: Auto-fix the 4 unused imports** + +Run: `.venv/Scripts/python.exe -m ruff check --fix src/ tests/` +This removes: `struct` (audio.py), `APP_NAME` (config.py), `ctypes.wintypes` (hotkey.py), `Qt` (settings_dialog.py). +Expected after: `2 errors` remain (the two `F841`). + +- [ ] **Step 2: Fix `src/main.py:495` with a noqa (binding must stay alive)** + +The `tray_app` reference keeps the tray app from being garbage-collected during `app.exec()`. Do NOT delete it. Change: + +```python + tray_app = _TrayApp(startup_mode=args.startup) +``` +to: +```python + tray_app = _TrayApp(startup_mode=args.startup) # noqa: F841 — keep ref alive for app lifetime +``` + +- [ ] **Step 3: Fix `tests/test_tray_menu.py:14` (drop the unused binding)** + +The QApplication singleton persists in Qt once constructed; the local name is unused. Change: + +```python + app = QApplication.instance() or QApplication([]) +``` +to: +```python + QApplication.instance() or QApplication([]) +``` + +- [ ] **Step 4: Verify lint is clean** + +Run: `.venv/Scripts/python.exe -m ruff check src/ tests/` +Expected: `All checks passed!` + +- [ ] **Step 5: Verify tests still pass** + +Run: `.venv/Scripts/python.exe -m unittest discover -s tests` +Expected: OK (no regressions from import removals). + +- [ ] **Step 6: Commit** + +```bash +git add src/ tests/test_tray_menu.py +git commit -m "style: remove unused imports and silence load-bearing unused binding" +``` + +--- + +## Task 3: Apply ruff format + +**Files:** +- Modify: whole repo (11 files reformatted under line-length 100) + +- [ ] **Step 1: Apply the formatter** + +Run: `.venv/Scripts/python.exe -m ruff format src/ tests/` +Expected: "11 files reformatted, 8 files left unchanged" (counts may shift slightly after Task 2 edits). + +- [ ] **Step 2: Verify format is clean and tests pass** + +Run: `.venv/Scripts/python.exe -m ruff format --check src/ tests/` → expected "N files already formatted". +Run: `.venv/Scripts/python.exe -m ruff check src/ tests/` → expected "All checks passed!". +Run: `.venv/Scripts/python.exe -m unittest discover -s tests` → expected OK. + +- [ ] **Step 3: Commit** + +```bash +git add src/ tests/ +git commit -m "style: apply ruff format across the codebase" +``` + +--- + +## Task 4: ci.yml — permissions + lint + audit jobs + +**Files:** +- Modify: `.github/workflows/ci.yml` + +- [ ] **Step 1: Add a top-level least-privilege permissions block** + +In `.github/workflows/ci.yml`, after the `concurrency:` block (before `jobs:`), insert: + +```yaml +permissions: + contents: read +``` + +- [ ] **Step 2: Add the `lint` job** + +Append to the `jobs:` map (after the `test` job): + +```yaml + lint: + name: lint (ruff) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - run: pip install ruff==0.15.15 + - run: ruff check src/ tests/ + - run: ruff format --check src/ tests/ +``` + +- [ ] **Step 3: Add the `audit` job** + +Append after `lint`: + +```yaml + audit: + name: pip-audit (CVE scan) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - run: pip install pip-audit + - run: pip-audit -r requirements.txt -r requirements-build.txt +``` + +- [ ] **Step 4: Validate the workflow YAML** + +Run: +```bash +.venv/Scripts/python.exe -c "import yaml; yaml.safe_load(open('.github/workflows/ci.yml', encoding='utf-8')); print('ci.yml OK')" +``` +Expected: `ci.yml OK`. + +- [ ] **Step 5: Commit** + +```bash +git add .github/workflows/ci.yml +git commit -m "ci: add ruff lint, format check, and pip-audit jobs with read-only permissions" +``` + +--- + +## Task 5: CodeQL workflow + +**Files:** +- Create: `.github/workflows/codeql.yml` + +- [ ] **Step 1: Write the workflow** + +```yaml +name: CodeQL + +on: + push: + branches: [main] + pull_request: + branches: [main] + schedule: + - cron: "31 4 * * 1" # weekly, Monday 04:31 UTC + +permissions: + contents: read + security-events: write + +jobs: + analyze: + name: analyze (python) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: github/codeql-action/init@v3 + with: + languages: python + - uses: github/codeql-action/analyze@v3 + with: + category: "/language:python" +``` + +- [ ] **Step 2: Validate the workflow YAML** + +Run: +```bash +.venv/Scripts/python.exe -c "import yaml; yaml.safe_load(open('.github/workflows/codeql.yml', encoding='utf-8')); print('codeql.yml OK')" +``` +Expected: `codeql.yml OK`. + +- [ ] **Step 3: Commit** + +```bash +git add .github/workflows/codeql.yml +git commit -m "ci: add CodeQL static analysis workflow for Python" +``` + +--- + +## Task 6: Document the plan + +**Files:** +- Create: `docs/superpowers/plans/2026-06-04-ci-tooling.md` (this file) + +- [ ] **Step 1: Commit** + +```bash +git add docs/superpowers/plans/2026-06-04-ci-tooling.md +git commit -m "docs: add CI tooling plan" +``` + +--- + +## Notes / Decisions baked in + +- **ruff pinned to 0.15.15** in CI for reproducibility (matches the version validated locally). It is not a `requirements` manifest, so Dependabot won't bump it — update manually when desired. +- **`ruff format` enforced** (`--check` in CI) after a one-time whole-repo reformat. Line-length 100 keeps the diff smaller and matches existing style. +- **pip-audit runs on Python 3.12** so the `pyinstaller` pin resolves (the dev venv is 3.14, where it doesn't). Strict (no `continue-on-error`) — requirements are currently clean, so the gate is green; a future CVE will correctly block until the dep is bumped or the advisory is `--ignore-vuln`'d. +- **CodeQL** is a separate workflow (convention), weekly + on push/PR to main, with `security-events: write`. `github/codeql-action` is kept current by the github-actions Dependabot entry. +- **Least privilege:** `ci.yml` and `codeql.yml` declare minimal `permissions`; `release.yml` already had `contents: write`. +- **No new runtime dependencies.** ruff/pip-audit are CI-only installs. diff --git a/ruff.toml b/ruff.toml new file mode 100644 index 0000000..2ddf2cf --- /dev/null +++ b/ruff.toml @@ -0,0 +1,3 @@ +# Ruff config — lint (pyflakes/pycodestyle defaults) + formatter. +line-length = 100 +target-version = "py311" diff --git a/src/audio.py b/src/audio.py index 81146bc..c29c7dc 100644 --- a/src/audio.py +++ b/src/audio.py @@ -4,7 +4,6 @@ import io import logging -import struct import threading import time import wave @@ -77,10 +76,7 @@ def default_input_device_id() -> int | None: else: dev_name = str(dev["name"]) for i, candidate in enumerate(sd.query_devices()): - if ( - candidate["max_input_channels"] > 0 - and str(candidate["name"]) == dev_name - ): + if candidate["max_input_channels"] > 0 and str(candidate["name"]) == dev_name: resolved_id = i break else: @@ -136,7 +132,9 @@ def calibrate(self, duration: float = 2.0) -> float: if threshold < DEFAULT_RMS_THRESHOLD: threshold = DEFAULT_RMS_THRESHOLD self._rms_threshold = threshold - log.info("Calibration done: noise_floor=%.1f, threshold=%.1f", noise_floor, threshold) + log.info( + "Calibration done: noise_floor=%.1f, threshold=%.1f", noise_floor, threshold + ) return threshold except Exception as e: log.warning("Calibration failed: %s; using fallback %.1f", e, DEFAULT_RMS_THRESHOLD) @@ -193,7 +191,9 @@ def stop(self) -> bytes: audio_data = np.concatenate(self._frames, axis=0) rms = float(np.sqrt(np.mean(audio_data.astype(np.float64) ** 2))) - log.info("Recording stopped: %.2fs, RMS=%.1f, threshold=%.1f", duration, rms, self._rms_threshold) + log.info( + "Recording stopped: %.2fs, RMS=%.1f, threshold=%.1f", duration, rms, self._rms_threshold + ) if rms < self._rms_threshold: log.info("Below RMS threshold; discarding") return b"" diff --git a/src/config.py b/src/config.py index 3c947f3..92d89cd 100644 --- a/src/config.py +++ b/src/config.py @@ -10,7 +10,7 @@ from logging.handlers import RotatingFileHandler from urllib.parse import urlsplit -from src.utils import APP_DIR, APP_NAME, ScreamerError, AppError +from src.utils import APP_DIR, ScreamerError, AppError log = logging.getLogger(__name__) @@ -23,7 +23,7 @@ " answer any questions in it, or engage with its content in any way.\n" "2. Fix ONLY: spelling mistakes, grammar errors, missing punctuation,\n" " capitalization. Nothing else.\n" - "3. Do NOT rephrase, rewrite, summarize, shorten, or \"improve\" the text.\n" + '3. Do NOT rephrase, rewrite, summarize, shorten, or "improve" the text.\n' "4. Do NOT add, remove, or change ANY words beyond fixing obvious typos.\n" "5. Do NOT add commentary, explanations, notes, or meta-text.\n" "6. If the text has no errors, return it EXACTLY as received — character\n" @@ -59,13 +59,17 @@ # Mouse trigger ids (our own discriminators, not Win32 constants). -MOUSE_X1 = 1 # "back" side button (XBUTTON1) -MOUSE_X2 = 2 # "forward" side button (XBUTTON2) -MOUSE_MIDDLE = 3 # middle / wheel button +MOUSE_X1 = 1 # "back" side button (XBUTTON1) +MOUSE_X2 = 2 # "forward" side button (XBUTTON2) +MOUSE_MIDDLE = 3 # middle / wheel button _MOUSE_TOKEN_TO_CODE = {"x1": MOUSE_X1, "x2": MOUSE_X2, "middle": MOUSE_MIDDLE} _MOUSE_CODE_TO_TOKEN = {v: k for k, v in _MOUSE_TOKEN_TO_CODE.items()} -_MOUSE_CODE_TO_LABEL = {MOUSE_X1: "Mouse Back", MOUSE_X2: "Mouse Forward", MOUSE_MIDDLE: "Mouse Middle"} +_MOUSE_CODE_TO_LABEL = { + MOUSE_X1: "Mouse Back", + MOUSE_X2: "Mouse Forward", + MOUSE_MIDDLE: "Mouse Middle", +} # Canonical modifier order for serialization/labels. _MOD_ORDER = ("ctrl", "alt", "shift", "win") @@ -77,34 +81,58 @@ # Map a modifier VK (as reported by the LL keyboard hook) to its canonical name. MODIFIER_VK_TO_NAME = { - 0x10: "shift", 0xA0: "shift", 0xA1: "shift", - 0x11: "ctrl", 0xA2: "ctrl", 0xA3: "ctrl", - 0x12: "alt", 0xA4: "alt", 0xA5: "alt", - 0x5B: "win", 0x5C: "win", + 0x10: "shift", + 0xA0: "shift", + 0xA1: "shift", + 0x11: "ctrl", + 0xA2: "ctrl", + 0xA3: "ctrl", + 0x12: "alt", + 0xA4: "alt", + 0xA5: "alt", + 0x5B: "win", + 0x5C: "win", } # Keys safe to bind alone (won't eat normal typing / clicking). SAFE_STANDALONE_KEYS = frozenset( - set(range(0x70, 0x88)) # F1..F24 - | {0x91, # Scroll Lock - 0x13, # Pause - 0x2D, # Insert - 0x2C, # PrintScreen - 0x5D, # Apps / Menu - 0x90} # Num Lock + set(range(0x70, 0x88)) # F1..F24 + | { + 0x91, # Scroll Lock + 0x13, # Pause + 0x2D, # Insert + 0x2C, # PrintScreen + 0x5D, # Apps / Menu + 0x90, + } # Num Lock ) # Human-readable names for common VK codes (labels only). _VK_NAMES = { - 0x08: "Backspace", 0x09: "Tab", 0x0D: "Enter", 0x13: "Pause", - 0x1B: "Esc", 0x20: "Space", 0x21: "Page Up", 0x22: "Page Down", - 0x23: "End", 0x24: "Home", 0x25: "Left", 0x26: "Up", 0x27: "Right", - 0x28: "Down", 0x2C: "PrintScreen", 0x2D: "Insert", 0x2E: "Delete", - 0x5D: "Menu", 0x90: "Num Lock", 0x91: "Scroll Lock", + 0x08: "Backspace", + 0x09: "Tab", + 0x0D: "Enter", + 0x13: "Pause", + 0x1B: "Esc", + 0x20: "Space", + 0x21: "Page Up", + 0x22: "Page Down", + 0x23: "End", + 0x24: "Home", + 0x25: "Left", + 0x26: "Up", + 0x27: "Right", + 0x28: "Down", + 0x2C: "PrintScreen", + 0x2D: "Insert", + 0x2E: "Delete", + 0x5D: "Menu", + 0x90: "Num Lock", + 0x91: "Scroll Lock", } -_VK_NAMES.update({c: chr(c) for c in range(0x30, 0x3A)}) # 0-9 -_VK_NAMES.update({c: chr(c) for c in range(0x41, 0x5B)}) # A-Z -_VK_NAMES.update({0x70 + i: f"F{i + 1}" for i in range(24)}) # F1..F24 +_VK_NAMES.update({c: chr(c) for c in range(0x30, 0x3A)}) # 0-9 +_VK_NAMES.update({c: chr(c) for c in range(0x41, 0x5B)}) # A-Z +_VK_NAMES.update({0x70 + i: f"F{i + 1}" for i in range(24)}) # F1..F24 def _vk_label(vk: int) -> str: @@ -181,13 +209,13 @@ def parse(cls, value: str) -> "Hotkey | None": mods = frozenset(mod_parts) if trigger.startswith("mouse:"): - token = trigger[len("mouse:"):] + token = trigger[len("mouse:") :] if token not in _MOUSE_TOKEN_TO_CODE: return None return cls(mods, "mouse", _MOUSE_TOKEN_TO_CODE[token]) if trigger.startswith("key:"): try: - code = int(trigger[len("key:"):], 16) + code = int(trigger[len("key:") :], 16) except ValueError: return None return cls(mods, "key", code) @@ -305,12 +333,14 @@ def llm_fallback_provider(self) -> FallbackProviderConfig: # Fields that contain secret API keys and must go through DPAPI. -_SECRET_FIELDS = frozenset({ - "stt_api_key", - "stt_fallback_api_key", - "llm_api_key", - "llm_fallback_api_key", -}) +_SECRET_FIELDS = frozenset( + { + "stt_api_key", + "stt_fallback_api_key", + "llm_api_key", + "llm_fallback_api_key", + } +) # DPAPI entropy string bound to this application. _ENTROPY = b"screamer-dpapi-v1" @@ -320,6 +350,7 @@ def llm_fallback_provider(self) -> FallbackProviderConfig: # DPAPI helpers (Windows-only, guarded at runtime) # --------------------------------------------------------------------------- + def _dpapi_available() -> bool: return platform.system() == "Windows" @@ -366,18 +397,23 @@ class DATA_BLOB(ctypes.Structure): def _dpapi_encrypt(plaintext: str) -> str: """Encrypt *plaintext* with Windows DPAPI. Returns hex-encoded blob string.""" - return _dpapi_crypt(plaintext.encode("utf-8"), protect=True, errmsg="DPAPI encrypt failed").hex() + return _dpapi_crypt( + plaintext.encode("utf-8"), protect=True, errmsg="DPAPI encrypt failed" + ).hex() def _dpapi_decrypt(hex_blob: str) -> str: """Decrypt a hex-encoded DPAPI blob. Returns plaintext string.""" - return _dpapi_crypt(bytes.fromhex(hex_blob), protect=False, errmsg="DPAPI decrypt failed").decode("utf-8") + return _dpapi_crypt( + bytes.fromhex(hex_blob), protect=False, errmsg="DPAPI decrypt failed" + ).decode("utf-8") # --------------------------------------------------------------------------- # QSettings helpers # --------------------------------------------------------------------------- + def _get_qsettings(): """Return a QSettings instance for the app. Import PySide6 lazily.""" from PySide6.QtCore import QSettings @@ -432,6 +468,7 @@ def _load_secrets(cfg: AppConfig) -> None: # Public API # --------------------------------------------------------------------------- + def load_config() -> AppConfig: """Load QSettings + DPAPI. Unknown keys get field defaults.""" settings = _get_qsettings() @@ -555,7 +592,9 @@ def validate_config(cfg: AppConfig) -> list[ConfigValidationIssue]: try: parse_custom_headers(headers) except (json.JSONDecodeError, ValueError) as e: - issues.append(ConfigValidationIssue(f"{label} custom headers are invalid: {e}", tab_index)) + issues.append( + ConfigValidationIssue(f"{label} custom headers are invalid: {e}", tab_index) + ) return issues @@ -639,7 +678,11 @@ def setup_logging(debug: bool = False) -> None: cfg = load_config() print("Loaded config defaults:") for f in fields(AppConfig): - print(f" {f.name} = {getattr(cfg, f.name)}") + if f.name in _SECRET_FIELDS: + masked = "***" if getattr(cfg, f.name) else "(empty)" + print(f" {f.name} = {masked}") + else: + print(f" {f.name} = {getattr(cfg, f.name)}") print() @@ -658,9 +701,12 @@ def setup_logging(debug: bool = False) -> None: # .env import test. cfg2 = import_from_env(cfg) print("After import_from_env (may be no-op):") - for f_name in _SECRET_FIELDS: - val = getattr(cfg2, f_name) - print(f" {f_name} = {'***' if val else '(empty)'}") + # Iterate dataclass fields (not the _SECRET_FIELDS literal) and mask secrets; + # the secret value only gates a constant, so it never reaches the print. + for f in fields(AppConfig): + if f.name in _SECRET_FIELDS: + masked = "***" if getattr(cfg2, f.name) else "(empty)" + print(f" {f.name} = {masked}") print() print("Config module OK") diff --git a/src/hotkey.py b/src/hotkey.py index 1630aa1..1602754 100644 --- a/src/hotkey.py +++ b/src/hotkey.py @@ -91,7 +91,9 @@ def start(self) -> None: # subsequent stop() can reliably post WM_QUIT. if not self._ready.wait(timeout=5.0): log.warning("Hotkey listener did not signal readiness within 5s") - log.info("HotkeyListener started: %s mode=%s", self._hotkey.to_canonical(), self._mode.value) + log.info( + "HotkeyListener started: %s mode=%s", self._hotkey.to_canonical(), self._mode.value + ) def stop(self) -> None: if platform.system() != "Windows": @@ -303,9 +305,19 @@ def _declare_win32_functions(ctypes, user32, kernel32, hookproc, lresult) -> Non user32.UnhookWindowsHookEx.restype = wintypes.BOOL user32.UnhookWindowsHookEx.argtypes = [ctypes.c_void_p] user32.CallNextHookEx.restype = lresult - user32.CallNextHookEx.argtypes = [ctypes.c_void_p, ctypes.c_int, wintypes.WPARAM, wintypes.LPARAM] + user32.CallNextHookEx.argtypes = [ + ctypes.c_void_p, + ctypes.c_int, + wintypes.WPARAM, + wintypes.LPARAM, + ] user32.GetMessageW.restype = wintypes.BOOL - user32.GetMessageW.argtypes = [ctypes.POINTER(wintypes.MSG), wintypes.HWND, wintypes.UINT, wintypes.UINT] + user32.GetMessageW.argtypes = [ + ctypes.POINTER(wintypes.MSG), + wintypes.HWND, + wintypes.UINT, + wintypes.UINT, + ] user32.PeekMessageW.restype = wintypes.BOOL user32.PeekMessageW.argtypes = [ ctypes.POINTER(wintypes.MSG), @@ -319,7 +331,12 @@ def _declare_win32_functions(ctypes, user32, kernel32, hookproc, lresult) -> Non user32.DispatchMessageW.restype = lresult user32.DispatchMessageW.argtypes = [ctypes.POINTER(wintypes.MSG)] user32.PostThreadMessageW.restype = wintypes.BOOL - user32.PostThreadMessageW.argtypes = [wintypes.DWORD, wintypes.UINT, wintypes.WPARAM, wintypes.LPARAM] + user32.PostThreadMessageW.argtypes = [ + wintypes.DWORD, + wintypes.UINT, + wintypes.WPARAM, + wintypes.LPARAM, + ] # --------------------------------------------------------------------------- diff --git a/src/http_client.py b/src/http_client.py index b6f8862..2820f52 100644 --- a/src/http_client.py +++ b/src/http_client.py @@ -34,7 +34,9 @@ def post( json: dict[str, object] | None = None, timeout: float | httpx.Timeout | None = None, ) -> httpx.Response: - return _get_client().post(url, headers=headers, data=data, files=files, json=json, timeout=timeout) + return _get_client().post( + url, headers=headers, data=data, files=files, json=json, timeout=timeout + ) def close() -> None: diff --git a/src/injector.py b/src/injector.py index be6e190..19a992e 100644 --- a/src/injector.py +++ b/src/injector.py @@ -108,7 +108,9 @@ def _send_vk(vk: int, key_up: bool = False) -> None: def _utf16_units(value: str) -> list[str]: encoded = value.encode("utf-16-le", errors="surrogatepass") - return [chr(int.from_bytes(encoded[i : i + 2], "little")) for i in range(0, len(encoded), 2)] + return [ + chr(int.from_bytes(encoded[i : i + 2], "little")) for i in range(0, len(encoded), 2) + ] try: with log_duration(log, f"Text injection ({len(text)} chars)"): @@ -143,7 +145,9 @@ def _utf16_units(value: str) -> list[str]: if platform.system() != "Windows": print("injector.py requires Windows for SendInput.") - print("On non-Windows: import succeeds, runtime raises ScreamerError(UNSUPPORTED_PLATFORM).") + print( + "On non-Windows: import succeeds, runtime raises ScreamerError(UNSUPPORTED_PLATFORM)." + ) print("Import test passed — no crash at import time.") raise SystemExit(0) diff --git a/src/main.py b/src/main.py index b2d1819..91bde97 100644 --- a/src/main.py +++ b/src/main.py @@ -237,7 +237,9 @@ def _rebuild_menu(self) -> None: self._set_recording_mode, ) self._add_choice_submenu("Hotkey", HOTKEY_OPTIONS, c.hotkey, self._set_hotkey) - self._add_choice_submenu("Post-type Key", POST_KEY_OPTIONS, c.post_type_key, self._set_post_key) + self._add_choice_submenu( + "Post-type Key", POST_KEY_OPTIONS, c.post_type_key, self._set_post_key + ) self._menu.addSeparator() self._add_persistent_checkbox("AI Rewrite", c.llm_enabled, self._toggle_rewrite) @@ -254,7 +256,9 @@ def _rebuild_menu(self) -> None: def _make_listener(self) -> None: """Create and start a HotkeyListener from current config, storing it on self.""" mode = HotkeyMode.TOGGLE if self._config.recording_mode == "toggle" else HotkeyMode.HOLD - hotkey = Hotkey.parse(self._config.hotkey) or Hotkey(frozenset({"ctrl", "alt"}), "key", 0x20) + hotkey = Hotkey.parse(self._config.hotkey) or Hotkey( + frozenset({"ctrl", "alt"}), "key", 0x20 + ) self._hotkey = HotkeyListener(hotkey, mode, self._bridge) self._hotkey.start() @@ -538,7 +542,7 @@ def main(argv: list[str] | None = None) -> None: app = QApplication([]) app.setQuitOnLastWindowClosed(False) - tray_app = _TrayApp(startup_mode=args.startup) + tray_app = _TrayApp(startup_mode=args.startup) # noqa: F841 — keep ref alive for app lifetime log.info("Screamer started") try: diff --git a/src/rewrite.py b/src/rewrite.py index 23307a7..03e72f2 100644 --- a/src/rewrite.py +++ b/src/rewrite.py @@ -39,7 +39,12 @@ def rewrite(text: str, config: AppConfig) -> PipelineResult: try: result = _call_llm(provider=provider, system_prompt=system_prompt, user_text=text) if result: - log.debug("%s LLM rewrite: %r → %r", "Fallback" if is_fallback else "Primary", text[:60], result[:60]) + log.debug( + "%s LLM rewrite: %r → %r", + "Fallback" if is_fallback else "Primary", + text[:60], + result[:60], + ) return PipelineResult(text=result) except Exception as e: log.warning("%s LLM failed: %s", "Fallback" if is_fallback else "Primary", e) diff --git a/src/settings_dialog.py b/src/settings_dialog.py index 81413c6..ce2ff55 100644 --- a/src/settings_dialog.py +++ b/src/settings_dialog.py @@ -495,7 +495,9 @@ def _select_device(self, cfg: AppConfig) -> None: if cfg.audio_device_id is not None: for i in range(self._device_combo.count()): if self._device_combo.itemData(i) == cfg.audio_device_id: - item_name = _clean_device_name(self._device_combo.itemText(i).split("] ", 1)[-1]) + item_name = _clean_device_name( + self._device_combo.itemText(i).split("] ", 1)[-1] + ) if not saved_name or saved_name in item_name.lower(): self._device_combo.setCurrentIndex(i) return @@ -685,7 +687,7 @@ class HotkeyCaptureEdit(QLineEdit): """ captured = Signal(object) # Hotkey - cancelled = Signal() # Esc pressed during recording + cancelled = Signal() # Esc pressed during recording def __init__(self) -> None: super().__init__() diff --git a/src/snackbar.py b/src/snackbar.py index ba1f7d0..e5b724e 100644 --- a/src/snackbar.py +++ b/src/snackbar.py @@ -14,7 +14,7 @@ # Keyed by ``TrayState.value`` so this stays decoupled from icons.py. # Value = (label, dot RGB). Absent key (e.g. "idle") -> hidden. _CONTENT: dict[str, tuple[str, tuple[int, int, int]]] = { - "recording": ("Recording", (229, 57, 53)), # red + "recording": ("Recording", (229, 57, 53)), # red "processing": ("Processing", (255, 179, 0)), # amber } @@ -40,12 +40,12 @@ class RecordingSnackbar(QWidget): dot plus a label. Never takes focus and never appears in the taskbar. """ - _MARGIN = 48 # px above the screen's available bottom edge - _PAD_X = 18 # horizontal inner padding - _PAD_Y = 11 # vertical inner padding - _DOT_R = 6 # dot radius - _GAP = 11 # gap between dot and text - _RADIUS = 15 # pill corner radius + _MARGIN = 48 # px above the screen's available bottom edge + _PAD_X = 18 # horizontal inner padding + _PAD_Y = 11 # vertical inner padding + _DOT_R = 6 # dot radius + _GAP = 11 # gap between dot and text + _RADIUS = 15 # pill corner radius def __init__(self) -> None: super().__init__(None) @@ -156,9 +156,12 @@ def paintEvent(self, event) -> None: # noqa: N802 (Qt override name) rect = self.rect() path = QPainterPath() path.addRoundedRect( - float(rect.x()), float(rect.y()), - float(rect.width()), float(rect.height()), - float(self._RADIUS), float(self._RADIUS), + float(rect.x()), + float(rect.y()), + float(rect.width()), + float(rect.height()), + float(self._RADIUS), + float(self._RADIUS), ) painter.fillPath(path, QColor(28, 28, 30, 220)) # dark translucent pill diff --git a/src/stt.py b/src/stt.py index fe4a9e2..f69c0f6 100644 --- a/src/stt.py +++ b/src/stt.py @@ -54,7 +54,9 @@ def transcribe(audio_wav: bytes, config: AppConfig) -> PipelineResult: if not fallback.enabled: raise ScreamerError(AppError.STT_FAILED, str(e)) from e - raise ScreamerError(AppError.STT_FAILED, "Both primary and fallback STT failed or returned no speech") + raise ScreamerError( + AppError.STT_FAILED, "Both primary and fallback STT failed or returned no speech" + ) def _call_stt( diff --git a/tests/test_config.py b/tests/test_config.py index d02bcd5..25f4777 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -4,7 +4,13 @@ import unittest from pathlib import Path -from src.config import AppConfig, ProviderConfig, import_from_env, parse_custom_headers, validate_config +from src.config import ( + AppConfig, + ProviderConfig, + import_from_env, + parse_custom_headers, + validate_config, +) class ConfigValidationTests(unittest.TestCase): diff --git a/tests/test_hotkey_listener.py b/tests/test_hotkey_listener.py index 5112fb4..4c4eb46 100644 --- a/tests/test_hotkey_listener.py +++ b/tests/test_hotkey_listener.py @@ -40,11 +40,11 @@ def test_full_combo_press_and_release(self): self.assertFalse(listener._on_kb_event(WM_KEYDOWN, VK_LCTRL)) # modifier passes self.assertFalse(listener._on_kb_event(WM_KEYDOWN, VK_LALT)) - self.assertTrue(listener._on_kb_event(WM_KEYDOWN, 0x20)) # trigger suppressed + self.assertTrue(listener._on_kb_event(WM_KEYDOWN, 0x20)) # trigger suppressed self.assertEqual(pressed, [1]) self.assertEqual(released, []) - self.assertTrue(listener._on_kb_event(WM_KEYUP, 0x20)) # release suppressed + self.assertTrue(listener._on_kb_event(WM_KEYUP, 0x20)) # release suppressed self.assertEqual(released, [1]) def test_autorepeat_does_not_re_emit(self): @@ -68,7 +68,7 @@ def test_extra_modifier_blocks_match(self): hk = Hotkey(frozenset({"ctrl"}), "key", 0x20) listener, pressed, _ = _listener(hk, HotkeyMode.HOLD) listener._on_kb_event(WM_KEYDOWN, VK_LCTRL) - listener._on_kb_event(WM_KEYDOWN, VK_LALT) # extra alt held + listener._on_kb_event(WM_KEYDOWN, VK_LALT) # extra alt held self.assertFalse(listener._on_kb_event(WM_KEYDOWN, 0x20)) self.assertEqual(pressed, []) diff --git a/tests/test_hotkey_model.py b/tests/test_hotkey_model.py index 586bf2d..21bf4c1 100644 --- a/tests/test_hotkey_model.py +++ b/tests/test_hotkey_model.py @@ -33,7 +33,9 @@ def test_canonical_roundtrip_mouse(self): self.assertEqual(Hotkey.parse("mouse:middle"), Hotkey(frozenset(), "mouse", MOUSE_MIDDLE)) def test_parse_legacy_keys_migrate(self): - self.assertEqual(Hotkey.parse("ctrl_alt_space"), Hotkey(frozenset({"ctrl", "alt"}), "key", 0x20)) + self.assertEqual( + Hotkey.parse("ctrl_alt_space"), Hotkey(frozenset({"ctrl", "alt"}), "key", 0x20) + ) self.assertEqual(Hotkey.parse("scroll_lock"), Hotkey(frozenset(), "key", 0x91)) self.assertEqual(Hotkey.parse("pause"), Hotkey(frozenset(), "key", 0x13)) @@ -43,17 +45,21 @@ def test_parse_invalid_returns_none(self): self.assertIsNone(Hotkey.parse("ctrl+mouse:x9")) def test_label_human_readable(self): - self.assertEqual(Hotkey(frozenset({"ctrl", "alt"}), "key", 0x20).to_label(), "Ctrl+Alt+Space") + self.assertEqual( + Hotkey(frozenset({"ctrl", "alt"}), "key", 0x20).to_label(), "Ctrl+Alt+Space" + ) self.assertEqual(Hotkey(frozenset(), "key", 0x91).to_label(), "Scroll Lock") - self.assertEqual(Hotkey(frozenset({"ctrl"}), "mouse", MOUSE_X1).to_label(), "Ctrl+Mouse Back") + self.assertEqual( + Hotkey(frozenset({"ctrl"}), "mouse", MOUSE_X1).to_label(), "Ctrl+Mouse Back" + ) self.assertEqual(Hotkey(frozenset(), "mouse", MOUSE_MIDDLE).to_label(), "Mouse Middle") def test_validate_ok_with_modifier(self): self.assertIsNone(Hotkey(frozenset({"ctrl", "alt"}), "key", 0x20).validate()) def test_validate_ok_safe_standalone(self): - self.assertIsNone(Hotkey(frozenset(), "key", 0x91).validate()) # Scroll Lock - self.assertIsNone(Hotkey(frozenset(), "key", 0x70).validate()) # F1 + self.assertIsNone(Hotkey(frozenset(), "key", 0x91).validate()) # Scroll Lock + self.assertIsNone(Hotkey(frozenset(), "key", 0x70).validate()) # F1 self.assertIsNone(Hotkey(frozenset(), "mouse", MOUSE_X1).validate()) def test_validate_rejects_bare_normal_key(self): diff --git a/tests/test_icons.py b/tests/test_icons.py index 962f7c7..dd81e38 100644 --- a/tests/test_icons.py +++ b/tests/test_icons.py @@ -22,9 +22,7 @@ def test_same_state_returns_cached_pixmap(self) -> None: def test_distinct_states_return_distinct_pixmaps(self) -> None: from src.icons import TrayState, get_icon_pixmap - self.assertIsNot( - get_icon_pixmap(TrayState.IDLE), get_icon_pixmap(TrayState.RECORDING) - ) + self.assertIsNot(get_icon_pixmap(TrayState.IDLE), get_icon_pixmap(TrayState.RECORDING)) if __name__ == "__main__": diff --git a/tests/test_snackbar.py b/tests/test_snackbar.py index 3e4632d..dc01378 100644 --- a/tests/test_snackbar.py +++ b/tests/test_snackbar.py @@ -36,13 +36,13 @@ def test_centers_horizontally_and_sits_above_bottom_margin(self): from PySide6.QtCore import QPoint, QRect, QSize from src.snackbar import bottom_center_xy - avail = QRect(0, 0, 1920, 1040) # 1920x1080 minus a 40px taskbar + avail = QRect(0, 0, 1920, 1040) # 1920x1080 minus a 40px taskbar size = QSize(160, 40) point = bottom_center_xy(avail, size, margin=48) self.assertIsInstance(point, QPoint) - self.assertEqual(point.x(), (1920 - 160) // 2) # 880 - self.assertEqual(point.y(), 0 + 1040 - 40 - 48) # 952 + self.assertEqual(point.x(), (1920 - 160) // 2) # 880 + self.assertEqual(point.y(), 0 + 1040 - 40 - 48) # 952 def test_respects_non_zero_screen_origin(self): from PySide6.QtCore import QRect, QSize @@ -53,7 +53,7 @@ def test_respects_non_zero_screen_origin(self): point = bottom_center_xy(avail, size, margin=10) self.assertEqual(point.x(), 100 + (800 - 200) // 2) # 400 - self.assertEqual(point.y(), 50 + 600 - 50 - 10) # 590 + self.assertEqual(point.y(), 50 + 600 - 50 - 10) # 590 class SnackbarWidgetTests(unittest.TestCase): diff --git a/tests/test_snackbar_wiring.py b/tests/test_snackbar_wiring.py index a747608..4b63e6c 100644 --- a/tests/test_snackbar_wiring.py +++ b/tests/test_snackbar_wiring.py @@ -16,8 +16,8 @@ def _bare_app(): QApplication.instance() or QApplication([]) app = _TrayApp.__new__(_TrayApp) QObject.__init__(app) - app._tray = MagicMock() # _apply_state calls setIcon/setToolTip - app._snackbar = MagicMock() # what we assert on + app._tray = MagicMock() # _apply_state calls setIcon/setToolTip + app._snackbar = MagicMock() # what we assert on return app diff --git a/tests/test_startup.py b/tests/test_startup.py index af826ad..5bb4d9f 100644 --- a/tests/test_startup.py +++ b/tests/test_startup.py @@ -19,7 +19,10 @@ def test_startup_command_uses_frozen_executable(self) -> None: with tempfile.TemporaryDirectory(prefix="Screamer Test ") as tmp: exe = str(Path(tmp) / "Screamer.exe") - with patch.object(sys, "executable", exe), patch.object(sys, "frozen", True, create=True): + with ( + patch.object(sys, "executable", exe), + patch.object(sys, "frozen", True, create=True), + ): command = startup.startup_command() self.assertEqual(command, f'"{exe}" --startup') diff --git a/tests/test_stt_rewrite.py b/tests/test_stt_rewrite.py index 2a81b96..17b01ef 100644 --- a/tests/test_stt_rewrite.py +++ b/tests/test_stt_rewrite.py @@ -11,7 +11,9 @@ class FakeResponse: - def __init__(self, payload: dict, *, status_code: int = 200, headers: dict[str, str] | None = None) -> None: + def __init__( + self, payload: dict, *, status_code: int = 200, headers: dict[str, str] | None = None + ) -> None: self._payload = payload self.status_code = status_code self.headers = headers or {} @@ -48,7 +50,13 @@ def fake_post(url, **kwargs): self.assertEqual(result.text, "fallback text") self.assertEqual(result.warnings, [AppError.STT_FALLBACK_USED]) - self.assertEqual(calls, ["https://primary.test/v1/audio/transcriptions", "https://fallback.test/v1/audio/transcriptions"]) + self.assertEqual( + calls, + [ + "https://primary.test/v1/audio/transcriptions", + "https://fallback.test/v1/audio/transcriptions", + ], + ) def test_stt_merges_custom_headers(self) -> None: cfg = AppConfig( @@ -97,7 +105,13 @@ def fake_post(url, **kwargs): self.assertEqual(result.text, "fixed text") self.assertEqual(result.warnings, []) - self.assertEqual(calls, ["https://primary.test/v1/chat/completions", "https://fallback.test/v1/chat/completions"]) + self.assertEqual( + calls, + [ + "https://primary.test/v1/chat/completions", + "https://fallback.test/v1/chat/completions", + ], + ) def test_stt_groq_uses_json_response_format(self) -> None: cfg = AppConfig( @@ -164,7 +178,9 @@ def test_rewrite_groq_sets_dynamic_completion_cap(self) -> None: def fake_post(_url, **kwargs): captured.update(kwargs["json"]) - return FakeResponse({"choices": [{"message": {"content": "fixed text"}, "finish_reason": "stop"}]}) + return FakeResponse( + {"choices": [{"message": {"content": "fixed text"}, "finish_reason": "stop"}]} + ) with patch("src.http_client.post", side_effect=fake_post): result = rewrite("hello world" * 80, cfg) @@ -184,7 +200,9 @@ def test_rewrite_length_finish_keeps_original_text(self) -> None: ) def fake_post(_url, **kwargs): - return FakeResponse({"choices": [{"message": {"content": "partial"}, "finish_reason": "length"}]}) + return FakeResponse( + {"choices": [{"message": {"content": "partial"}, "finish_reason": "length"}]} + ) with patch("src.http_client.post", side_effect=fake_post): result = rewrite("raw text", cfg) @@ -201,7 +219,9 @@ def test_rewrite_length_finish_keeps_original_text_for_non_groq(self) -> None: ) def fake_post(_url, **kwargs): - return FakeResponse({"choices": [{"message": {"content": "partial"}, "finish_reason": "length"}]}) + return FakeResponse( + {"choices": [{"message": {"content": "partial"}, "finish_reason": "length"}]} + ) with patch("src.http_client.post", side_effect=fake_post): result = rewrite("raw text", cfg) diff --git a/tests/test_tray_menu.py b/tests/test_tray_menu.py index bc2c406..055d2fd 100644 --- a/tests/test_tray_menu.py +++ b/tests/test_tray_menu.py @@ -11,7 +11,7 @@ def make_tray_app(): - app = QApplication.instance() or QApplication([]) + QApplication.instance() or QApplication([]) tray_app = _TrayApp.__new__(_TrayApp) QObject.__init__(tray_app) tray_app._menu = QMenu()