diff --git a/.gitignore b/.gitignore
index 7a60b85..2940e5d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,2 +1,5 @@
__pycache__/
*.pyc
+.env
+.env.*
+secrets.env
diff --git a/README.md b/README.md
index 842052a..b722a4d 100644
--- a/README.md
+++ b/README.md
@@ -125,7 +125,7 @@ systemctl --user enable --now ydotool.service # nutzt /usr/local/bin/ydotoold
**6. Anwendung starten**
```bash
-python app/blitztext_linux.py
+./run.sh
```
@@ -144,7 +144,7 @@ Blitztext registriert globale Hotkeys via `evdev`. Mit diesen Kombinationen hast
| **Blitztext :)** | Meta + Shift + E | ✅ | Ergänzt deine Nachricht passend mit Emojis. |
> [!NOTE]
-> **LLM-Workflows** (`Blitztext+`, `Blitztext $%&!`, `Blitztext :)`) setzen einen gültigen **OpenAI API-Key** voraus. Ohne diesen Key sind diese Funktionen im Menü und über die Hotkeys deaktiviert bzw. führen zu einer Fehlermeldung.
+> **LLM-Workflows** (`Blitztext+`, `Blitztext $%&!`, `Blitztext :)`) setzen einen gültigen **OpenAI API-Key** voraus. Lege ihn am einfachsten in `~/.config/blitztext-linux/secrets.env` ab, indem du dort die Variable `OPENAI_API_KEY` mit deinem Key als Wert setzt (Zeilenformat `NAME=WERT`). `./run.sh` und der systemd-Service laden diese Datei automatisch. Ohne diesen Key sind diese Funktionen im Menü und über die Hotkeys deaktiviert bzw. führen zu einer Fehlermeldung.
## KI-Workflows
@@ -244,7 +244,7 @@ Zusätzlich zu den Workflows bietet das Tool drei Komfort-Funktionen:
## Konfiguration
-Alles wird lokal und sicher unter `~/.config/blitztext-linux/config.json` gespeichert.
+Alles wird lokal und sicher unter `~/.config/blitztext-linux/config.json` gespeichert. Der OpenAI-Schlüssel wird nicht mehr in dieser Datei abgelegt, sondern aus einer Umgebungsvariable gelesen. Die Konfigurationsdatei lässt sich für erweiterte Prompt- und Workflow-Anpassungen direkt aus den Einstellungen öffnen: **Einstellungen → Allgemein → „Konfigurationsdatei öffnen"**.

@@ -252,7 +252,7 @@ Alles wird lokal und sicher unter `~/.config/blitztext-linux/config.json` gespei
> [!IMPORTANT]
-> Um den OpenAI API-Key zu schützen, wird die Datei automatisch mit restriktiven Dateiberechtigungen (**`0o600` / `chmod 600`**) gespeichert.
+> Die Konfigurationsdatei wird automatisch mit restriktiven Dateiberechtigungen (**`0o600` / `chmod 600`**) gespeichert. Der echte OpenAI-Key liegt stattdessen in `~/.config/blitztext-linux/secrets.env` oder wird als Umgebungsvariable bereitgestellt.
Beispiel-Konfiguration & Felderklärung
@@ -263,7 +263,7 @@ Alles wird lokal und sicher unter `~/.config/blitztext-linux/config.json` gespei
"language": "de",
"backend": "openai-whisper",
"hotkey_mode": "toggle",
- "openai_api_key": "DEIN_KEY",
+ "openai_api_key_env": "OPENAI_API_KEY",
"autopaste": true,
"audio_device": "@DEFAULT_SOURCE@",
"workflows": {
@@ -280,7 +280,8 @@ Alles wird lokal und sicher unter `~/.config/blitztext-linux/config.json` gespei
- **hotkey_mode**:
- `toggle`: Einmal drücken startet, erneutes Drücken beendet.
- `hold`: Aufnahme läuft solange der Hotkey gedrückt wird.
-- **openai_api_key**: OpenAI API-Key.
+- **openai_api_key_env**: Name der Umgebungsvariable für den OpenAI API-Key. Standard: `OPENAI_API_KEY`.
+- Der eigentliche Key liegt nicht in `config.json`, sondern in `~/.config/blitztext-linux/secrets.env` oder einer bereits gesetzten Umgebungsvariable.
- **autopaste**: Fügt per `ydotool` ein.
- **audio_device**: Name der Audioquelle.
- **workflows**: Feintuning von Tonalität, Emojis und dem Dampf-Prompt.
diff --git a/SECURITY.md b/SECURITY.md
index 6a69f95..93c3225 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -28,7 +28,7 @@ Include:
## Security Notes
- The app sends audio and text directly to OpenAI when you use the remote workflows.
-- Your OpenAI API key is stored in `~/.config/blitztext-linux/config.json`, written with restrictive `0o600` permissions.
+- Your OpenAI API key is read from the environment. Put it in `~/.config/blitztext-linux/secrets.env` (chmod `600`) or export the configured environment variable before launch.
- Temporary audio files may exist briefly during processing.
- Auto-paste uses `ydotool` to inject `Ctrl+V` into the focused application.
- Global hotkeys read input from `/dev/input/event*` via `evdev`, which requires membership in the `input` group. On a shared session this means other processes of the same user could read input as well — a deliberate trade-off under Wayland without XDG GlobalShortcuts. Run Blitztext only in environments you trust. Replacing this path with a desktop-native XDG GlobalShortcuts integration is on the roadmap.
diff --git a/app/blitztext_linux.py b/app/blitztext_linux.py
index 5ec1c28..a8f44cf 100644
--- a/app/blitztext_linux.py
+++ b/app/blitztext_linux.py
@@ -14,8 +14,10 @@
from pathlib import Path
from typing import Optional
-from PyQt6.QtCore import QObject, Qt, QThread, QThreadPool, QRunnable, pyqtSignal, pyqtSlot
-from PyQt6.QtGui import QAction, QBrush, QColor, QIcon, QKeySequence, QPainter, QPen, QPixmap
+from PyQt6.QtCore import QObject, Qt, QThread, QThreadPool, QRunnable, QUrl, pyqtSignal, pyqtSlot
+from PyQt6.QtGui import (
+ QAction, QBrush, QColor, QDesktopServices, QIcon, QKeySequence, QPainter, QPen, QPixmap,
+)
from PyQt6.QtWidgets import (
QApplication, QDialog, QVBoxLayout, QHBoxLayout, QTabWidget, QWidget,
QFormLayout, QComboBox, QLineEdit, QCheckBox, QPlainTextEdit,
@@ -194,17 +196,28 @@ def init_ui(self) -> None:
form_llm = QFormLayout(tab_llm)
form_llm.setSpacing(10)
- self.edit_api_key = QLineEdit()
- self.edit_api_key.setText(self.config.openai_api_key)
- self.edit_api_key.setEchoMode(QLineEdit.EchoMode.Password)
- self.edit_api_key.setPlaceholderText("sk-...")
+ self.edit_api_key_env = QLineEdit()
+ self.edit_api_key_env.setText(self.config.openai_api_key_env)
+ self.edit_api_key_env.setPlaceholderText("OPENAI_API_KEY")
+ self.edit_api_key_env.textChanged.connect(lambda *_: self._refresh_api_key_status())
+
+ self.lbl_api_key_status = QLabel()
+ self.lbl_api_key_status.setWordWrap(True)
- api_key_layout = QHBoxLayout()
- api_key_layout.addWidget(self.edit_api_key)
- self.btn_show_key = QPushButton("Anzeigen")
- self.btn_show_key.setCheckable(True)
- self.btn_show_key.clicked.connect(self._toggle_api_key_visibility)
- api_key_layout.addWidget(self.btn_show_key)
+ api_key_layout = QVBoxLayout()
+ api_key_layout.addWidget(self.edit_api_key_env)
+ api_key_layout.addWidget(self.lbl_api_key_status)
+ if self.config.has_legacy_openai_api_key:
+ self.lbl_legacy_api_key_notice = QLabel(
+ "Legacy openai_api_key gefunden. Er wird beim nächsten Speichern entfernt."
+ )
+ self.lbl_legacy_api_key_notice.setWordWrap(True)
+ self.lbl_legacy_api_key_notice.setStyleSheet("color: #b26a00; font-size: 10px;")
+ api_key_layout.addWidget(self.lbl_legacy_api_key_notice)
+ else:
+ self.lbl_legacy_api_key_notice = None
+
+ self._refresh_api_key_status()
self.combo_tone = QComboBox()
self.combo_tone.addItems(["formal", "neutral", "locker"])
@@ -241,8 +254,8 @@ def init_ui(self) -> None:
custom_terms_widget = QWidget()
custom_terms_widget.setLayout(custom_terms_layout)
- form_llm.addRow("OpenAI API-Key:", api_key_layout)
- form_llm.addRow("", create_help_label("Erforderlich für alle KI/LLM-Features (Blitztext+, Dampf ablassen, Emojis)."))
+ form_llm.addRow("OpenAI API-Key-Umgebung:", api_key_layout)
+ form_llm.addRow("", create_help_label("Nur der Name der Umgebungsvariable wird gespeichert. Der Schlüssel selbst wird aus os.environ gelesen."))
form_llm.addRow("Text-Verbesserer Tonfall:", self.combo_tone)
form_llm.addRow("Emoji-Dichte:", self.combo_emoji)
@@ -276,6 +289,13 @@ def init_ui(self) -> None:
form_general.addRow("Verlauf-Größe:", self.spin_history_size)
form_general.addRow("", create_help_label("Maximale Anzahl der im Verlauf gespeicherten Einträge."))
+ self.btn_open_config = QPushButton("📄 Konfigurationsdatei öffnen")
+ self.btn_open_config.clicked.connect(self._open_config_file)
+ form_general.addRow(self.btn_open_config)
+ form_general.addRow("", create_help_label(
+ "Öffnet config.json im Standard-Editor – für erweiterte Prompt- und "
+ "Workflow-Anpassungen, die über die Felder oben hinausgehen."))
+
# Dezente Versionsanzeige ganz unten auf der letzten Einstellungsseite
version_label = QLabel(f"Version {APP_VERSION}")
version_label.setStyleSheet("color: gray; font-size: 9px;")
@@ -292,13 +312,36 @@ def init_ui(self) -> None:
button_box.rejected.connect(self.reject)
layout.addWidget(button_box)
- def _toggle_api_key_visibility(self) -> None:
- if self.btn_show_key.isChecked():
- self.edit_api_key.setEchoMode(QLineEdit.EchoMode.Normal)
- self.btn_show_key.setText("Verbergen")
- else:
- self.edit_api_key.setEchoMode(QLineEdit.EchoMode.Password)
- self.btn_show_key.setText("Anzeigen")
+ def _refresh_api_key_status(self) -> None:
+ env_name = self.edit_api_key_env.text().strip() or self.config.openai_api_key_env
+ env_value = os.environ.get(env_name, "").strip()
+ status = "gesetzt" if env_value else "nicht gesetzt"
+ self.lbl_api_key_status.setText(f"Status: {status} ({env_name})")
+
+ def _open_config_file(self) -> None:
+ """Open the config.json in the desktop's default editor.
+
+ Falls die Datei noch nie gespeichert wurde oder noch ein Legacy-API-Key
+ im Speicher hängt, wird sie zuvor über die bestehende, atomare
+ ``config.save()``-Logik (0o600) angelegt bzw. bereinigt.
+ """
+ try:
+ if (not self.config.config_file.is_file()) or self.config.has_legacy_openai_api_key:
+ self.config.save()
+ opened = QDesktopServices.openUrl(
+ QUrl.fromLocalFile(str(self.config.config_file)))
+ if not opened:
+ QMessageBox.warning(
+ self,
+ "Öffnen fehlgeschlagen",
+ f"Konfigurationsdatei konnte nicht geöffnet werden:\n{self.config.config_file}",
+ )
+ except Exception as e:
+ QMessageBox.critical(
+ self,
+ "Fehler",
+ f"Konfigurationsdatei konnte nicht geöffnet werden: {e}",
+ )
def _collect_custom_terms(self) -> list[str]:
terms: list[str] = []
@@ -338,7 +381,7 @@ def save_settings(self) -> None:
self.config.hotkey_mode = self.combo_hotkey_mode.currentText()
self.config.transcription_hotkey = self.combo_transcription_key.currentText()
- self.config.openai_api_key = self.edit_api_key.text().strip()
+ self.config.openai_api_key_env = self.edit_api_key_env.text().strip()
self.config.text_improver_tone = self.combo_tone.currentText()
self.config.emoji_density = self.combo_emoji.currentText()
self.config.dampf_system_prompt = self.edit_dampf_prompt.toPlainText().strip()
@@ -414,7 +457,7 @@ def run(self) -> None:
self._emit("status_changed", "rewriting")
if not self.llm_service.is_available():
raise LLMServiceError(
- "OpenAI API-Key nicht konfiguriert. Bitte in den Einstellungen eintragen."
+ f"OpenAI API-Key nicht gesetzt. Bitte {self.config.openai_api_key_env} in ~/.config/blitztext-linux/secrets.env setzen."
)
result_text = self.llm_service.rewrite(self.workflow, transcript)
else:
@@ -448,11 +491,12 @@ def __init__(self, app: QApplication) -> None:
self.config = Config.load()
self.llm_service = LLMService(
- api_key=self.config.openai_api_key or "placeholder",
+ api_key=self.config.resolve_openai_api_key(),
tone=self.config.text_improver_tone,
emoji_density=self.config.emoji_density,
dampf_system_prompt=self.config.dampf_system_prompt,
custom_terms=self.config.custom_terms,
+ api_key_env=self.config.openai_api_key_env,
)
self.audio_recorder = AudioRecorder()
self.paste_service = PasteService(autopaste=self.config.autopaste)
@@ -622,11 +666,12 @@ def show_settings_dialog(self) -> None:
if dialog.exec() == QDialog.DialogCode.Accepted:
# Update LLM Service parameters from saved configuration
self.llm_service = LLMService(
- api_key=self.config.openai_api_key or "placeholder",
+ api_key=self.config.resolve_openai_api_key(),
tone=self.config.text_improver_tone,
emoji_density=self.config.emoji_density,
dampf_system_prompt=self.config.dampf_system_prompt,
custom_terms=self.config.custom_terms,
+ api_key_env=self.config.openai_api_key_env,
)
self.update_menu_availability()
diff --git a/app/config.py b/app/config.py
index 157a0d8..1828b78 100644
--- a/app/config.py
+++ b/app/config.py
@@ -1,13 +1,16 @@
"""Config for BlitztextLinux.
Pfad: ~/.config/blitztext-linux/config.json
-Berechtigungen: 0o600 (API-Key liegt im File).
+Berechtigungen: 0o600. Der eigentliche OpenAI-Key wird nur noch zur Laufzeit
+über eine Umgebungsvariable gelesen.
"""
from __future__ import annotations
+import copy
import json
import logging
import os
+import re
from pathlib import Path
from typing import Any
@@ -19,7 +22,7 @@
"backend": "openai-whisper",
"hotkey_mode": "hold",
"transcription_hotkey": "KEY_LEFTALT",
- "openai_api_key": "",
+ "openai_api_key_env": "OPENAI_API_KEY",
"autopaste": True,
"audio_device": "@DEFAULT_SOURCE@",
"notes_folder": str(Path.home() / "Blitztext-Notizen"),
@@ -44,6 +47,7 @@
"KEY_F13", "KEY_F14", "KEY_F15", "KEY_F16",
"KEY_SCROLLLOCK", "KEY_PAUSE", "KEY_INSERT", "KEY_CAPSLOCK",
}
+ENV_VAR_NAME_RE = re.compile(r"^[A-Z_][A-Z0-9_]*$")
class ConfigError(Exception):
@@ -59,13 +63,14 @@ def __init__(self, config_dir: Path | None = None) -> None:
else:
self.config_dir = Path(config_dir)
self.config_file = self.config_dir / "config.json"
-
- # Load data
+ self._legacy_openai_api_key_present = False
+ self._legacy_openai_api_key_value = ""
+
self._data = self._load()
self._validate_and_sanitize()
@classmethod
- def load(cls, path: Path | None = None) -> BlitztextConfig:
+ def load(cls, path: Path | None = None) -> "BlitztextConfig":
if path is not None:
config_dir = Path(path).parent
else:
@@ -81,9 +86,14 @@ def _load(self) -> dict[str, Any]:
data = json.loads(raw)
if not isinstance(data, dict):
return _deep_merge(DEFAULTS, {})
- return _deep_merge(DEFAULTS, data)
- except Exception as exc:
- # Avoid logging any loaded data to prevent leaking API keys
+
+ legacy_api_key = data.get("openai_api_key")
+ self._legacy_openai_api_key_present = "openai_api_key" in data
+ self._legacy_openai_api_key_value = legacy_api_key.strip() if isinstance(legacy_api_key, str) else ""
+ sanitized = dict(data)
+ sanitized.pop("openai_api_key", None)
+ return _deep_merge(DEFAULTS, sanitized)
+ except Exception:
logger.warning("Config could not be loaded, using defaults")
return _deep_merge(DEFAULTS, {})
@@ -94,21 +104,40 @@ def save(self) -> None:
tmp = self.config_file.with_suffix(".json.tmp")
flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC
fd = os.open(str(tmp), flags, 0o600)
- with open(fd, "w", encoding="utf-8") as f:
- json.dump(self._data, f, indent=2, ensure_ascii=False)
+ payload = _deep_copy_without_legacy_key(self._data)
+ with os.fdopen(fd, "w", encoding="utf-8") as f:
+ json.dump(payload, f, indent=2, ensure_ascii=False)
f.write("\n")
tmp.replace(self.config_file)
self.config_file.chmod(0o600)
+ self._data = payload
+ self._legacy_openai_api_key_present = False
+ self._legacy_openai_api_key_value = ""
except OSError as exc:
raise ConfigError(f"Config konnte nicht gespeichert werden: {exc}") from exc
def has_api_key(self) -> bool:
- key = self._data.get("openai_api_key", "")
- return bool(key and key.strip())
+ return bool(self.resolve_openai_api_key())
+
+ @property
+ def openai_api_key_env(self) -> str:
+ value = self._data.get("openai_api_key_env", DEFAULTS["openai_api_key_env"])
+ return _normalize_env_var_name(value)
+
+ @openai_api_key_env.setter
+ def openai_api_key_env(self, value: str) -> None:
+ self._data["openai_api_key_env"] = _normalize_env_var_name(value)
- # ------------------------------------------------------------------
- # Properties
- # ------------------------------------------------------------------
+ def resolve_openai_api_key(self) -> str:
+ env_name = self.openai_api_key_env
+ env_value = os.environ.get(env_name, "").strip()
+ if env_value:
+ return env_value
+ return self._legacy_openai_api_key_value
+
+ @property
+ def has_legacy_openai_api_key(self) -> bool:
+ return self._legacy_openai_api_key_present
@property
def model(self) -> str:
@@ -158,14 +187,6 @@ def transcription_hotkey(self, value: str) -> None:
raise ValueError(f"Ungueltige Hotkey-Taste: {value!r}. Gueltig: {sorted(VALID_HOTKEY_KEYS)}")
self._data["transcription_hotkey"] = value
- @property
- def openai_api_key(self) -> str:
- return self._data["openai_api_key"]
-
- @openai_api_key.setter
- def openai_api_key(self, value: str) -> None:
- self._data["openai_api_key"] = value
-
@property
def autopaste(self) -> bool:
return bool(self._data["autopaste"])
@@ -218,7 +239,6 @@ def tts_speed(self, value: float) -> None:
def workflows(self) -> dict[str, Any]:
return self._data["workflows"]
- # Convenience workflow sub-properties for the GUI
@property
def text_improver_tone(self) -> str:
return self._data["workflows"]["text_improver_tone"]
@@ -256,7 +276,6 @@ def custom_terms(self, value: list[str]) -> None:
self._data["workflows"]["custom_terms"] = _sanitize_terms(value)
def as_dict(self) -> dict[str, Any]:
- import copy
return copy.deepcopy(self._data)
def _validate_and_sanitize(self) -> None:
@@ -269,7 +288,6 @@ def _validate_and_sanitize(self) -> None:
if self._data.get("transcription_hotkey") not in VALID_HOTKEY_KEYS:
self._data["transcription_hotkey"] = "KEY_LEFTALT"
- # History/TTS sanitize
try:
self._data["history_size"] = max(10, min(100, int(self._data.get("history_size", 50))))
except (TypeError, ValueError):
@@ -283,13 +301,15 @@ def _validate_and_sanitize(self) -> None:
if not isinstance(self._data.get("tts_voice", ""), str):
self._data["tts_voice"] = ""
- # Ensure workflows dict exists
+ self._data["openai_api_key_env"] = _normalize_env_var_name(
+ self._data.get("openai_api_key_env", DEFAULTS["openai_api_key_env"])
+ )
+ self._data.pop("openai_api_key", None)
+
if "workflows" not in self._data or not isinstance(self._data["workflows"], dict):
self._data["workflows"] = {}
-
+
wf = self._data["workflows"]
-
- # Merge sub-defaults
for k, v in DEFAULTS["workflows"].items():
if k not in wf:
if isinstance(v, dict):
@@ -306,10 +326,18 @@ def _validate_and_sanitize(self) -> None:
wf["custom_terms"] = _sanitize_terms(wf.get("custom_terms"))
+def _normalize_env_var_name(value: Any) -> str:
+ if not isinstance(value, str):
+ return DEFAULTS["openai_api_key_env"]
+ candidate = value.strip().upper()
+ if not candidate or not ENV_VAR_NAME_RE.fullmatch(candidate):
+ return DEFAULTS["openai_api_key_env"]
+ return candidate
+
+
def _sanitize_terms(values: Any) -> list[str]:
if not isinstance(values, list):
return []
-
result: list[str] = []
seen: set[str] = set()
for value in values:
@@ -323,9 +351,13 @@ def _sanitize_terms(values: Any) -> list[str]:
return result
-def _deep_merge(base: dict, override: dict) -> dict:
- import copy
+def _deep_copy_without_legacy_key(data: dict[str, Any]) -> dict[str, Any]:
+ payload = copy.deepcopy(data)
+ payload.pop("openai_api_key", None)
+ return payload
+
+def _deep_merge(base: dict, override: dict) -> dict:
result = copy.deepcopy(base)
for key, val in override.items():
if key in result and isinstance(result[key], dict) and isinstance(val, dict):
@@ -336,4 +368,3 @@ def _deep_merge(base: dict, override: dict) -> dict:
Config = BlitztextConfig
-
diff --git a/app/llm_service.py b/app/llm_service.py
index ac63af2..58deb0a 100644
--- a/app/llm_service.py
+++ b/app/llm_service.py
@@ -3,6 +3,7 @@
import logging
from typing import Any, Optional
+
from app.workflows import WorkflowType
logger = logging.getLogger("blitztext.llm_service")
@@ -10,7 +11,6 @@
LLM_WORKFLOWS = {WorkflowType.TEXT_IMPROVER, WorkflowType.DAMPF_ABLASSEN, WorkflowType.EMOJI_TEXT}
MODEL = "gpt-4o-mini"
-# --- System prompts ---
_DAMPF_SYSTEM = (
"Du erhältst ein emotional gesprochenes Transkript. Erkenne zuerst das eigentliche "
"Ziel, Anliegen und den wahren Frust der Person. Formuliere daraus eine klare, "
@@ -50,42 +50,53 @@ def __init__(
emoji_density: str = "mittel",
dampf_system_prompt: str = "",
custom_terms: Optional[list[str]] = None,
+ api_key_env: str = "OPENAI_API_KEY",
) -> None:
- """
- Args:
- api_key: OpenAI API key. Raises ValueError if empty/falsy.
- client: Pre-built client (for testing/mocking).
- tone: Text-improver tone: 'formal' | 'neutral' | 'locker'.
- emoji_density: 'wenig' | 'mittel' | 'viel'.
- dampf_system_prompt: Override for dampf_ablassen system prompt.
- custom_terms: Globale Liste von Eigennamen/Fachbegriffen.
- """
- if not api_key:
- raise ValueError("api_key must not be empty")
-
- self.api_key = api_key
+ self.api_key = api_key or ""
+ self.api_key_env = api_key_env or "OPENAI_API_KEY"
self.tone = tone
self.emoji_density = emoji_density
self.dampf_system_prompt = dampf_system_prompt
self.custom_terms = self._sanitize_terms(custom_terms)
self._openai_installed = True
+ self._client_is_fallback_mock = False
if client is not None:
self.client = client
else:
try:
import openai
- self.client = openai.OpenAI(api_key=api_key)
except ImportError:
self._openai_installed = False
+ self._client_is_fallback_mock = True
from unittest.mock import MagicMock
+
self.client = MagicMock()
+ else:
+ if self.api_key and self.api_key.strip():
+ self.client = openai.OpenAI(api_key=self.api_key)
+ else:
+ # Ohne API-Key keinen echten Client bauen: Neuere openai-Versionen
+ # werfen bereits im Konstruktor bei leerem Key. Der eigentliche
+ # Fehler wird zur Aufrufzeit über _check_openai() klar gemeldet,
+ # damit die App auch ohne gesetzten Key startet.
+ from unittest.mock import MagicMock
+
+ self.client = MagicMock()
def is_available(self) -> bool:
return bool(self.api_key and self.api_key.strip())
+ def _missing_key_message(self) -> str:
+ return (
+ f"OpenAI API-Key nicht gesetzt. Bitte die Umgebungsvariable "
+ f"{self.api_key_env} in ~/.config/blitztext-linux/secrets.env setzen."
+ )
+
def _check_openai(self) -> None:
- if not self._openai_installed and type(self.client).__name__ != 'MagicMock':
+ if not self.is_available():
+ raise LLMServiceError(self._missing_key_message())
+ if not self._openai_installed and self._client_is_fallback_mock:
raise LLMServiceError("openai-Paket nicht installiert. Bitte: pip install openai")
@staticmethod
@@ -117,9 +128,9 @@ def dampf_ablassen(self, transcript: str, custom_system_prompt: str = "") -> str
self._check_openai()
if not transcript or not transcript.strip():
raise ValueError("transcript must not be empty")
-
+
system = (custom_system_prompt.strip() or self.dampf_system_prompt.strip() or _DAMPF_SYSTEM) + self._custom_terms_instruction()
-
+
response = self.client.chat.completions.create(
model=MODEL,
messages=[
@@ -183,18 +194,18 @@ def rewrite(self, workflow: WorkflowType, transcript: str) -> str:
Raises:
LLMServiceError: If key is missing, package missing, or API error.
"""
+ self._check_openai()
if workflow not in LLM_WORKFLOWS:
raise LLMServiceError(f"rewrite() only allowed for LLM workflows, got {workflow!r}")
try:
if workflow == WorkflowType.DAMPF_ABLASSEN:
return self.dampf_ablassen(transcript, custom_system_prompt=self.dampf_system_prompt)
- elif workflow == WorkflowType.TEXT_IMPROVER:
+ if workflow == WorkflowType.TEXT_IMPROVER:
return self.text_improver(transcript, tone=self.tone)
- elif workflow == WorkflowType.EMOJI_TEXT:
+ if workflow == WorkflowType.EMOJI_TEXT:
return self.emoji_text(transcript, density=self.emoji_density)
- else:
- raise LLMServiceError(f"Unsupported workflow: {workflow}")
+ raise LLMServiceError(f"Unsupported workflow: {workflow}")
except Exception as exc:
if isinstance(exc, LLMServiceError):
raise
diff --git a/run.sh b/run.sh
index e5a816d..8c62d77 100755
--- a/run.sh
+++ b/run.sh
@@ -5,6 +5,7 @@ set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
VENV_PYTHON="${SCRIPT_DIR}/.venv/bin/python"
LOCKFILE="${XDG_RUNTIME_DIR:-/tmp}/blitztext_linux.pid"
+SECRETS_FILE="${HOME}/.config/blitztext-linux/secrets.env"
# --- Single-Instance-Guard ---
if [[ -f "${LOCKFILE}" ]]; then
@@ -20,6 +21,14 @@ fi
echo $$ > "${LOCKFILE}"
trap 'rm -f "${LOCKFILE}"' EXIT INT TERM
+# --- secrets.env (optional) ---
+if [[ -f "${SECRETS_FILE}" ]]; then
+ set -a
+ # shellcheck disable=SC1090
+ source "${SECRETS_FILE}"
+ set +a
+fi
+
# --- venv-Prüfung ---
if [[ ! -x "${VENV_PYTHON}" ]]; then
echo "FEHLER: .venv nicht gefunden. Bitte zuerst 'bash scripts/install.sh' ausführen." >&2
diff --git a/systemd/blitztext-linux.service b/systemd/blitztext-linux.service
index fce823e..b642137 100644
--- a/systemd/blitztext-linux.service
+++ b/systemd/blitztext-linux.service
@@ -5,6 +5,10 @@
# Das Installations-Skript ersetzt den Platzhalter %BLITZTEXT_DIR% automatisch
# durch den absoluten Pfad des BlitztextLinux/-Verzeichnisses.
#
+# Manuelle Laufzeit-Konfiguration:
+# Optionale Secrets liegen in ~/.config/blitztext-linux/secrets.env.
+# Datei mit chmod 600 schützen; sie wird per EnvironmentFile geladen.
+#
# Manueller Start (nach install.sh und erfolgreichem Test):
# systemctl --user start blitztext-linux
#
@@ -26,6 +30,7 @@ Type=simple
# z. B. /home/benutzer/git/blitztext-linux
WorkingDirectory=%BLITZTEXT_DIR%
+EnvironmentFile=-%h/.config/blitztext-linux/secrets.env
ExecStart=%BLITZTEXT_DIR%/.venv/bin/python app/blitztext_linux.py
Restart=on-failure
diff --git a/tests/test_config.py b/tests/test_config.py
index cd2850d..7475247 100644
--- a/tests/test_config.py
+++ b/tests/test_config.py
@@ -1,161 +1,146 @@
-"""Tests für BlitztextConfig — Lesen, Schreiben, Defaults, API-Key-Handling."""
+"""Tests für BlitztextConfig — Defaults, Persistenz, Legacy-Migration."""
+from __future__ import annotations
+
import json
-import os
-import pytest
from pathlib import Path
+
+import pytest
+
from app.config import BlitztextConfig
@pytest.fixture
-def config_dir(tmp_path):
+def config_dir(tmp_path) -> Path:
return tmp_path / ".config" / "blitztext-linux"
@pytest.fixture
-def config(config_dir):
+def config(config_dir) -> BlitztextConfig:
return BlitztextConfig(config_dir=config_dir)
class TestDefaults:
- def test_default_model(self, config):
+ def test_default_model_is_base(self, config):
assert config.model == "base"
- def test_default_language(self, config):
+ def test_default_language_is_de(self, config):
assert config.language == "de"
- def test_default_backend(self, config):
+ def test_default_backend_is_openai_whisper(self, config):
assert config.backend == "openai-whisper"
def test_default_hotkey_mode(self, config):
assert config.hotkey_mode in ("toggle", "hold")
- def test_default_api_key_empty(self, config):
- assert config.openai_api_key == ""
+ def test_default_api_key_env(self, config):
+ assert config.openai_api_key_env == "OPENAI_API_KEY"
+ assert config.resolve_openai_api_key() == ""
def test_default_autopaste(self, config):
assert config.autopaste is True
class TestPersistence:
- def test_save_creates_file(self, config, config_dir):
- config.save()
- assert (config_dir / "config.json").exists()
-
def test_save_load_roundtrip(self, config, config_dir):
config.model = "small"
config.language = "en"
+ config.openai_api_key_env = "my_openai_key"
config.save()
+
loaded = BlitztextConfig(config_dir=config_dir)
assert loaded.model == "small"
assert loaded.language == "en"
+ assert loaded.openai_api_key_env == "MY_OPENAI_KEY"
def test_config_file_permissions(self, config, config_dir):
config.save()
- cfg_file = config_dir / "config.json"
- mode = oct(cfg_file.stat().st_mode)[-3:]
- assert mode == "600", f"Expected 600, got {mode}"
+ mode = (config_dir / "config.json").stat().st_mode & 0o777
+ assert mode == 0o600
def test_partial_config_fills_defaults(self, config_dir):
config_dir.mkdir(parents=True, exist_ok=True)
- partial = {"model": "tiny"}
- (config_dir / "config.json").write_text(json.dumps(partial))
+ (config_dir / "config.json").write_text(json.dumps({"model": "tiny"}), encoding="utf-8")
+
loaded = BlitztextConfig(config_dir=config_dir)
assert loaded.model == "tiny"
- assert loaded.language == "de" # default
+ assert loaded.language == "de"
+ assert loaded.openai_api_key_env == "OPENAI_API_KEY"
class TestWorkflowConfig:
- def test_text_improver_tone_default(self, config):
- assert config.workflows["text_improver_tone"] == "neutral"
-
- def test_emoji_density_default(self, config):
- assert config.workflows["emoji_density"] == "mittel"
-
- def test_custom_dampf_prompt_default_empty(self, config):
- assert config.workflows["dampf_system_prompt"] == ""
-
- def test_custom_terms_default_empty_list(self, config):
- assert config.custom_terms == []
-
- def test_custom_terms_persist_after_save_reload(self, config, config_dir):
- config.custom_terms = ["Blitztext", "OpenRouter", "Leopoldshöhe"]
- config.save()
- loaded = BlitztextConfig(config_dir=config_dir)
- assert loaded.custom_terms == ["Blitztext", "OpenRouter", "Leopoldshöhe"]
-
- def test_custom_terms_sanitized_on_setter(self, config):
- config.custom_terms = [" Blitztext ", "", " ", "OpenRouter", "Blitztext", 5]
- assert config.custom_terms == ["Blitztext", "OpenRouter"]
-
- def test_partial_workflow_config_without_custom_terms_remains_compatible(self, config_dir):
+ def test_workflows_dict_created_when_missing(self, config_dir):
config_dir.mkdir(parents=True, exist_ok=True)
- partial = {
- "workflows": {
- "text_improver_tone": "formal",
- "emoji_density": "viel",
- }
- }
- (config_dir / "config.json").write_text(json.dumps(partial), encoding="utf-8")
+ (config_dir / "config.json").write_text(json.dumps({"model": "base"}), encoding="utf-8")
+
loaded = BlitztextConfig(config_dir=config_dir)
+ assert isinstance(loaded.workflows, dict)
+ assert loaded.text_improver_tone == "neutral"
+ assert loaded.emoji_density == "mittel"
assert loaded.custom_terms == []
- def test_custom_terms_sanitized_on_load(self, config_dir):
- config_dir.mkdir(parents=True, exist_ok=True)
- payload = {
- "workflows": {
- "custom_terms": [" Blitztext ", "", "OpenRouter", "Blitztext", None, 7, " "]
- }
- }
- (config_dir / "config.json").write_text(json.dumps(payload), encoding="utf-8")
- loaded = BlitztextConfig(config_dir=config_dir)
- assert loaded.custom_terms == ["Blitztext", "OpenRouter"]
+ def test_custom_terms_are_sanitized(self, config):
+ config.custom_terms = [" Blitztext ", "Blitztext", "", "OpenRouter", 123]
+ assert config.custom_terms == ["Blitztext", "OpenRouter"]
class TestTranscriptionHotkey:
- def test_default_transcription_hotkey(self, config):
- assert config.transcription_hotkey == "KEY_LEFTALT"
-
- def test_set_valid_transcription_hotkey(self, config):
+ def test_valid_hotkey_is_accepted(self, config):
config.transcription_hotkey = "KEY_F13"
assert config.transcription_hotkey == "KEY_F13"
- def test_invalid_transcription_hotkey_raises(self, config):
+ def test_invalid_hotkey_is_rejected(self, config):
with pytest.raises(ValueError):
- config.transcription_hotkey = "KEY_SPACE"
-
- def test_transcription_hotkey_persists(self, config, config_dir):
- config.transcription_hotkey = "KEY_RIGHTCTRL"
- config.save()
- loaded = BlitztextConfig(config_dir=config_dir)
- assert loaded.transcription_hotkey == "KEY_RIGHTCTRL"
-
- def test_hotkey_mode_toggle_persists(self, config, config_dir):
- config.hotkey_mode = "toggle"
- config.save()
- loaded = BlitztextConfig(config_dir=config_dir)
- assert loaded.hotkey_mode == "toggle"
-
- def test_hotkey_mode_hold_persists(self, config, config_dir):
- config.hotkey_mode = "hold"
- config.save()
- loaded = BlitztextConfig(config_dir=config_dir)
- assert loaded.hotkey_mode == "hold"
+ config.transcription_hotkey = "KEY_A"
class TestAPIKeyHandling:
- def test_has_api_key_false_when_empty(self, config):
- config.openai_api_key = ""
+ def test_has_api_key_false_when_env_missing(self, config, monkeypatch):
+ monkeypatch.delenv(config.openai_api_key_env, raising=False)
assert config.has_api_key() is False
- def test_has_api_key_true_when_set(self, config):
- config.openai_api_key = "sk-abc123"
+ def test_has_api_key_true_when_env_set(self, config, monkeypatch):
+ monkeypatch.setenv(config.openai_api_key_env, "dummy-openai-key")
assert config.has_api_key() is True
+ assert config.resolve_openai_api_key() == "dummy-openai-key"
+
+ def test_invalid_env_var_name_is_normalized_to_default(self, config):
+ config.openai_api_key_env = " invalid-name "
+ assert config.openai_api_key_env == "OPENAI_API_KEY"
- def test_api_key_not_logged(self, config, capsys, config_dir):
- """API-Key darf nie in stdout/stderr landen."""
- config.openai_api_key = "sk-secret-do-not-log"
+ def test_legacy_api_key_is_removed_on_load_and_save(self, config_dir):
+ config_dir.mkdir(parents=True, exist_ok=True)
+ (config_dir / "config.json").write_text(
+ json.dumps({"openai_api_key": "legacy-placeholder", "model": "base"}),
+ encoding="utf-8",
+ )
+
+ loaded = BlitztextConfig(config_dir=config_dir)
+ assert loaded.has_legacy_openai_api_key is True
+ assert loaded.resolve_openai_api_key() == "legacy-placeholder"
+ assert loaded.has_api_key() is True
+
+ loaded.save()
+ saved = json.loads((config_dir / "config.json").read_text(encoding="utf-8"))
+ assert "openai_api_key" not in saved
+ assert saved["openai_api_key_env"] == "OPENAI_API_KEY"
+
+ def test_save_never_writes_openai_api_key(self, config_dir):
+ config = BlitztextConfig(config_dir=config_dir)
+ config.openai_api_key_env = "CUSTOM_OPENAI_KEY"
config.save()
+
+ saved = json.loads((config_dir / "config.json").read_text(encoding="utf-8"))
+ assert "openai_api_key" not in saved
+ assert saved["openai_api_key_env"] == "CUSTOM_OPENAI_KEY"
+
+
+ def test_env_value_wins_over_legacy_fallback(self, config_dir, monkeypatch):
+ config_dir.mkdir(parents=True, exist_ok=True)
+ (config_dir / "config.json").write_text(
+ json.dumps({"openai_api_key": "legacy-placeholder"}),
+ encoding="utf-8",
+ )
+
+ monkeypatch.setenv("OPENAI_API_KEY", "env-placeholder")
loaded = BlitztextConfig(config_dir=config_dir)
- captured = capsys.readouterr()
- assert "sk-secret-do-not-log" not in captured.out
- assert "sk-secret-do-not-log" not in captured.err
+ assert loaded.resolve_openai_api_key() == "env-placeholder"
diff --git a/tests/test_llm_service.py b/tests/test_llm_service.py
index aa1db3a..c3c50ef 100644
--- a/tests/test_llm_service.py
+++ b/tests/test_llm_service.py
@@ -1,143 +1,113 @@
-"""Tests für LLMService — OpenAI-Calls werden vollständig gemockt."""
-import pytest
+"""Tests für LLMService — ohne echte Secrets oder Netzwerkzugriffe."""
+from __future__ import annotations
+
+from types import SimpleNamespace
from unittest.mock import MagicMock, patch
-from app.llm_service import LLMService
+
+import pytest
+
+from app.llm_service import LLMService, LLMServiceError
+from app.workflows import WorkflowType
-API_KEY = "***"
+DUMMY_API_KEY = "dummy-openai-key"
RAW_TRANSCRIPT = "Ich bin total genervt von diesem Projekt und alles ist kaputt!"
CUSTOM_TERMS = ["Blitztext", "OpenRouter", "Leopoldshöhe"]
@pytest.fixture
-def service():
- return LLMService(api_key=API_KEY)
+def mock_client():
+ client = MagicMock()
+ response = SimpleNamespace(choices=[SimpleNamespace(message=SimpleNamespace(content="OK"))])
+ client.chat.completions.create.return_value = response
+ return client
+
+
+@pytest.fixture
+def service(mock_client):
+ return LLMService(api_key=DUMMY_API_KEY, client=mock_client)
class TestLLMServiceInit:
- def test_no_api_key_raises(self):
- with pytest.raises(ValueError, match="api_key"):
- LLMService(api_key="")
+ def test_empty_api_key_is_not_available(self, mock_client):
+ service = LLMService(api_key="", client=mock_client, api_key_env="CUSTOM_OPENAI_KEY")
+ assert service.is_available() is False
+
+ def test_missing_key_message_mentions_env_name_without_value(self, mock_client):
+ service = LLMService(api_key="", client=mock_client, api_key_env="CUSTOM_OPENAI_KEY")
- def test_api_key_stored(self, service):
- assert service.api_key == API_KEY
+ with pytest.raises(LLMServiceError) as exc:
+ service.rewrite(WorkflowType.TEXT_IMPROVER, "test")
- def test_custom_terms_are_stored(self):
- service = LLMService(api_key=API_KEY, custom_terms=CUSTOM_TERMS)
+ message = str(exc.value)
+ assert "CUSTOM_OPENAI_KEY" in message
+ assert DUMMY_API_KEY not in message
+ assert "sk-" not in message
+
+ def test_custom_terms_are_stored(self, mock_client):
+ service = LLMService(api_key=DUMMY_API_KEY, client=mock_client, custom_terms=CUSTOM_TERMS)
assert service.custom_terms == CUSTOM_TERMS
class TestDampfAblassen:
def test_returns_string(self, service):
- mock_response = MagicMock()
- mock_response.choices[0].message.content = "Ich hätte gerne Feedback zum Projekt."
- with patch.object(service.client.chat.completions, "create", return_value=mock_response):
- result = service.dampf_ablassen(RAW_TRANSCRIPT)
+ result = service.dampf_ablassen(RAW_TRANSCRIPT)
assert isinstance(result, str)
- assert len(result) > 0
-
- def test_passes_transcript_in_user_message(self, service):
- mock_response = MagicMock()
- mock_response.choices[0].message.content = "OK"
- with patch.object(service.client.chat.completions, "create", return_value=mock_response) as mock_create:
- service.dampf_ablassen(RAW_TRANSCRIPT)
- call_kwargs = mock_create.call_args
- messages = call_kwargs.kwargs.get("messages") or call_kwargs.args[0]
+ assert result == "OK"
+
+ def test_passes_transcript_in_user_message(self, service, mock_client):
+ service.dampf_ablassen(RAW_TRANSCRIPT)
+ messages = mock_client.chat.completions.create.call_args.kwargs["messages"]
user_messages = [m for m in messages if m["role"] == "user"]
assert any(RAW_TRANSCRIPT in m["content"] for m in user_messages)
- def test_system_prompt_present(self, service):
- mock_response = MagicMock()
- mock_response.choices[0].message.content = "OK"
- with patch.object(service.client.chat.completions, "create", return_value=mock_response) as mock_create:
- service.dampf_ablassen(RAW_TRANSCRIPT)
- messages = mock_create.call_args.kwargs.get("messages") or mock_create.call_args.args[0]
- system_messages = [m for m in messages if m["role"] == "system"]
- assert len(system_messages) >= 1
-
- def test_system_prompt_contains_custom_terms_instruction(self):
- service = LLMService(api_key=API_KEY, custom_terms=CUSTOM_TERMS)
- mock_response = MagicMock()
- mock_response.choices[0].message.content = "OK"
- with patch.object(service.client.chat.completions, "create", return_value=mock_response) as mock_create:
- service.dampf_ablassen(RAW_TRANSCRIPT)
- messages = mock_create.call_args.kwargs.get("messages") or mock_create.call_args.args[0]
+ def test_system_prompt_contains_custom_terms_instruction(self, mock_client):
+ service = LLMService(api_key=DUMMY_API_KEY, client=mock_client, custom_terms=CUSTOM_TERMS)
+ service.dampf_ablassen(RAW_TRANSCRIPT)
+ messages = mock_client.chat.completions.create.call_args.kwargs["messages"]
system_message = next(m["content"] for m in messages if m["role"] == "system")
assert "muessen exakt so geschrieben werden" in system_message
assert ", ".join(CUSTOM_TERMS) in system_message
class TestTextImprover:
- def test_neutral_tone(self, service):
- mock_response = MagicMock()
- mock_response.choices[0].message.content = "Sauberer Text."
- with patch.object(service.client.chat.completions, "create", return_value=mock_response):
- result = service.text_improver("roher text", tone="neutral")
- assert isinstance(result, str)
-
def test_invalid_tone_raises(self, service):
with pytest.raises(ValueError, match="tone"):
service.text_improver("text", tone="aggressiv")
- def test_custom_prompt_used(self, service):
- mock_response = MagicMock()
- mock_response.choices[0].message.content = "OK"
+ def test_custom_prompt_used(self, service, mock_client):
custom = "Mein eigener Prompt:"
- with patch.object(service.client.chat.completions, "create", return_value=mock_response) as mock_create:
- service.text_improver("text", tone="neutral", custom_prompt=custom)
- messages = mock_create.call_args.kwargs.get("messages") or mock_create.call_args.args[0]
- all_content = " ".join(m["content"] for m in messages)
- assert custom in all_content
-
- def test_prompt_contains_custom_terms_instruction(self):
- service = LLMService(api_key=API_KEY, custom_terms=CUSTOM_TERMS)
- mock_response = MagicMock()
- mock_response.choices[0].message.content = "OK"
- with patch.object(service.client.chat.completions, "create", return_value=mock_response) as mock_create:
- service.text_improver("text", tone="neutral")
- messages = mock_create.call_args.kwargs.get("messages") or mock_create.call_args.args[0]
- system_message = next(m["content"] for m in messages if m["role"] == "system")
- assert "muessen exakt so geschrieben werden" in system_message
- assert ", ".join(CUSTOM_TERMS) in system_message
-
- def test_prompt_without_custom_terms_has_no_extra_instruction(self, service):
- mock_response = MagicMock()
- mock_response.choices[0].message.content = "OK"
- with patch.object(service.client.chat.completions, "create", return_value=mock_response) as mock_create:
- service.text_improver("text", tone="neutral")
- messages = mock_create.call_args.kwargs.get("messages") or mock_create.call_args.args[0]
- system_message = next(m["content"] for m in messages if m["role"] == "system")
- assert "muessen exakt so geschrieben werden" not in system_message
+ service.text_improver("text", tone="neutral", custom_prompt=custom)
+ messages = mock_client.chat.completions.create.call_args.kwargs["messages"]
+ assert custom in " ".join(m["content"] for m in messages)
class TestEmojiText:
@pytest.mark.parametrize("density", ["wenig", "mittel", "viel"])
def test_valid_densities(self, service, density):
- mock_response = MagicMock()
- mock_response.choices[0].message.content = "Text mit Emojis 🎉"
- with patch.object(service.client.chat.completions, "create", return_value=mock_response):
- result = service.emoji_text("Hallo Welt", density=density)
- assert isinstance(result, str)
+ result = service.emoji_text("Hallo Welt", density=density)
+ assert result == "OK"
def test_invalid_density_raises(self, service):
with pytest.raises(ValueError, match="density"):
service.emoji_text("text", density="extrem")
- def test_prompt_contains_custom_terms_instruction(self):
- service = LLMService(api_key=API_KEY, custom_terms=CUSTOM_TERMS)
- mock_response = MagicMock()
- mock_response.choices[0].message.content = "Text mit Emojis 🎉"
- with patch.object(service.client.chat.completions, "create", return_value=mock_response) as mock_create:
- service.emoji_text("Hallo Welt", density="mittel")
- messages = mock_create.call_args.kwargs.get("messages") or mock_create.call_args.args[0]
- system_message = next(m["content"] for m in messages if m["role"] == "system")
- assert "muessen exakt so geschrieben werden" in system_message
- assert ", ".join(CUSTOM_TERMS) in system_message
+class TestRewrite:
+ def test_rewrite_routes_to_text_improver(self, service):
+ with patch.object(service, "text_improver", return_value="verbessert") as patched:
+ result = service.rewrite(WorkflowType.TEXT_IMPROVER, "roh")
+ patched.assert_called_once_with("roh", tone=service.tone)
+ assert result == "verbessert"
+
+ def test_openai_error_is_wrapped(self, service):
+ with patch.object(service, "dampf_ablassen", side_effect=RuntimeError("API Error")):
+ with pytest.raises(LLMServiceError, match="OpenAI API-Fehler: API Error"):
+ service.rewrite(WorkflowType.DAMPF_ABLASSEN, RAW_TRANSCRIPT)
+
+ def test_missing_openai_package_raises_clear_error(self, service):
+ service._openai_installed = False
+ service._client_is_fallback_mock = True
-class TestAPIError:
- def test_openai_error_propagates(self, service):
- """API-Fehler sollen nicht still geschluckt werden."""
- with patch.object(service.client.chat.completions, "create", side_effect=Exception("API Error")):
- with pytest.raises(Exception, match="API Error"):
- service.dampf_ablassen(RAW_TRANSCRIPT)
+ with pytest.raises(LLMServiceError, match="openai-Paket nicht installiert"):
+ service.rewrite(WorkflowType.TEXT_IMPROVER, "test")
diff --git a/tests/test_settings_dialog.py b/tests/test_settings_dialog.py
new file mode 100644
index 0000000..e86c301
--- /dev/null
+++ b/tests/test_settings_dialog.py
@@ -0,0 +1,120 @@
+"""Tests für SettingsDialog-Helfer ohne echten Editor oder GUI-Leaks."""
+from __future__ import annotations
+
+import json
+from types import SimpleNamespace
+from unittest.mock import patch
+
+from app.blitztext_linux import SettingsDialog
+from app.config import BlitztextConfig
+
+
+def _fake_self(config_dir):
+ return SimpleNamespace(config=BlitztextConfig(config_dir=config_dir))
+
+
+def test_open_config_creates_missing_file(tmp_path):
+ fake = _fake_self(tmp_path / ".config" / "blitztext-linux")
+ assert not fake.config.config_file.is_file()
+
+ with patch("app.blitztext_linux.QDesktopServices.openUrl", return_value=True) as m_open, \
+ patch("app.blitztext_linux.QMessageBox") as m_box:
+ SettingsDialog._open_config_file(fake)
+
+ assert fake.config.config_file.is_file()
+ m_open.assert_called_once()
+ m_box.warning.assert_not_called()
+ m_box.critical.assert_not_called()
+
+
+def test_open_config_passes_correct_local_path(tmp_path):
+ fake = _fake_self(tmp_path / ".config" / "blitztext-linux")
+
+ with patch("app.blitztext_linux.QDesktopServices.openUrl", return_value=True) as m_open, \
+ patch("app.blitztext_linux.QMessageBox"):
+ SettingsDialog._open_config_file(fake)
+
+ (url_arg,) = m_open.call_args.args
+ assert url_arg.toLocalFile() == str(fake.config.config_file)
+
+
+def test_open_config_does_not_resave_existing_file_without_legacy_key(tmp_path):
+ fake = _fake_self(tmp_path / ".config" / "blitztext-linux")
+ fake.config.save()
+
+ with patch.object(type(fake.config), "save") as m_save, \
+ patch("app.blitztext_linux.QDesktopServices.openUrl", return_value=True), \
+ patch("app.blitztext_linux.QMessageBox"):
+ SettingsDialog._open_config_file(fake)
+
+ m_save.assert_not_called()
+
+
+def test_open_config_resaves_existing_legacy_config_before_open(tmp_path):
+ config_dir = tmp_path / ".config" / "blitztext-linux"
+ config_dir.mkdir(parents=True, exist_ok=True)
+ config_path = config_dir / "config.json"
+ config_path.write_text(json.dumps({"openai_api_key": "legacy-placeholder"}), encoding="utf-8")
+ fake = _fake_self(config_dir)
+ assert fake.config.has_legacy_openai_api_key is True
+
+ with patch("app.blitztext_linux.QDesktopServices.openUrl", return_value=True), \
+ patch("app.blitztext_linux.QMessageBox"):
+ SettingsDialog._open_config_file(fake)
+
+ saved = json.loads(config_path.read_text(encoding="utf-8"))
+ assert "openai_api_key" not in saved
+ assert saved["openai_api_key_env"] == "OPENAI_API_KEY"
+
+
+def test_open_config_handles_save_error_before_open(tmp_path):
+ fake = _fake_self(tmp_path / ".config" / "blitztext-linux")
+
+ with patch.object(type(fake.config), "save", side_effect=OSError("disk full")), \
+ patch("app.blitztext_linux.QDesktopServices.openUrl") as m_open, \
+ patch("app.blitztext_linux.QMessageBox") as m_box:
+ SettingsDialog._open_config_file(fake)
+
+ m_open.assert_not_called()
+ m_box.critical.assert_called_once()
+ m_box.warning.assert_not_called()
+
+
+def test_open_config_handles_open_failure(tmp_path):
+ fake = _fake_self(tmp_path / ".config" / "blitztext-linux")
+
+ with patch("app.blitztext_linux.QDesktopServices.openUrl", return_value=False) as m_open, \
+ patch("app.blitztext_linux.QMessageBox") as m_box:
+ SettingsDialog._open_config_file(fake)
+
+ m_open.assert_called_once()
+ m_box.warning.assert_called_once()
+ m_box.critical.assert_not_called()
+
+
+def test_refresh_api_key_status_shows_env_name_not_secret(monkeypatch):
+ secret_value = "dummy-openai-key"
+ monkeypatch.setenv("CUSTOM_OPENAI_KEY", secret_value)
+
+ class FakeLineEdit:
+ def text(self):
+ return "CUSTOM_OPENAI_KEY"
+
+ class FakeLabel:
+ def __init__(self):
+ self.text = ""
+
+ def setText(self, value):
+ self.text = value
+
+ fake = SimpleNamespace(
+ edit_api_key_env=FakeLineEdit(),
+ config=SimpleNamespace(openai_api_key_env="OPENAI_API_KEY"),
+ lbl_api_key_status=FakeLabel(),
+ )
+
+ SettingsDialog._refresh_api_key_status(fake)
+
+ assert "CUSTOM_OPENAI_KEY" in fake.lbl_api_key_status.text
+ assert "gesetzt" in fake.lbl_api_key_status.text
+ assert secret_value not in fake.lbl_api_key_status.text