From 1a3bb5680d32a042ee751cffb3a87132e972f81e Mon Sep 17 00:00:00 2001 From: Bhagyashri Date: Sun, 12 Jul 2026 19:59:34 +0530 Subject: [PATCH 01/10] fix: resolve cross-platform paths, empty strings, and dryRun flag (#370) --- plugins/alacritty/src/plugin.py | 512 +++++++-------- plugins/autohotkey/test/test_autohotkey.py | 564 ++++++++-------- plugins/bat/src/plugin.py | 2 +- plugins/cargo/src/plugin.py | 380 +++++------ plugins/chezmoi/src/plugin.py | 430 ++++++------ plugins/docker/src/plugin.py | 346 +++++----- plugins/espanso/test/test_espanso.py | 722 ++++++++++----------- plugins/gh/src/plugin.py | 324 ++++----- plugins/gh/test/test_gh.py | 426 ++++++------ plugins/helix-editor/src/plugin.py | 6 +- plugins/lazygit/src/plugin.py | 316 ++++----- plugins/mise/src/plugin.py | 494 +++++++------- plugins/nvm/src/plugin.py | 2 +- plugins/rainmeter/test/test_rainmeter.py | 2 +- plugins/scoop/src/plugin.py | 422 ++++++------ plugins/starship/src/plugin.py | 6 +- plugins/topgrade/src/plugin.py | 2 +- plugins/winget/src/plugin.py | 350 +++++----- plugins/yasb/src/plugin.py | 450 ++++++------- plugins/yazi/src/plugin.py | 6 +- plugins/zoxide/src/plugin.py | 452 ++++++------- 21 files changed, 3131 insertions(+), 3083 deletions(-) diff --git a/plugins/alacritty/src/plugin.py b/plugins/alacritty/src/plugin.py index 0d59db0f..de797f20 100644 --- a/plugins/alacritty/src/plugin.py +++ b/plugins/alacritty/src/plugin.py @@ -1,254 +1,258 @@ -import json -import os -import shutil -import sys -import tempfile -import uuid - - -def log(msg): - sys.stderr.write(f"[alacritty-plugin] {msg}\n") - sys.stderr.flush() - - -def get_config_path() -> str: - if sys.platform == "win32": - appdata = os.environ.get("APPDATA") - if appdata: - return os.path.join(appdata, "alacritty", "alacritty.toml") - return os.path.expanduser("~/.config/alacritty/alacritty.toml") - - -def read_toml(file_path: str) -> dict: - if not os.path.exists(file_path): - return {} - - try: - if sys.version_info >= (3, 11): - import tomllib - - with open(file_path, "rb") as f: - data = tomllib.load(f) - return data if isinstance(data, dict) else {} - else: - import tomli - - with open(file_path, "rb") as f: - data = tomli.load(f) - return data if isinstance(data, dict) else {} - except ModuleNotFoundError: - raise - except Exception as e: - backup_path = f"{file_path}.{uuid.uuid4().hex}.bak" - log(f"Failed to parse alacritty.toml: {e}. Backing up to {backup_path}") - try: - shutil.copy2(file_path, backup_path) - except Exception as backup_err: - log(f"Failed to create backup: {backup_err}") - return {} - - -def toml_value(value) -> str: - if isinstance(value, bool): - return "true" if value else "false" - if isinstance(value, (int, float)): - return str(value) - if isinstance(value, str): - # Escape quotes and backslashes for TOML strings - escaped = value.replace("\\", "\\\\").replace('"', '\\"') - return f'"{escaped}"' - if isinstance(value, list): - return "[" + ", ".join(toml_value(v) for v in value) + "]" - raise ValueError(f"Unsupported TOML value type: {type(value).__name__}") - - -def toml_lines(data: dict, prefix: str = "") -> list: - lines = [] - # First emit primitives - for k, v in data.items(): - if not isinstance(v, dict): - lines.append(f"{k} = {toml_value(v)}") - - # Then emit nested dicts as tables - for k, v in data.items(): - if isinstance(v, dict): - table_name = f"{prefix}.{k}" if prefix else k - lines.append("") - lines.append(f"[{table_name}]") - lines.extend(toml_lines(v, table_name)) - return lines - - -def write_toml(file_path: str, data: dict) -> None: - dir_path = os.path.dirname(file_path) or "." - os.makedirs(dir_path, exist_ok=True) - - fd, temp_path = tempfile.mkstemp(dir=dir_path, prefix="alacritty.toml.") - try: - lines = toml_lines(data) - with os.fdopen(fd, "w", encoding="utf-8") as f: - f.write("\n".join(lines).strip() + "\n") - os.replace(temp_path, file_path) - except Exception: - os.unlink(temp_path) - raise - - -def merge_settings(target: dict, source: dict) -> bool: - changed = False - for key, value in source.items(): - if isinstance(value, dict): - if key not in target or not isinstance(target.get(key), dict): - target[key] = {} - changed = True - if merge_settings(target[key], value): - changed = True - else: - if key not in target or target[key] != value: - target[key] = value - changed = True - return changed - - -def check_installed(args: dict, request_id: str) -> dict: - installed = shutil.which("alacritty.exe") is not None or shutil.which("alacritty") is not None - return { - "requestId": request_id, - "success": True, - "changed": False, - "data": installed, - } - - -def apply_config(args: dict, context: dict, request_id: str) -> dict: - dry_run = bool(context.get("dryRun", False)) - settings = args.get("settings", {}) or {} - - try: - config_path = get_config_path() - current_config = read_toml(config_path) - changed = merge_settings(current_config, settings) - - if not changed: - return { - "requestId": request_id, - "success": True, - "changed": False, - "data": None, - } - - if dry_run: - log(f"dry_run: would update {config_path}") - return { - "requestId": request_id, - "success": True, - "changed": changed, - "data": None, - } - - write_toml(config_path, current_config) - log(f"Updated config: {config_path}") - - return { - "requestId": request_id, - "success": True, - "changed": True, - "data": None, - } - - except Exception as e: - log(f"Failed to apply config: {e}") - return { - "requestId": request_id, - "success": False, - "changed": False, - "error": str(e), - "data": None, - } - - -def handle(request: dict) -> dict: - request_id = request.get("requestId", "unknown") - command = request.get("command") - args = request.get("args", {}) - context = request.get("context", {}) - - if command == "check_installed": - return check_installed(args, request_id) - - if command == "apply": - if not isinstance(args, dict): - return { - "requestId": request_id, - "success": False, - "changed": False, - "error": "args must be an object", - "data": None, - } - if not isinstance(context, dict): - return { - "requestId": request_id, - "success": False, - "changed": False, - "error": "context must be an object", - "data": None, - } - settings = args.get("settings") - if settings is not None and not isinstance(settings, dict): - return { - "requestId": request_id, - "success": False, - "changed": False, - "error": "settings must be an object", - "data": None, - } - return apply_config(args, context, request_id) - - return { - "requestId": request_id, - "success": False, - "changed": False, - "error": f"Unknown command: {command}", - "data": None, - } - - -def main() -> None: - raw = sys.stdin.read() - if not raw or not raw.strip(): - sys.stdout.write( - json.dumps( - { - "requestId": "unknown", - "success": False, - "changed": False, - "error": "Empty input", - "data": None, - } - ) - + "\n" - ) - sys.stdout.flush() - return - - try: - request = json.loads(raw) - result = handle(request) - except Exception as error: - request_id = "unknown" - if "request" in locals() and isinstance(request, dict): - request_id = request.get("requestId", "unknown") - result = { - "requestId": request_id, - "success": False, - "changed": False, - "error": str(error), - "data": None, - } - - sys.stdout.write(json.dumps(result) + "\n") - sys.stdout.flush() - - -if __name__ == "__main__": - main() +import json +import os +import shutil +import sys +import tempfile +import uuid + + +def log(msg): + sys.stderr.write(f"[alacritty-plugin] {msg}\n") + sys.stderr.flush() + + +def get_config_path() -> str: + if sys.platform == "win32": + appdata = (os.environ.get("APPDATA") or os.path.join(os.path.expanduser("~"), "AppData", "Roaming")) + if appdata: + return os.path.join(appdata, "alacritty", "alacritty.toml") + return os.path.expanduser("~/.config/alacritty/alacritty.toml") + + +def read_toml(file_path: str) -> dict: + if not os.path.exists(file_path): + return {} + + try: + if sys.version_info >= (3, 11): + import tomllib + + with open(file_path, "rb") as f: + data = tomllib.load(f) + return data if isinstance(data, dict) else {} + else: + import tomli + + with open(file_path, "rb") as f: + data = tomli.load(f) + return data if isinstance(data, dict) else {} + except ModuleNotFoundError: + raise + except Exception as e: + backup_path = f"{file_path}.{uuid.uuid4().hex}.bak" + log(f"Failed to parse alacritty.toml: {e}. Backing up to {backup_path}") + try: + shutil.copy2(file_path, backup_path) + except Exception as backup_err: + log(f"Failed to create backup: {backup_err}") + return {} + + +def toml_value(value) -> str: + if isinstance(value, bool): + return "true" if value else "false" + if isinstance(value, (int, float)): + return str(value) + if isinstance(value, str): + # Escape quotes and backslashes for TOML strings + escaped = value.replace("\\", "\\\\").replace('"', '\\"') + return f'"{escaped}"' + if isinstance(value, list): + return "[" + ", ".join(toml_value(v) for v in value) + "]" + raise ValueError(f"Unsupported TOML value type: {type(value).__name__}") + + +def toml_lines(data: dict, prefix: str = "") -> list: + lines = [] + # First emit primitives + for k, v in data.items(): + if not isinstance(v, dict): + lines.append(f"{k} = {toml_value(v)}") + + # Then emit nested dicts as tables + for k, v in data.items(): + if isinstance(v, dict): + table_name = f"{prefix}.{k}" if prefix else k + lines.append("") + lines.append(f"[{table_name}]") + lines.extend(toml_lines(v, table_name)) + return lines + + +def write_toml(file_path: str, data: dict) -> None: + dir_path = os.path.dirname(file_path) or "." + os.makedirs(dir_path, exist_ok=True) + + fd, temp_path = tempfile.mkstemp(dir=dir_path, prefix="alacritty.toml.") + try: + lines = toml_lines(data) + with os.fdopen(fd, "w", encoding="utf-8") as f: + f.write("\n".join(lines).strip() + "\n") + os.replace(temp_path, file_path) + except Exception: + os.unlink(temp_path) + raise + + +def merge_settings(target: dict, source: dict) -> bool: + changed = False + for key, value in source.items(): + if isinstance(value, dict): + if key not in target or not isinstance(target.get(key), dict): + target[key] = {} + changed = True + if merge_settings(target[key], value): + changed = True + else: + if value == "": + if key in target: + del target[key] + changed = True + elif key not in target or target[key] != value: + target[key] = value + changed = True + return changed + + +def check_installed(args: dict, request_id: str) -> dict: + installed = shutil.which("alacritty.exe") is not None or shutil.which("alacritty") is not None + return { + "requestId": request_id, + "success": True, + "changed": False, + "data": installed, + } + + +def apply_config(args: dict, context: dict, request_id: str) -> dict: + dry_run = bool(context.get("dryRun", False)) + settings = args.get("settings", {}) or {} + + try: + config_path = get_config_path() + current_config = read_toml(config_path) + changed = merge_settings(current_config, settings) + + if not changed: + return { + "requestId": request_id, + "success": True, + "changed": False, + "data": None, + } + + if dry_run: + log(f"dry_run: would update {config_path}") + return { + "requestId": request_id, + "success": True, + "changed": changed, + "data": None, + } + + write_toml(config_path, current_config) + log(f"Updated config: {config_path}") + + return { + "requestId": request_id, + "success": True, + "changed": True, + "data": None, + } + + except Exception as e: + log(f"Failed to apply config: {e}") + return { + "requestId": request_id, + "success": False, + "changed": False, + "error": str(e), + "data": None, + } + + +def handle(request: dict) -> dict: + request_id = request.get("requestId", "unknown") + command = request.get("command") + args = request.get("args", {}) + context = request.get("context", {}) + + if command == "check_installed": + return check_installed(args, request_id) + + if command == "apply": + if not isinstance(args, dict): + return { + "requestId": request_id, + "success": False, + "changed": False, + "error": "args must be an object", + "data": None, + } + if not isinstance(context, dict): + return { + "requestId": request_id, + "success": False, + "changed": False, + "error": "context must be an object", + "data": None, + } + settings = args.get("settings") + if settings is not None and not isinstance(settings, dict): + return { + "requestId": request_id, + "success": False, + "changed": False, + "error": "settings must be an object", + "data": None, + } + return apply_config(args, context, request_id) + + return { + "requestId": request_id, + "success": False, + "changed": False, + "error": f"Unknown command: {command}", + "data": None, + } + + +def main() -> None: + raw = sys.stdin.read() + if not raw or not raw.strip(): + sys.stdout.write( + json.dumps( + { + "requestId": "unknown", + "success": False, + "changed": False, + "error": "Empty input", + "data": None, + } + ) + + "\n" + ) + sys.stdout.flush() + return + + try: + request = json.loads(raw) + result = handle(request) + except Exception as error: + request_id = "unknown" + if "request" in locals() and isinstance(request, dict): + request_id = request.get("requestId", "unknown") + result = { + "requestId": request_id, + "success": False, + "changed": False, + "error": str(error), + "data": None, + } + + sys.stdout.write(json.dumps(result) + "\n") + sys.stdout.flush() + + +if __name__ == "__main__": + main() diff --git a/plugins/autohotkey/test/test_autohotkey.py b/plugins/autohotkey/test/test_autohotkey.py index 99f9469b..1b373deb 100644 --- a/plugins/autohotkey/test/test_autohotkey.py +++ b/plugins/autohotkey/test/test_autohotkey.py @@ -1,282 +1,282 @@ -import importlib.util -import json -import subprocess -import sys -from pathlib import Path - -import pytest - -PLUGIN_PATH = Path(__file__).resolve().parents[1] / "src" / "plugin.py" - - -def load_plugin_module(): - spec = importlib.util.spec_from_file_location("autohotkey_plugin", PLUGIN_PATH) - module = importlib.util.module_from_spec(spec) - assert spec.loader is not None - spec.loader.exec_module(module) - return module - - -def run_plugin(payload: dict) -> dict: - result = subprocess.run( - [sys.executable, str(PLUGIN_PATH)], - input=json.dumps(payload), - capture_output=True, - text=True, - check=False, - ) - - assert result.stdout.strip(), result.stderr - return json.loads(result.stdout.strip()) - - -def test_check_installed_reports_true_when_executable_is_found(monkeypatch): - plugin = load_plugin_module() - - monkeypatch.setattr(plugin.shutil, "which", lambda name: None) - monkeypatch.setattr(plugin.os.path, "exists", lambda path: path.endswith("AutoHotkey64.exe")) - monkeypatch.setenv("PROGRAMFILES", r"C:\Program Files") - monkeypatch.setenv("PROGRAMFILES(X86)", r"C:\Program Files (x86)") - monkeypatch.setenv("LOCALAPPDATA", r"C:\Users\Test\AppData\Local") - - response = plugin.check_installed({}, "req-1") - - assert response == { - "requestId": "req-1", - "success": True, - "changed": False, - "data": True, - } - - -def test_check_installed_reports_false_when_not_found(monkeypatch): - plugin = load_plugin_module() - - monkeypatch.setattr(plugin.shutil, "which", lambda name: None) - monkeypatch.setattr(plugin.os.path, "exists", lambda path: False) - monkeypatch.setenv("PROGRAMFILES", r"C:\Program Files") - monkeypatch.setenv("PROGRAMFILES(X86)", r"C:\Program Files (x86)") - monkeypatch.setenv("LOCALAPPDATA", r"C:\Users\Test\AppData\Local") - - response = plugin.check_installed({}, "req-2") - - assert response["requestId"] == "req-2" - assert response["success"] is True - assert response["data"] is False - - -def test_apply_writes_new_script_with_ahk_v2_syntax(tmp_path): - plugin = load_plugin_module() - - script_path = tmp_path / "Documents" / "AutoHotkey" / "main.ahk" - response = plugin.apply_config( - { - "dry_run": False, - "script_path": str(script_path), - "hotkeys": { - "#z": 'Run "https://www.google.com"', - "!Space": 'Send "{Volume_Mute}"', - "^!t": 'Run "cmd.exe"', - }, - "hotstrings": { - "::btw::": "by the way", - "::sig::": "Best regards,\nJohn Doe", - "::email::": "john@example.com", - }, - "settings": { - "icon_tip": "WinHome managed", - "persistent": True, - "detect_hidden_windows": "On", - }, - }, - {"dryRun": False}, - "req-3", - ) - - assert response == { - "requestId": "req-3", - "success": True, - "changed": True, - } - content = script_path.read_text(encoding="utf-8") - assert content.startswith("#Requires AutoHotkey v2.0") - assert "Persistent" in content - assert 'DetectHiddenWindows "On"' in content - assert 'TrayTip "WinHome managed"' in content - assert "#z::" in content - assert 'Run "https://www.google.com"' in content - assert "::btw::by the way" in content - assert "::email::john@example.com" in content - assert "::sig::" in content - assert "Best regards," in content - assert "John Doe" in content - - -def test_apply_merges_hotkeys_without_losing_custom_sections(tmp_path): - plugin = load_plugin_module() - - script_path = tmp_path / "AutoHotkey" / "main.ahk" - script_path.parent.mkdir(parents=True, exist_ok=True) - script_path.write_text( - "\n".join( - [ - "#Requires AutoHotkey v2.0", - "", - "; custom start", - 'MsgBox "Keep me"', - "; custom end", - ] - ) - + "\n", - encoding="utf-8", - ) - - response = plugin.apply_config( - { - "dry_run": False, - "script_path": str(script_path), - "hotkeys": { - "#z": 'Run "https://www.google.com"', - }, - "hotstrings": {}, - "settings": {}, - }, - {"dryRun": False}, - "req-4", - ) - - assert response["requestId"] == "req-4" - assert response["success"] is True - assert response["changed"] is True - - content = script_path.read_text(encoding="utf-8") - assert 'MsgBox "Keep me"' in content - assert "; custom start" in content - assert "#z::" in content - assert 'Run "https://www.google.com"' in content - - -def test_apply_dry_run_does_not_write_file(tmp_path, monkeypatch): - plugin = load_plugin_module() - - script_path = tmp_path / "AutoHotkey" / "main.ahk" - monkeypatch.setattr( - plugin, - "write_text", - lambda *args, **kwargs: pytest.fail("write_text should not be called"), - ) - - response = plugin.apply_config( - { - "dry_run": True, - "script_path": str(script_path), - "hotkeys": {"#z": 'Run "https://www.google.com"'}, - "hotstrings": {}, - "settings": {}, - }, - {"dryRun": True}, - "req-5", - ) - - assert response == { - "requestId": "req-5", - "success": True, - "changed": True, - } - assert not script_path.exists() - - -def test_apply_creates_missing_directories(tmp_path): - plugin = load_plugin_module() - - script_path = tmp_path / "nested" / "AutoHotkey" / "main.ahk" - response = plugin.apply_config( - { - "dry_run": False, - "script_path": str(script_path), - "hotkeys": {"#z": 'Run "https://www.google.com"'}, - "hotstrings": {}, - "settings": {}, - }, - {"dryRun": False}, - "req-6", - ) - - assert response["success"] is True - assert response["changed"] is True - assert script_path.exists() - - -def test_apply_is_idempotent_for_unchanged_script(tmp_path): - plugin = load_plugin_module() - - script_path = tmp_path / "AutoHotkey" / "main.ahk" - args = { - "dry_run": False, - "script_path": str(script_path), - "hotkeys": {"#z": 'Run "https://www.google.com"'}, - "hotstrings": {"::btw::": "by the way"}, - "settings": {"persistent": True}, - } - - first = plugin.apply_config(args, {"dryRun": False}, "req-7") - second = plugin.apply_config(args, {"dryRun": False}, "req-7") - - assert first["changed"] is True - assert second["changed"] is False - - -def test_all_responses_include_request_id_and_errors_return_error_field( - monkeypatch, -): - plugin = load_plugin_module() - - success_response = plugin.process_request( - { - "requestId": "req-8", - "command": "check_installed", - "args": {}, - } - ) - assert success_response["requestId"] == "req-8" - - monkeypatch.setattr( - plugin, - "apply_config", - lambda args, context, request_id: (_ for _ in ()).throw(RuntimeError("boom")), - ) - error_response = plugin.process_request( - { - "requestId": "req-9", - "command": "apply", - "args": {}, - } - ) - - assert error_response["requestId"] == "req-9" - assert error_response["success"] is False - assert "error" in error_response - assert error_response["error"] == "Internal Script Error: boom" - - -def test_subprocess_protocol_round_trip(tmp_path): - script_path = tmp_path / "AutoHotkey" / "main.ahk" - - response = run_plugin( - { - "requestId": "req-10", - "command": "apply", - "args": { - "script_path": str(script_path), - "hotkeys": {"#z": 'Run "https://www.google.com"'}, - "hotstrings": {}, - "settings": {}, - }, - "context": {"dryRun": False}, - } - ) - - assert response["requestId"] == "req-10" - assert response["success"] is True - assert response["changed"] is True - assert script_path.exists() +import importlib.util +import json +import subprocess +import sys +from pathlib import Path + +import pytest + +PLUGIN_PATH = Path(__file__).resolve().parents[1] / "src" / "plugin.py" + + +def load_plugin_module(): + spec = importlib.util.spec_from_file_location("autohotkey_plugin", PLUGIN_PATH) + module = importlib.util.module_from_spec(spec) + assert spec.loader is not None + spec.loader.exec_module(module) + return module + + +def run_plugin(payload: dict) -> dict: + result = subprocess.run( + [sys.executable, str(PLUGIN_PATH)], + input=json.dumps(payload), + capture_output=True, + text=True, + check=False, + ) + + assert result.stdout.strip(), result.stderr + return json.loads(result.stdout.strip()) + + +def test_check_installed_reports_true_when_executable_is_found(monkeypatch): + plugin = load_plugin_module() + + monkeypatch.setattr(plugin.shutil, "which", lambda name: None) + monkeypatch.setattr(plugin.os.path, "exists", lambda path: path.endswith("AutoHotkey64.exe")) + monkeypatch.setenv("PROGRAMFILES", r"C:\Program Files") + monkeypatch.setenv("PROGRAMFILES(X86)", r"C:\Program Files (x86)") + monkeypatch.setenv("LOCALAPPDATA", r"C:\Users\Test\AppData\Local") + + response = plugin.check_installed({}, "req-1") + + assert response == { + "requestId": "req-1", + "success": True, + "changed": False, + "data": True, + } + + +def test_check_installed_reports_false_when_not_found(monkeypatch): + plugin = load_plugin_module() + + monkeypatch.setattr(plugin.shutil, "which", lambda name: None) + monkeypatch.setattr(plugin.os.path, "exists", lambda path: False) + monkeypatch.setenv("PROGRAMFILES", r"C:\Program Files") + monkeypatch.setenv("PROGRAMFILES(X86)", r"C:\Program Files (x86)") + monkeypatch.setenv("LOCALAPPDATA", r"C:\Users\Test\AppData\Local") + + response = plugin.check_installed({}, "req-2") + + assert response["requestId"] == "req-2" + assert response["success"] is True + assert response["data"] is False + + +def test_apply_writes_new_script_with_ahk_v2_syntax(tmp_path): + plugin = load_plugin_module() + + script_path = tmp_path / "Documents" / "AutoHotkey" / "main.ahk" + response = plugin.apply_config( + { + "dryRun": False, + "script_path": str(script_path), + "hotkeys": { + "#z": 'Run "https://www.google.com"', + "!Space": 'Send "{Volume_Mute}"', + "^!t": 'Run "cmd.exe"', + }, + "hotstrings": { + "::btw::": "by the way", + "::sig::": "Best regards,\nJohn Doe", + "::email::": "john@example.com", + }, + "settings": { + "icon_tip": "WinHome managed", + "persistent": True, + "detect_hidden_windows": "On", + }, + }, + {"dryRun": False}, + "req-3", + ) + + assert response == { + "requestId": "req-3", + "success": True, + "changed": True, + } + content = script_path.read_text(encoding="utf-8") + assert content.startswith("#Requires AutoHotkey v2.0") + assert "Persistent" in content + assert 'DetectHiddenWindows "On"' in content + assert 'TrayTip "WinHome managed"' in content + assert "#z::" in content + assert 'Run "https://www.google.com"' in content + assert "::btw::by the way" in content + assert "::email::john@example.com" in content + assert "::sig::" in content + assert "Best regards," in content + assert "John Doe" in content + + +def test_apply_merges_hotkeys_without_losing_custom_sections(tmp_path): + plugin = load_plugin_module() + + script_path = tmp_path / "AutoHotkey" / "main.ahk" + script_path.parent.mkdir(parents=True, exist_ok=True) + script_path.write_text( + "\n".join( + [ + "#Requires AutoHotkey v2.0", + "", + "; custom start", + 'MsgBox "Keep me"', + "; custom end", + ] + ) + + "\n", + encoding="utf-8", + ) + + response = plugin.apply_config( + { + "dryRun": False, + "script_path": str(script_path), + "hotkeys": { + "#z": 'Run "https://www.google.com"', + }, + "hotstrings": {}, + "settings": {}, + }, + {"dryRun": False}, + "req-4", + ) + + assert response["requestId"] == "req-4" + assert response["success"] is True + assert response["changed"] is True + + content = script_path.read_text(encoding="utf-8") + assert 'MsgBox "Keep me"' in content + assert "; custom start" in content + assert "#z::" in content + assert 'Run "https://www.google.com"' in content + + +def test_apply_dry_run_does_not_write_file(tmp_path, monkeypatch): + plugin = load_plugin_module() + + script_path = tmp_path / "AutoHotkey" / "main.ahk" + monkeypatch.setattr( + plugin, + "write_text", + lambda *args, **kwargs: pytest.fail("write_text should not be called"), + ) + + response = plugin.apply_config( + { + "dryRun": True, + "script_path": str(script_path), + "hotkeys": {"#z": 'Run "https://www.google.com"'}, + "hotstrings": {}, + "settings": {}, + }, + {"dryRun": True}, + "req-5", + ) + + assert response == { + "requestId": "req-5", + "success": True, + "changed": True, + } + assert not script_path.exists() + + +def test_apply_creates_missing_directories(tmp_path): + plugin = load_plugin_module() + + script_path = tmp_path / "nested" / "AutoHotkey" / "main.ahk" + response = plugin.apply_config( + { + "dryRun": False, + "script_path": str(script_path), + "hotkeys": {"#z": 'Run "https://www.google.com"'}, + "hotstrings": {}, + "settings": {}, + }, + {"dryRun": False}, + "req-6", + ) + + assert response["success"] is True + assert response["changed"] is True + assert script_path.exists() + + +def test_apply_is_idempotent_for_unchanged_script(tmp_path): + plugin = load_plugin_module() + + script_path = tmp_path / "AutoHotkey" / "main.ahk" + args = { + "dryRun": False, + "script_path": str(script_path), + "hotkeys": {"#z": 'Run "https://www.google.com"'}, + "hotstrings": {"::btw::": "by the way"}, + "settings": {"persistent": True}, + } + + first = plugin.apply_config(args, {"dryRun": False}, "req-7") + second = plugin.apply_config(args, {"dryRun": False}, "req-7") + + assert first["changed"] is True + assert second["changed"] is False + + +def test_all_responses_include_request_id_and_errors_return_error_field( + monkeypatch, +): + plugin = load_plugin_module() + + success_response = plugin.process_request( + { + "requestId": "req-8", + "command": "check_installed", + "args": {}, + } + ) + assert success_response["requestId"] == "req-8" + + monkeypatch.setattr( + plugin, + "apply_config", + lambda args, context, request_id: (_ for _ in ()).throw(RuntimeError("boom")), + ) + error_response = plugin.process_request( + { + "requestId": "req-9", + "command": "apply", + "args": {}, + } + ) + + assert error_response["requestId"] == "req-9" + assert error_response["success"] is False + assert "error" in error_response + assert error_response["error"] == "Internal Script Error: boom" + + +def test_subprocess_protocol_round_trip(tmp_path): + script_path = tmp_path / "AutoHotkey" / "main.ahk" + + response = run_plugin( + { + "requestId": "req-10", + "command": "apply", + "args": { + "script_path": str(script_path), + "hotkeys": {"#z": 'Run "https://www.google.com"'}, + "hotstrings": {}, + "settings": {}, + }, + "context": {"dryRun": False}, + } + ) + + assert response["requestId"] == "req-10" + assert response["success"] is True + assert response["changed"] is True + assert script_path.exists() diff --git a/plugins/bat/src/plugin.py b/plugins/bat/src/plugin.py index d1b2b77e..12e6d2e0 100644 --- a/plugins/bat/src/plugin.py +++ b/plugins/bat/src/plugin.py @@ -39,7 +39,7 @@ def get_config_path() -> Path: system = platform.system() if system == "Windows": - appdata = os.environ.get("APPDATA") + appdata = (os.environ.get("APPDATA") or os.path.join(os.path.expanduser("~"), "AppData", "Roaming")) if not appdata: # Keep behavior explicit; host will return structured error. raise RuntimeError("APPDATA environment variable not set") diff --git a/plugins/cargo/src/plugin.py b/plugins/cargo/src/plugin.py index e050150b..b64ea46a 100644 --- a/plugins/cargo/src/plugin.py +++ b/plugins/cargo/src/plugin.py @@ -1,188 +1,192 @@ -# /// script -# dependencies = [] -# /// - -import json -import os -import shutil -import sys - - -def log(msg): - sys.stderr.write(f"[cargo-plugin] {msg}\n") - sys.stderr.flush() - - -def get_config_path() -> str: - userprofile = os.environ.get("USERPROFILE", "") - return os.path.join(userprofile, ".cargo", "config.toml") - - -def read_toml(file_path: str) -> dict: - if not os.path.exists(file_path): - return {} - try: - import tomllib - except ImportError: - try: - import tomli as tomllib - except ImportError: - raise RuntimeError("tomllib (Python 3.11+) or tomli is required") - with open(file_path, "rb") as f: - return tomllib.load(f) - - -def write_toml(file_path: str, data: dict) -> None: - os.makedirs(os.path.dirname(file_path), exist_ok=True) - lines = [] - for key, value in data.items(): - if not isinstance(value, dict): - lines.append(f"{key} = {toml_value(value)}") - for section, contents in data.items(): - if isinstance(contents, dict): - lines.append(f"\n[{section}]") - for k, v in contents.items(): - lines.append(f"{k} = {toml_value(v)}") - temp_path = file_path + ".tmp" - with open(temp_path, "w", encoding="utf-8") as f: - f.write("\n".join(lines).strip() + "\n") - os.replace(temp_path, file_path) - - -def toml_value(value) -> str: - if isinstance(value, bool): - return "true" if value else "false" - elif isinstance(value, int): - return str(value) - elif isinstance(value, float): - return str(value) - elif isinstance(value, str): - return f'"{value}"' - elif isinstance(value, list): - items = ", ".join(toml_value(i) for i in value) - return f"[{items}]" - else: - return f'"{str(value)}"' - - -def merge_settings(target: dict, source: dict) -> bool: - changed = False - for key, value in source.items(): - if isinstance(value, dict): - if key not in target or not isinstance(target.get(key), dict): - target[key] = {} - changed = True - if merge_settings(target[key], value): - changed = True - else: - if key not in target or target[key] != value: - target[key] = value - changed = True - return changed - - -def check_installed(args: dict, request_id: str) -> dict: - cargo_home = os.environ.get("CARGO_HOME", "") - installed = ( - shutil.which("cargo.exe") is not None - or shutil.which("cargo") is not None - or (cargo_home != "" and os.path.exists(os.path.join(cargo_home, "bin", "cargo.exe"))) - ) - return { - "requestId": request_id, - "success": True, - "changed": False, - "data": installed, - } - - -def apply_config(args: dict, context: dict, request_id: str) -> dict: - dry_run = bool(context.get("dryRun", False)) - settings = args.get("settings", {}) - - try: - config_path = get_config_path() - current_config = read_toml(config_path) - changed = merge_settings(current_config, settings) - - if not changed: - return { - "requestId": request_id, - "success": True, - "changed": False, - } - - if dry_run: - log(f"dry_run: would update {config_path}") - return { - "requestId": request_id, - "success": True, - "changed": changed, - } - - write_toml(config_path, current_config) - log(f"Updated Cargo config: {config_path}") - - return { - "requestId": request_id, - "success": True, - "changed": True, - } - - except Exception as e: - log(f"Failed to apply config: {e}") - return { - "requestId": request_id, - "success": False, - "changed": False, - "error": str(e), - } - - -def handle(request: dict) -> dict: - request_id = request.get("requestId", "unknown") - command = request.get("command") - args = request.get("args", {}) - context = request.get("context", {}) - - if command == "check_installed": - return check_installed(args, request_id) - if command == "apply": - if not isinstance(args, dict): - raise ValueError("args must be an object") - if not isinstance(context, dict): - raise ValueError("context must be an object") - return apply_config(args, context, request_id) - - return { - "requestId": request_id, - "success": False, - "changed": False, - "error": f"Unknown command: {command}", - } - - -def main() -> None: - raw = sys.stdin.read() - if not raw: - return - - try: - request = json.loads(raw) - result = handle(request) - except Exception as error: - result = { - "requestId": request.get("requestId", "unknown") - if "request" in locals() and isinstance(request, dict) - else "unknown", - "success": False, - "changed": False, - "error": str(error), - } - - sys.stdout.write(json.dumps(result) + "\n") - sys.stdout.flush() - - -if __name__ == "__main__": - main() +# /// script +# dependencies = [] +# /// + +import json +import os +import shutil +import sys + + +def log(msg): + sys.stderr.write(f"[cargo-plugin] {msg}\n") + sys.stderr.flush() + + +def get_config_path() -> str: + userprofile = os.environ.get("USERPROFILE", "") + return os.path.join(userprofile, ".cargo", "config.toml") + + +def read_toml(file_path: str) -> dict: + if not os.path.exists(file_path): + return {} + try: + import tomllib + except ImportError: + try: + import tomli as tomllib + except ImportError: + raise RuntimeError("tomllib (Python 3.11+) or tomli is required") + with open(file_path, "rb") as f: + return tomllib.load(f) + + +def write_toml(file_path: str, data: dict) -> None: + os.makedirs(os.path.dirname(file_path), exist_ok=True) + lines = [] + for key, value in data.items(): + if not isinstance(value, dict): + lines.append(f"{key} = {toml_value(value)}") + for section, contents in data.items(): + if isinstance(contents, dict): + lines.append(f"\n[{section}]") + for k, v in contents.items(): + lines.append(f"{k} = {toml_value(v)}") + temp_path = file_path + ".tmp" + with open(temp_path, "w", encoding="utf-8") as f: + f.write("\n".join(lines).strip() + "\n") + os.replace(temp_path, file_path) + + +def toml_value(value) -> str: + if isinstance(value, bool): + return "true" if value else "false" + elif isinstance(value, int): + return str(value) + elif isinstance(value, float): + return str(value) + elif isinstance(value, str): + return f'"{value}"' + elif isinstance(value, list): + items = ", ".join(toml_value(i) for i in value) + return f"[{items}]" + else: + return f'"{str(value)}"' + + +def merge_settings(target: dict, source: dict) -> bool: + changed = False + for key, value in source.items(): + if isinstance(value, dict): + if key not in target or not isinstance(target.get(key), dict): + target[key] = {} + changed = True + if merge_settings(target[key], value): + changed = True + else: + if value == "": + if key in target: + del target[key] + changed = True + elif key not in target or target[key] != value: + target[key] = value + changed = True + return changed + + +def check_installed(args: dict, request_id: str) -> dict: + cargo_home = os.environ.get("CARGO_HOME", "") + installed = ( + shutil.which("cargo.exe") is not None + or shutil.which("cargo") is not None + or (cargo_home != "" and os.path.exists(os.path.join(cargo_home, "bin", "cargo.exe"))) + ) + return { + "requestId": request_id, + "success": True, + "changed": False, + "data": installed, + } + + +def apply_config(args: dict, context: dict, request_id: str) -> dict: + dry_run = bool(context.get("dryRun", False)) + settings = args.get("settings", {}) + + try: + config_path = get_config_path() + current_config = read_toml(config_path) + changed = merge_settings(current_config, settings) + + if not changed: + return { + "requestId": request_id, + "success": True, + "changed": False, + } + + if dry_run: + log(f"dry_run: would update {config_path}") + return { + "requestId": request_id, + "success": True, + "changed": changed, + } + + write_toml(config_path, current_config) + log(f"Updated Cargo config: {config_path}") + + return { + "requestId": request_id, + "success": True, + "changed": True, + } + + except Exception as e: + log(f"Failed to apply config: {e}") + return { + "requestId": request_id, + "success": False, + "changed": False, + "error": str(e), + } + + +def handle(request: dict) -> dict: + request_id = request.get("requestId", "unknown") + command = request.get("command") + args = request.get("args", {}) + context = request.get("context", {}) + + if command == "check_installed": + return check_installed(args, request_id) + if command == "apply": + if not isinstance(args, dict): + raise ValueError("args must be an object") + if not isinstance(context, dict): + raise ValueError("context must be an object") + return apply_config(args, context, request_id) + + return { + "requestId": request_id, + "success": False, + "changed": False, + "error": f"Unknown command: {command}", + } + + +def main() -> None: + raw = sys.stdin.read() + if not raw: + return + + try: + request = json.loads(raw) + result = handle(request) + except Exception as error: + result = { + "requestId": request.get("requestId", "unknown") + if "request" in locals() and isinstance(request, dict) + else "unknown", + "success": False, + "changed": False, + "error": str(error), + } + + sys.stdout.write(json.dumps(result) + "\n") + sys.stdout.flush() + + +if __name__ == "__main__": + main() diff --git a/plugins/chezmoi/src/plugin.py b/plugins/chezmoi/src/plugin.py index e8a253bf..5b469fe7 100644 --- a/plugins/chezmoi/src/plugin.py +++ b/plugins/chezmoi/src/plugin.py @@ -1,213 +1,217 @@ -import json -import os -import shutil -import sys -import tempfile -import uuid - - -def log(msg): - sys.stderr.write(f"[chezmoi-plugin] {msg}\n") - sys.stderr.flush() - - -def get_config_path() -> str: - userprofile = os.environ.get("USERPROFILE") - if userprofile and sys.platform == "win32": - return os.path.join(userprofile, ".config", "chezmoi", "chezmoi.yaml") - return os.path.expanduser("~/.config/chezmoi/chezmoi.yaml") - - -def read_yaml(file_path: str) -> dict: - if not os.path.exists(file_path): - return {} - - try: - import yaml - - with open(file_path, "r", encoding="utf-8") as f: - data = yaml.safe_load(f) - return data if isinstance(data, dict) else {} - except Exception as e: - backup_path = f"{file_path}.{uuid.uuid4().hex}.bak" - log(f"Failed to parse chezmoi.yaml: {e}. Backing up to {backup_path}") - try: - shutil.copy2(file_path, backup_path) - except Exception as backup_err: - log(f"Failed to create backup: {backup_err}") - return {} - - -def write_yaml(file_path: str, data: dict) -> None: - os.makedirs(os.path.dirname(file_path), exist_ok=True) - - fd, temp_path = tempfile.mkstemp(dir=os.path.dirname(file_path), prefix="chezmoi.yaml.") - try: - import yaml - - with os.fdopen(fd, "w", encoding="utf-8") as f: - yaml.safe_dump(data, f, default_flow_style=False, sort_keys=False) - os.replace(temp_path, file_path) - except Exception: - os.unlink(temp_path) - raise - - -def merge_settings(target: dict, source: dict) -> bool: - changed = False - for key, value in source.items(): - if isinstance(value, dict): - if key not in target or not isinstance(target.get(key), dict): - target[key] = {} - changed = True - if merge_settings(target[key], value): - changed = True - else: - if key not in target or target[key] != value: - target[key] = value - changed = True - return changed - - -def check_installed(args: dict, request_id: str) -> dict: - installed = shutil.which("chezmoi.exe") is not None or shutil.which("chezmoi") is not None - return { - "requestId": request_id, - "success": True, - "changed": False, - "data": installed, - } - - -def apply_config(args: dict, context: dict, request_id: str) -> dict: - dry_run = bool(context.get("dryRun", False)) - settings = args.get("settings", {}) or {} - - try: - config_path = get_config_path() - current_config = read_yaml(config_path) - changed = merge_settings(current_config, settings) - - if not changed: - return { - "requestId": request_id, - "success": True, - "changed": False, - "data": None, - } - - if dry_run: - log(f"dry_run: would update {config_path}") - return { - "requestId": request_id, - "success": True, - "changed": changed, - "data": None, - } - - write_yaml(config_path, current_config) - log(f"Updated config: {config_path}") - - return { - "requestId": request_id, - "success": True, - "changed": True, - "data": None, - } - - except Exception as e: - log(f"Failed to apply config: {e}") - return { - "requestId": request_id, - "success": False, - "changed": False, - "error": str(e), - "data": None, - } - - -def handle(request: dict) -> dict: - request_id = request.get("requestId", "unknown") - command = request.get("command") - args = request.get("args", {}) - context = request.get("context", {}) - - if command == "check_installed": - return check_installed(args, request_id) - - if command == "apply": - if not isinstance(args, dict): - return { - "requestId": request_id, - "success": False, - "changed": False, - "error": "args must be an object", - "data": None, - } - if not isinstance(context, dict): - return { - "requestId": request_id, - "success": False, - "changed": False, - "error": "context must be an object", - "data": None, - } - settings = args.get("settings") - if settings is not None and not isinstance(settings, dict): - return { - "requestId": request_id, - "success": False, - "changed": False, - "error": "settings must be an object", - "data": None, - } - return apply_config(args, context, request_id) - - return { - "requestId": request_id, - "success": False, - "changed": False, - "error": f"Unknown command: {command}", - "data": None, - } - - -def main() -> None: - raw = sys.stdin.read() - if not raw or not raw.strip(): - sys.stdout.write( - json.dumps( - { - "requestId": "unknown", - "success": False, - "changed": False, - "error": "Empty input", - "data": None, - } - ) - + "\n" - ) - sys.stdout.flush() - return - - try: - request = json.loads(raw) - result = handle(request) - except Exception as error: - request_id = "unknown" - if "request" in locals() and isinstance(request, dict): - request_id = request.get("requestId", "unknown") - result = { - "requestId": request_id, - "success": False, - "changed": False, - "error": str(error), - "data": None, - } - - sys.stdout.write(json.dumps(result) + "\n") - sys.stdout.flush() - - -if __name__ == "__main__": - main() +import json +import os +import shutil +import sys +import tempfile +import uuid + + +def log(msg): + sys.stderr.write(f"[chezmoi-plugin] {msg}\n") + sys.stderr.flush() + + +def get_config_path() -> str: + userprofile = os.environ.get("USERPROFILE") + if userprofile and sys.platform == "win32": + return os.path.join(userprofile, ".config", "chezmoi", "chezmoi.yaml") + return os.path.expanduser("~/.config/chezmoi/chezmoi.yaml") + + +def read_yaml(file_path: str) -> dict: + if not os.path.exists(file_path): + return {} + + try: + import yaml + + with open(file_path, "r", encoding="utf-8") as f: + data = yaml.safe_load(f) + return data if isinstance(data, dict) else {} + except Exception as e: + backup_path = f"{file_path}.{uuid.uuid4().hex}.bak" + log(f"Failed to parse chezmoi.yaml: {e}. Backing up to {backup_path}") + try: + shutil.copy2(file_path, backup_path) + except Exception as backup_err: + log(f"Failed to create backup: {backup_err}") + return {} + + +def write_yaml(file_path: str, data: dict) -> None: + os.makedirs(os.path.dirname(file_path), exist_ok=True) + + fd, temp_path = tempfile.mkstemp(dir=os.path.dirname(file_path), prefix="chezmoi.yaml.") + try: + import yaml + + with os.fdopen(fd, "w", encoding="utf-8") as f: + yaml.safe_dump(data, f, default_flow_style=False, sort_keys=False) + os.replace(temp_path, file_path) + except Exception: + os.unlink(temp_path) + raise + + +def merge_settings(target: dict, source: dict) -> bool: + changed = False + for key, value in source.items(): + if isinstance(value, dict): + if key not in target or not isinstance(target.get(key), dict): + target[key] = {} + changed = True + if merge_settings(target[key], value): + changed = True + else: + if value == "": + if key in target: + del target[key] + changed = True + elif key not in target or target[key] != value: + target[key] = value + changed = True + return changed + + +def check_installed(args: dict, request_id: str) -> dict: + installed = shutil.which("chezmoi.exe") is not None or shutil.which("chezmoi") is not None + return { + "requestId": request_id, + "success": True, + "changed": False, + "data": installed, + } + + +def apply_config(args: dict, context: dict, request_id: str) -> dict: + dry_run = bool(context.get("dryRun", False)) + settings = args.get("settings", {}) or {} + + try: + config_path = get_config_path() + current_config = read_yaml(config_path) + changed = merge_settings(current_config, settings) + + if not changed: + return { + "requestId": request_id, + "success": True, + "changed": False, + "data": None, + } + + if dry_run: + log(f"dry_run: would update {config_path}") + return { + "requestId": request_id, + "success": True, + "changed": changed, + "data": None, + } + + write_yaml(config_path, current_config) + log(f"Updated config: {config_path}") + + return { + "requestId": request_id, + "success": True, + "changed": True, + "data": None, + } + + except Exception as e: + log(f"Failed to apply config: {e}") + return { + "requestId": request_id, + "success": False, + "changed": False, + "error": str(e), + "data": None, + } + + +def handle(request: dict) -> dict: + request_id = request.get("requestId", "unknown") + command = request.get("command") + args = request.get("args", {}) + context = request.get("context", {}) + + if command == "check_installed": + return check_installed(args, request_id) + + if command == "apply": + if not isinstance(args, dict): + return { + "requestId": request_id, + "success": False, + "changed": False, + "error": "args must be an object", + "data": None, + } + if not isinstance(context, dict): + return { + "requestId": request_id, + "success": False, + "changed": False, + "error": "context must be an object", + "data": None, + } + settings = args.get("settings") + if settings is not None and not isinstance(settings, dict): + return { + "requestId": request_id, + "success": False, + "changed": False, + "error": "settings must be an object", + "data": None, + } + return apply_config(args, context, request_id) + + return { + "requestId": request_id, + "success": False, + "changed": False, + "error": f"Unknown command: {command}", + "data": None, + } + + +def main() -> None: + raw = sys.stdin.read() + if not raw or not raw.strip(): + sys.stdout.write( + json.dumps( + { + "requestId": "unknown", + "success": False, + "changed": False, + "error": "Empty input", + "data": None, + } + ) + + "\n" + ) + sys.stdout.flush() + return + + try: + request = json.loads(raw) + result = handle(request) + except Exception as error: + request_id = "unknown" + if "request" in locals() and isinstance(request, dict): + request_id = request.get("requestId", "unknown") + result = { + "requestId": request_id, + "success": False, + "changed": False, + "error": str(error), + "data": None, + } + + sys.stdout.write(json.dumps(result) + "\n") + sys.stdout.flush() + + +if __name__ == "__main__": + main() diff --git a/plugins/docker/src/plugin.py b/plugins/docker/src/plugin.py index 08d1831a..b6ffba03 100644 --- a/plugins/docker/src/plugin.py +++ b/plugins/docker/src/plugin.py @@ -1,171 +1,175 @@ -import datetime -import json -import os -import shutil -import sys -import uuid - - -def log(msg): - sys.stderr.write(f"[docker-plugin] {msg}\n") - sys.stderr.flush() - - -def get_config_path(): - appdata = os.getenv("APPDATA") - if not appdata: - raise Exception("APPDATA environment variable not found") - - config_dir = os.path.join(appdata, "Docker") - return os.path.join(config_dir, "settings.json") - - -def read_json(file_path: str) -> dict: - if not os.path.exists(file_path): - return {} - - try: - with open(file_path, "r", encoding="utf-8") as f: - data = json.load(f) - return data if isinstance(data, dict) else {} - except json.JSONDecodeError: - timestamp = datetime.datetime.now(datetime.timezone.utc).strftime("%Y%m%d%H%M%S") - suffix = uuid.uuid4().hex[:8] - backup_path = f"{file_path}.corrupted.{timestamp}.{suffix}" - log(f"Config corrupted. Backing up to {backup_path} and starting fresh.") - try: - shutil.move(file_path, backup_path) - except Exception as backup_e: - log(f"Failed to backup corrupted config: {backup_e}") - return {} - except OSError as e: - raise OSError(f"Could not read {file_path}: {e}") from e - - -def write_json(file_path: str, data: dict) -> None: - os.makedirs(os.path.dirname(file_path), exist_ok=True) - temp_path = file_path + ".tmp" - with open(temp_path, "w", encoding="utf-8") as f: - json.dump(data, f, indent=2) - os.replace(temp_path, file_path) - - -def merge_settings(target: dict, source: dict) -> bool: - changed = False - for key, value in source.items(): - if isinstance(value, dict): - if key not in target or not isinstance(target.get(key), dict): - target[key] = {} - changed = True - - # Recursive merge for deep dictionaries - if merge_settings(target[key], value): - changed = True - else: - if key not in target or target[key] != value: - target[key] = value - changed = True - return changed - - -def check_installed(args: dict, request_id: str) -> dict: - # Check for docker or docker.exe in PATH - installed = shutil.which("docker.exe") is not None or shutil.which("docker") is not None - return { - "requestId": request_id, - "success": True, - "changed": False, - "data": installed, - } - - -def apply_config(args: dict, context: dict, request_id: str) -> dict: - dry_run = context.get("dryRun", False) - settings = args.get("settings", {}) - - try: - config_path = get_config_path() - current_config = read_json(config_path) - - changed = merge_settings(current_config, settings) - - if not changed: - return { - "requestId": request_id, - "success": True, - "changed": False, - } - - if dry_run: - log(f"Would update {config_path} with new settings") - return { - "requestId": request_id, - "success": True, - "changed": changed, - } - - write_json(config_path, current_config) - log(f"Updated Docker config: {config_path}") - - return { - "requestId": request_id, - "success": True, - "changed": True, - } - - except Exception as e: - log(f"Failed to apply config: {e}") - return { - "requestId": request_id, - "success": False, - "changed": False, - "error": str(e), - } - - -def main(): - input_data = sys.stdin.read() - if not input_data: - return - - try: - request = json.loads(input_data) - except Exception as e: - log(f"Failed to parse request: {e}") - response = { - "requestId": "unknown", - "success": False, - "changed": False, - "error": f"Failed to parse request: {str(e)}", - } - sys.stdout.write(json.dumps(response) + "\n") - sys.stdout.flush() - return - - request_id = request.get("requestId", "unknown") - command = request.get("command") - args = request.get("args", {}) - context = request.get("context", {}) - - response = { - "requestId": request_id, - "success": False, - "changed": False, - } - - try: - if command == "check_installed": - response = check_installed(args, request_id) - elif command == "apply": - response = apply_config(args, context, request_id) - else: - response["error"] = f"Unknown command: {command}" - except Exception as fatal_err: - response["error"] = f"Internal Script Error: {str(fatal_err)}" - - sys.stdout.write(json.dumps(response) + "\n") - sys.stdout.flush() - - -if __name__ == "__main__": - main() +import datetime +import json +import os +import shutil +import sys +import uuid + + +def log(msg): + sys.stderr.write(f"[docker-plugin] {msg}\n") + sys.stderr.flush() + + +def get_config_path(): + appdata = os.getenv("APPDATA") + if not appdata: + raise Exception("APPDATA environment variable not found") + + config_dir = os.path.join(appdata, "Docker") + return os.path.join(config_dir, "settings.json") + + +def read_json(file_path: str) -> dict: + if not os.path.exists(file_path): + return {} + + try: + with open(file_path, "r", encoding="utf-8") as f: + data = json.load(f) + return data if isinstance(data, dict) else {} + except json.JSONDecodeError: + timestamp = datetime.datetime.now(datetime.timezone.utc).strftime("%Y%m%d%H%M%S") + suffix = uuid.uuid4().hex[:8] + backup_path = f"{file_path}.corrupted.{timestamp}.{suffix}" + log(f"Config corrupted. Backing up to {backup_path} and starting fresh.") + try: + shutil.move(file_path, backup_path) + except Exception as backup_e: + log(f"Failed to backup corrupted config: {backup_e}") + return {} + except OSError as e: + raise OSError(f"Could not read {file_path}: {e}") from e + + +def write_json(file_path: str, data: dict) -> None: + os.makedirs(os.path.dirname(file_path), exist_ok=True) + temp_path = file_path + ".tmp" + with open(temp_path, "w", encoding="utf-8") as f: + json.dump(data, f, indent=2) + os.replace(temp_path, file_path) + + +def merge_settings(target: dict, source: dict) -> bool: + changed = False + for key, value in source.items(): + if isinstance(value, dict): + if key not in target or not isinstance(target.get(key), dict): + target[key] = {} + changed = True + + # Recursive merge for deep dictionaries + if merge_settings(target[key], value): + changed = True + else: + if value == "": + if key in target: + del target[key] + changed = True + elif key not in target or target[key] != value: + target[key] = value + changed = True + return changed + + +def check_installed(args: dict, request_id: str) -> dict: + # Check for docker or docker.exe in PATH + installed = shutil.which("docker.exe") is not None or shutil.which("docker") is not None + return { + "requestId": request_id, + "success": True, + "changed": False, + "data": installed, + } + + +def apply_config(args: dict, context: dict, request_id: str) -> dict: + dry_run = context.get("dryRun", False) + settings = args.get("settings", {}) + + try: + config_path = get_config_path() + current_config = read_json(config_path) + + changed = merge_settings(current_config, settings) + + if not changed: + return { + "requestId": request_id, + "success": True, + "changed": False, + } + + if dry_run: + log(f"Would update {config_path} with new settings") + return { + "requestId": request_id, + "success": True, + "changed": changed, + } + + write_json(config_path, current_config) + log(f"Updated Docker config: {config_path}") + + return { + "requestId": request_id, + "success": True, + "changed": True, + } + + except Exception as e: + log(f"Failed to apply config: {e}") + return { + "requestId": request_id, + "success": False, + "changed": False, + "error": str(e), + } + + +def main(): + input_data = sys.stdin.read() + if not input_data: + return + + try: + request = json.loads(input_data) + except Exception as e: + log(f"Failed to parse request: {e}") + response = { + "requestId": "unknown", + "success": False, + "changed": False, + "error": f"Failed to parse request: {str(e)}", + } + sys.stdout.write(json.dumps(response) + "\n") + sys.stdout.flush() + return + + request_id = request.get("requestId", "unknown") + command = request.get("command") + args = request.get("args", {}) + context = request.get("context", {}) + + response = { + "requestId": request_id, + "success": False, + "changed": False, + } + + try: + if command == "check_installed": + response = check_installed(args, request_id) + elif command == "apply": + response = apply_config(args, context, request_id) + else: + response["error"] = f"Unknown command: {command}" + except Exception as fatal_err: + response["error"] = f"Internal Script Error: {str(fatal_err)}" + + sys.stdout.write(json.dumps(response) + "\n") + sys.stdout.flush() + + +if __name__ == "__main__": + main() diff --git a/plugins/espanso/test/test_espanso.py b/plugins/espanso/test/test_espanso.py index 38189016..9ddc250d 100644 --- a/plugins/espanso/test/test_espanso.py +++ b/plugins/espanso/test/test_espanso.py @@ -1,361 +1,361 @@ -""" -Tests for the Espanso WinHome plugin. - -Run with: pytest test/test_espanso.py -v -""" - -import json -import sys -from copy import deepcopy -from io import StringIO -from pathlib import Path -from unittest.mock import patch - -import pytest - -sys.path.insert(0, str(Path(__file__).parent.parent / "src")) -import plugin # noqa: E402 - -# --------------------------------------------------------------------------- -# Fixtures -# --------------------------------------------------------------------------- - - -@pytest.fixture() -def tmp_appdata(tmp_path, monkeypatch): - """Point APPDATA to a temp dir and return it.""" - monkeypatch.setenv("APPDATA", str(tmp_path)) - return tmp_path - - -@pytest.fixture() -def installed_appdata(tmp_appdata): - """APPDATA with the espanso directory already created.""" - (tmp_appdata / "espanso").mkdir() - return tmp_appdata - - -@pytest.fixture() -def existing_config(): - return { - "matches": [ - {"trigger": ":email", "replace": "old@example.com"}, - {"trigger": ":hello", "replace": "Hello there!"}, - ], - "global_vars": [ - {"name": "today", "type": "date", "params": {"format": "%Y-%m-%d"}}, - ], - } - - -# --------------------------------------------------------------------------- -# Helpers -# --------------------------------------------------------------------------- - - -def run_main(msg: dict) -> dict: - """Feed one JSON message through main() and return the parsed response.""" - stdin_data = json.dumps(msg) - with ( - patch("sys.stdin", StringIO(stdin_data)), - patch("sys.stdout", new_callable=StringIO) as mock_stdout, - ): - try: - plugin.main() - except SystemExit: - pass - return json.loads(mock_stdout.getvalue().strip()) - - -# --------------------------------------------------------------------------- -# get_base_yml_path -# --------------------------------------------------------------------------- - - -def test_get_base_yml_path(tmp_appdata): - result = plugin.get_base_yml_path() - assert result == tmp_appdata / "espanso" / "match" / "base.yml" - - -def test_get_base_yml_path_no_appdata(monkeypatch): - monkeypatch.delenv("APPDATA", raising=False) - with pytest.raises(EnvironmentError, match="APPDATA"): - plugin.get_base_yml_path() - - -# --------------------------------------------------------------------------- -# is_espanso_installed -# --------------------------------------------------------------------------- - - -def test_is_espanso_installed_true(installed_appdata): - assert plugin.is_espanso_installed() is True - - -def test_is_espanso_installed_false(tmp_appdata): - assert plugin.is_espanso_installed() is False - - -def test_is_espanso_installed_no_appdata(monkeypatch): - monkeypatch.delenv("APPDATA", raising=False) - assert plugin.is_espanso_installed() is False - - -# --------------------------------------------------------------------------- -# read_config / write_config -# --------------------------------------------------------------------------- - - -def test_read_config_missing(tmp_path): - assert plugin.read_config(tmp_path / "nonexistent.yml") == {} - - -def test_write_config_creates_dirs(tmp_path): - path = tmp_path / "a" / "b" / "base.yml" - plugin.write_config(path, {"matches": [{"trigger": ":t", "replace": "test"}]}) - assert path.exists() - - -def test_write_config_trailing_newline(tmp_path): - path = tmp_path / "base.yml" - plugin.write_config(path, {"matches": []}) - assert path.read_text(encoding="utf-8").endswith("\n") - - -# --------------------------------------------------------------------------- -# deep_merge_lists -# --------------------------------------------------------------------------- - - -def test_merge_lists_replaces(): - existing = [{"trigger": ":email", "replace": "old@example.com"}] - incoming = [{"trigger": ":email", "replace": "new@example.com"}] - result = plugin.deep_merge_lists(existing, incoming) - assert result == [{"trigger": ":email", "replace": "new@example.com"}] - - -def test_merge_lists_appends(): - existing = [{"trigger": ":email", "replace": "old@example.com"}] - incoming = [{"trigger": ":sig", "replace": "Best regards"}] - result = plugin.deep_merge_lists(existing, incoming) - assert len(result) == 2 - - -def test_merge_lists_preserves_untouched(): - existing = [ - {"trigger": ":email", "replace": "me@example.com"}, - {"trigger": ":hello", "replace": "Hello!"}, - ] - result = plugin.deep_merge_lists(existing, [{"trigger": ":email", "replace": "new@example.com"}]) - assert len(result) == 2 - assert result[1]["trigger"] == ":hello" - - -def test_merge_lists_custom_key(): - existing = [{"name": "today", "type": "date"}] - incoming = [{"name": "today", "type": "shell"}] - result = plugin.deep_merge_lists(existing, incoming, key="name") - assert result == [{"name": "today", "type": "shell"}] - - -# --------------------------------------------------------------------------- -# merge_config -# --------------------------------------------------------------------------- - - -def test_merge_config_detects_change(existing_config): - incoming = {"matches": [{"trigger": ":email", "replace": "new@example.com"}]} - _, changed = plugin.merge_config(existing_config, incoming) - assert changed is True - - -def test_merge_config_no_change(existing_config): - _, changed = plugin.merge_config( - existing_config, - { - "matches": deepcopy(existing_config["matches"]), - "global_vars": deepcopy(existing_config["global_vars"]), - }, - ) - assert changed is False - - -def test_merge_config_does_not_mutate(existing_config): - original = deepcopy(existing_config) - plugin.merge_config(existing_config, {"matches": [{"trigger": ":new", "replace": "x"}]}) - assert existing_config == original - - -def test_merge_config_preserves_existing(existing_config): - merged, _ = plugin.merge_config( - existing_config, - {"matches": [{"trigger": ":email", "replace": "new@example.com"}]}, - ) - triggers = [m["trigger"] for m in merged["matches"]] - assert ":hello" in triggers - assert ":email" in triggers - - -# --------------------------------------------------------------------------- -# handle_check_installed -# --------------------------------------------------------------------------- - - -def test_handle_check_installed_true(installed_appdata): - result = plugin.handle_check_installed("req-1", {}) - assert result == { - "requestId": "req-1", - "success": True, - "changed": False, - "data": {"installed": True}, - } - - -def test_handle_check_installed_false(tmp_appdata): - result = plugin.handle_check_installed("req-2", {}) - assert result["data"]["installed"] is False - assert result["success"] is True - assert result["changed"] is False - assert result["requestId"] == "req-2" - - -# --------------------------------------------------------------------------- -# handle_apply -# --------------------------------------------------------------------------- - - -def test_handle_apply_writes_file(tmp_path, monkeypatch): - base_yml = tmp_path / "espanso" / "match" / "base.yml" - monkeypatch.setattr(plugin, "get_base_yml_path", lambda: base_yml) - result = plugin.handle_apply( - "req-3", - {"matches": [{"trigger": ":email", "replace": "me@example.com"}]}, - ) - assert result == {"requestId": "req-3", "success": True, "changed": True} - assert base_yml.exists() - - -def test_handle_apply_dry_run_no_write(tmp_path, monkeypatch): - base_yml = tmp_path / "espanso" / "match" / "base.yml" - monkeypatch.setattr(plugin, "get_base_yml_path", lambda: base_yml) - result = plugin.handle_apply("req-4", {"matches": [{"trigger": ":t", "replace": "x"}]}, dry_run=True) - assert result == {"requestId": "req-4", "success": True, "changed": True} - assert not base_yml.exists() - - -def test_handle_apply_no_change_no_write(tmp_path, monkeypatch, existing_config): - base_yml = tmp_path / "espanso" / "match" / "base.yml" - base_yml.parent.mkdir(parents=True) - plugin.write_config(base_yml, existing_config) - mtime = base_yml.stat().st_mtime - monkeypatch.setattr(plugin, "get_base_yml_path", lambda: base_yml) - result = plugin.handle_apply("req-5", existing_config) - assert result == {"requestId": "req-5", "success": True, "changed": False} - assert base_yml.stat().st_mtime == mtime - - -def test_handle_apply_merges_not_overwrites(tmp_path, monkeypatch, existing_config): - base_yml = tmp_path / "espanso" / "match" / "base.yml" - base_yml.parent.mkdir(parents=True) - plugin.write_config(base_yml, existing_config) - monkeypatch.setattr(plugin, "get_base_yml_path", lambda: base_yml) - plugin.handle_apply( - "req-6", - {"matches": [{"trigger": ":email", "replace": "new@example.com"}]}, - ) - data = plugin.read_config(base_yml) - triggers = [m["trigger"] for m in data["matches"]] - assert ":hello" in triggers - - -def test_handle_apply_creates_missing_dirs(tmp_path, monkeypatch): - base_yml = tmp_path / "deep" / "nested" / "base.yml" - monkeypatch.setattr(plugin, "get_base_yml_path", lambda: base_yml) - result = plugin.handle_apply("req-7", {"matches": [{"trigger": ":t", "replace": "test"}]}) - assert result["success"] is True - assert base_yml.exists() - - -# --------------------------------------------------------------------------- -# Single-shot JSON-over-stdio protocol -# --------------------------------------------------------------------------- - - -def test_protocol_single_shot_check_installed(installed_appdata): - resp = run_main({"requestId": "r1", "command": "check_installed", "args": {}}) - assert resp["requestId"] == "r1" - assert resp["success"] is True - assert resp["data"]["installed"] is True - - -def test_protocol_single_shot_apply(tmp_path, monkeypatch): - base_yml = tmp_path / "espanso" / "match" / "base.yml" - monkeypatch.setattr(plugin, "get_base_yml_path", lambda: base_yml) - resp = run_main( - { - "requestId": "r2", - "command": "apply", - "args": {"matches": [{"trigger": ":email", "replace": "a@b.com"}]}, - "context": {}, - } - ) - assert resp["requestId"] == "r2" - assert resp["success"] is True - assert resp["changed"] is True - - -def test_protocol_dry_run_via_context(tmp_path, monkeypatch): - """dryRun must be read from context, not top-level.""" - base_yml = tmp_path / "espanso" / "match" / "base.yml" - monkeypatch.setattr(plugin, "get_base_yml_path", lambda: base_yml) - resp = run_main( - { - "requestId": "r3", - "command": "apply", - "args": {"matches": [{"trigger": ":t", "replace": "x"}]}, - "context": {"dryRun": True}, - } - ) - assert resp["success"] is True - assert resp["changed"] is True - assert not base_yml.exists(), "dry-run must NOT write the file" - - -def test_protocol_dry_run_top_level_ignored(tmp_path, monkeypatch): - """dry_run at top-level must NOT be honoured (WinHome sends context.dryRun).""" - base_yml = tmp_path / "espanso" / "match" / "base.yml" - monkeypatch.setattr(plugin, "get_base_yml_path", lambda: base_yml) - run_main( - { - "requestId": "r4", - "command": "apply", - "args": {"matches": [{"trigger": ":t", "replace": "x"}]}, - "dry_run": True, # old incorrect field — must be ignored - "context": {}, - } - ) - assert base_yml.exists(), "top-level dry_run must be ignored; file should have been written" - - -def test_protocol_unknown_command(): - resp = run_main({"requestId": "r5", "command": "bad_cmd", "args": {}}) - assert "error" in resp - assert resp["requestId"] == "r5" - - -def test_protocol_request_id_propagated(installed_appdata): - resp = run_main({"requestId": "my-unique-id", "command": "check_installed", "args": {}}) - assert resp["requestId"] == "my-unique-id" - - -def test_protocol_invalid_json(): - with ( - patch("sys.stdin", StringIO("not json")), - patch("sys.stdout", new_callable=StringIO) as mock_stdout, - ): - try: - plugin.main() - except SystemExit: - pass - resp = json.loads(mock_stdout.getvalue().strip()) - assert "error" in resp +""" +Tests for the Espanso WinHome plugin. + +Run with: pytest test/test_espanso.py -v +""" + +import json +import sys +from copy import deepcopy +from io import StringIO +from pathlib import Path +from unittest.mock import patch + +import pytest + +sys.path.insert(0, str(Path(__file__).parent.parent / "src")) +import plugin # noqa: E402 + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture() +def tmp_appdata(tmp_path, monkeypatch): + """Point APPDATA to a temp dir and return it.""" + monkeypatch.setenv("APPDATA", str(tmp_path)) + return tmp_path + + +@pytest.fixture() +def installed_appdata(tmp_appdata): + """APPDATA with the espanso directory already created.""" + (tmp_appdata / "espanso").mkdir() + return tmp_appdata + + +@pytest.fixture() +def existing_config(): + return { + "matches": [ + {"trigger": ":email", "replace": "old@example.com"}, + {"trigger": ":hello", "replace": "Hello there!"}, + ], + "global_vars": [ + {"name": "today", "type": "date", "params": {"format": "%Y-%m-%d"}}, + ], + } + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def run_main(msg: dict) -> dict: + """Feed one JSON message through main() and return the parsed response.""" + stdin_data = json.dumps(msg) + with ( + patch("sys.stdin", StringIO(stdin_data)), + patch("sys.stdout", new_callable=StringIO) as mock_stdout, + ): + try: + plugin.main() + except SystemExit: + pass + return json.loads(mock_stdout.getvalue().strip()) + + +# --------------------------------------------------------------------------- +# get_base_yml_path +# --------------------------------------------------------------------------- + + +def test_get_base_yml_path(tmp_appdata): + result = plugin.get_base_yml_path() + assert result == tmp_appdata / "espanso" / "match" / "base.yml" + + +def test_get_base_yml_path_no_appdata(monkeypatch): + monkeypatch.delenv("APPDATA", raising=False) + with pytest.raises(EnvironmentError, match="APPDATA"): + plugin.get_base_yml_path() + + +# --------------------------------------------------------------------------- +# is_espanso_installed +# --------------------------------------------------------------------------- + + +def test_is_espanso_installed_true(installed_appdata): + assert plugin.is_espanso_installed() is True + + +def test_is_espanso_installed_false(tmp_appdata): + assert plugin.is_espanso_installed() is False + + +def test_is_espanso_installed_no_appdata(monkeypatch): + monkeypatch.delenv("APPDATA", raising=False) + assert plugin.is_espanso_installed() is False + + +# --------------------------------------------------------------------------- +# read_config / write_config +# --------------------------------------------------------------------------- + + +def test_read_config_missing(tmp_path): + assert plugin.read_config(tmp_path / "nonexistent.yml") == {} + + +def test_write_config_creates_dirs(tmp_path): + path = tmp_path / "a" / "b" / "base.yml" + plugin.write_config(path, {"matches": [{"trigger": ":t", "replace": "test"}]}) + assert path.exists() + + +def test_write_config_trailing_newline(tmp_path): + path = tmp_path / "base.yml" + plugin.write_config(path, {"matches": []}) + assert path.read_text(encoding="utf-8").endswith("\n") + + +# --------------------------------------------------------------------------- +# deep_merge_lists +# --------------------------------------------------------------------------- + + +def test_merge_lists_replaces(): + existing = [{"trigger": ":email", "replace": "old@example.com"}] + incoming = [{"trigger": ":email", "replace": "new@example.com"}] + result = plugin.deep_merge_lists(existing, incoming) + assert result == [{"trigger": ":email", "replace": "new@example.com"}] + + +def test_merge_lists_appends(): + existing = [{"trigger": ":email", "replace": "old@example.com"}] + incoming = [{"trigger": ":sig", "replace": "Best regards"}] + result = plugin.deep_merge_lists(existing, incoming) + assert len(result) == 2 + + +def test_merge_lists_preserves_untouched(): + existing = [ + {"trigger": ":email", "replace": "me@example.com"}, + {"trigger": ":hello", "replace": "Hello!"}, + ] + result = plugin.deep_merge_lists(existing, [{"trigger": ":email", "replace": "new@example.com"}]) + assert len(result) == 2 + assert result[1]["trigger"] == ":hello" + + +def test_merge_lists_custom_key(): + existing = [{"name": "today", "type": "date"}] + incoming = [{"name": "today", "type": "shell"}] + result = plugin.deep_merge_lists(existing, incoming, key="name") + assert result == [{"name": "today", "type": "shell"}] + + +# --------------------------------------------------------------------------- +# merge_config +# --------------------------------------------------------------------------- + + +def test_merge_config_detects_change(existing_config): + incoming = {"matches": [{"trigger": ":email", "replace": "new@example.com"}]} + _, changed = plugin.merge_config(existing_config, incoming) + assert changed is True + + +def test_merge_config_no_change(existing_config): + _, changed = plugin.merge_config( + existing_config, + { + "matches": deepcopy(existing_config["matches"]), + "global_vars": deepcopy(existing_config["global_vars"]), + }, + ) + assert changed is False + + +def test_merge_config_does_not_mutate(existing_config): + original = deepcopy(existing_config) + plugin.merge_config(existing_config, {"matches": [{"trigger": ":new", "replace": "x"}]}) + assert existing_config == original + + +def test_merge_config_preserves_existing(existing_config): + merged, _ = plugin.merge_config( + existing_config, + {"matches": [{"trigger": ":email", "replace": "new@example.com"}]}, + ) + triggers = [m["trigger"] for m in merged["matches"]] + assert ":hello" in triggers + assert ":email" in triggers + + +# --------------------------------------------------------------------------- +# handle_check_installed +# --------------------------------------------------------------------------- + + +def test_handle_check_installed_true(installed_appdata): + result = plugin.handle_check_installed("req-1", {}) + assert result == { + "requestId": "req-1", + "success": True, + "changed": False, + "data": {"installed": True}, + } + + +def test_handle_check_installed_false(tmp_appdata): + result = plugin.handle_check_installed("req-2", {}) + assert result["data"]["installed"] is False + assert result["success"] is True + assert result["changed"] is False + assert result["requestId"] == "req-2" + + +# --------------------------------------------------------------------------- +# handle_apply +# --------------------------------------------------------------------------- + + +def test_handle_apply_writes_file(tmp_path, monkeypatch): + base_yml = tmp_path / "espanso" / "match" / "base.yml" + monkeypatch.setattr(plugin, "get_base_yml_path", lambda: base_yml) + result = plugin.handle_apply( + "req-3", + {"matches": [{"trigger": ":email", "replace": "me@example.com"}]}, + ) + assert result == {"requestId": "req-3", "success": True, "changed": True} + assert base_yml.exists() + + +def test_handle_apply_dry_run_no_write(tmp_path, monkeypatch): + base_yml = tmp_path / "espanso" / "match" / "base.yml" + monkeypatch.setattr(plugin, "get_base_yml_path", lambda: base_yml) + result = plugin.handle_apply("req-4", {"matches": [{"trigger": ":t", "replace": "x"}]}, dry_run=True) + assert result == {"requestId": "req-4", "success": True, "changed": True} + assert not base_yml.exists() + + +def test_handle_apply_no_change_no_write(tmp_path, monkeypatch, existing_config): + base_yml = tmp_path / "espanso" / "match" / "base.yml" + base_yml.parent.mkdir(parents=True) + plugin.write_config(base_yml, existing_config) + mtime = base_yml.stat().st_mtime + monkeypatch.setattr(plugin, "get_base_yml_path", lambda: base_yml) + result = plugin.handle_apply("req-5", existing_config) + assert result == {"requestId": "req-5", "success": True, "changed": False} + assert base_yml.stat().st_mtime == mtime + + +def test_handle_apply_merges_not_overwrites(tmp_path, monkeypatch, existing_config): + base_yml = tmp_path / "espanso" / "match" / "base.yml" + base_yml.parent.mkdir(parents=True) + plugin.write_config(base_yml, existing_config) + monkeypatch.setattr(plugin, "get_base_yml_path", lambda: base_yml) + plugin.handle_apply( + "req-6", + {"matches": [{"trigger": ":email", "replace": "new@example.com"}]}, + ) + data = plugin.read_config(base_yml) + triggers = [m["trigger"] for m in data["matches"]] + assert ":hello" in triggers + + +def test_handle_apply_creates_missing_dirs(tmp_path, monkeypatch): + base_yml = tmp_path / "deep" / "nested" / "base.yml" + monkeypatch.setattr(plugin, "get_base_yml_path", lambda: base_yml) + result = plugin.handle_apply("req-7", {"matches": [{"trigger": ":t", "replace": "test"}]}) + assert result["success"] is True + assert base_yml.exists() + + +# --------------------------------------------------------------------------- +# Single-shot JSON-over-stdio protocol +# --------------------------------------------------------------------------- + + +def test_protocol_single_shot_check_installed(installed_appdata): + resp = run_main({"requestId": "r1", "command": "check_installed", "args": {}}) + assert resp["requestId"] == "r1" + assert resp["success"] is True + assert resp["data"]["installed"] is True + + +def test_protocol_single_shot_apply(tmp_path, monkeypatch): + base_yml = tmp_path / "espanso" / "match" / "base.yml" + monkeypatch.setattr(plugin, "get_base_yml_path", lambda: base_yml) + resp = run_main( + { + "requestId": "r2", + "command": "apply", + "args": {"matches": [{"trigger": ":email", "replace": "a@b.com"}]}, + "context": {}, + } + ) + assert resp["requestId"] == "r2" + assert resp["success"] is True + assert resp["changed"] is True + + +def test_protocol_dry_run_via_context(tmp_path, monkeypatch): + """dryRun must be read from context, not top-level.""" + base_yml = tmp_path / "espanso" / "match" / "base.yml" + monkeypatch.setattr(plugin, "get_base_yml_path", lambda: base_yml) + resp = run_main( + { + "requestId": "r3", + "command": "apply", + "args": {"matches": [{"trigger": ":t", "replace": "x"}]}, + "context": {"dryRun": True}, + } + ) + assert resp["success"] is True + assert resp["changed"] is True + assert not base_yml.exists(), "dry-run must NOT write the file" + + +def test_protocol_dry_run_top_level_ignored(tmp_path, monkeypatch): + """dry_run at top-level must NOT be honoured (WinHome sends context.dryRun).""" + base_yml = tmp_path / "espanso" / "match" / "base.yml" + monkeypatch.setattr(plugin, "get_base_yml_path", lambda: base_yml) + run_main( + { + "requestId": "r4", + "command": "apply", + "args": {"matches": [{"trigger": ":t", "replace": "x"}]}, + "dryRun": True, # old incorrect field — must be ignored + "context": {}, + } + ) + assert base_yml.exists(), "top-level dry_run must be ignored; file should have been written" + + +def test_protocol_unknown_command(): + resp = run_main({"requestId": "r5", "command": "bad_cmd", "args": {}}) + assert "error" in resp + assert resp["requestId"] == "r5" + + +def test_protocol_request_id_propagated(installed_appdata): + resp = run_main({"requestId": "my-unique-id", "command": "check_installed", "args": {}}) + assert resp["requestId"] == "my-unique-id" + + +def test_protocol_invalid_json(): + with ( + patch("sys.stdin", StringIO("not json")), + patch("sys.stdout", new_callable=StringIO) as mock_stdout, + ): + try: + plugin.main() + except SystemExit: + pass + resp = json.loads(mock_stdout.getvalue().strip()) + assert "error" in resp diff --git a/plugins/gh/src/plugin.py b/plugins/gh/src/plugin.py index c93021c7..e013f3d2 100644 --- a/plugins/gh/src/plugin.py +++ b/plugins/gh/src/plugin.py @@ -1,162 +1,162 @@ -# /// script -# dependencies = [ -# "pyyaml", -# ] -# /// - -import json -import os -import shutil -import sys - -try: - import yaml -except ImportError: - yaml = None - - -def get_config_path() -> str: - return os.path.join(os.environ.get("APPDATA", ""), "GitHub CLI", "config.yml") - - -def log(message: str) -> None: - sys.stderr.write(f"[gh-plugin] {message}\n") - sys.stderr.flush() - - -def read_yaml(file_path: str) -> dict: - if yaml is None: - raise RuntimeError("PyYAML is required to read or write gh config") - - if not os.path.exists(file_path): - return {} - - with open(file_path, "r", encoding="utf-8") as file_handle: - data = yaml.safe_load(file_handle) - return data if isinstance(data, dict) else {} - - -def write_yaml(file_path: str, data: dict) -> None: - if yaml is None: - raise RuntimeError("PyYAML is required to read or write gh config") - - os.makedirs(os.path.dirname(file_path), exist_ok=True) - with open(file_path, "w", encoding="utf-8") as file_handle: - yaml.dump(data, file_handle, default_flow_style=False, sort_keys=False) - - -def merge_settings(target: dict, source: dict) -> bool: - changed = False - - for key, value in source.items(): - if value == "": - continue - - current_value = target.get(key) - if isinstance(value, dict): - if not isinstance(current_value, dict): - target[key] = {} - current_value = target[key] - changed = True - - if merge_settings(current_value, value): - changed = True - elif current_value != value: - target[key] = value - changed = True - - return changed - - -def get_config_target(config: dict) -> dict: - nested_args = config.get("args") - if isinstance(nested_args, dict): - return nested_args - return config - - -def check_installed(request_id: str) -> dict: - installed = shutil.which("gh") is not None or shutil.which("gh.exe") is not None - return { - "requestId": request_id, - "success": True, - "changed": False, - "data": installed, - } - - -def apply_config(request_id: str, args: dict, context: dict) -> dict: - dry_run = bool(context.get("dryRun", False)) - updates = {key: value for key, value in args.items() if key != "dry_run"} - - config_path = get_config_path() - if yaml is None: - raise RuntimeError("PyYAML is required to read or write gh config") - - current_config = read_yaml(config_path) - target = get_config_target(current_config) - changed = merge_settings(target, updates) - - if dry_run: - if changed: - log(f"dry_run: would update {config_path}") - else: - log(f"dry_run: no changes for {config_path}") - return { - "requestId": request_id, - "success": True, - "changed": changed, - } - - if changed: - write_yaml(config_path, current_config) - - return { - "requestId": request_id, - "success": True, - "changed": changed, - } - - -def handle(request: dict) -> dict: - request_id = request.get("requestId", "unknown") - command = request.get("command") - args = request.get("args", {}) - context = request.get("context", {}) - - if command == "check_installed": - return check_installed(request_id) - if command == "apply": - if not isinstance(args, dict): - raise ValueError("args must be an object") - if not isinstance(context, dict): - raise ValueError("context must be an object") - return apply_config(request_id, args, context) - - raise ValueError(f"Unknown command: {command}") - - -def main() -> None: - raw = sys.stdin.read() - if not raw: - return - - try: - request = json.loads(raw) - result = handle(request) - except Exception as error: - result = { - "requestId": request.get("requestId", "unknown") - if "request" in locals() and isinstance(request, dict) - else "unknown", - "success": False, - "changed": False, - "error": str(error), - } - - sys.stdout.write(json.dumps(result) + "\n") - sys.stdout.flush() - - -if __name__ == "__main__": - main() +# /// script +# dependencies = [ +# "pyyaml", +# ] +# /// + +import json +import os +import shutil +import sys + +try: + import yaml +except ImportError: + yaml = None + + +def get_config_path() -> str: + return os.path.join(os.environ.get("APPDATA", ""), "GitHub CLI", "config.yml") + + +def log(message: str) -> None: + sys.stderr.write(f"[gh-plugin] {message}\n") + sys.stderr.flush() + + +def read_yaml(file_path: str) -> dict: + if yaml is None: + raise RuntimeError("PyYAML is required to read or write gh config") + + if not os.path.exists(file_path): + return {} + + with open(file_path, "r", encoding="utf-8") as file_handle: + data = yaml.safe_load(file_handle) + return data if isinstance(data, dict) else {} + + +def write_yaml(file_path: str, data: dict) -> None: + if yaml is None: + raise RuntimeError("PyYAML is required to read or write gh config") + + os.makedirs(os.path.dirname(file_path), exist_ok=True) + with open(file_path, "w", encoding="utf-8") as file_handle: + yaml.dump(data, file_handle, default_flow_style=False, sort_keys=False) + + +def merge_settings(target: dict, source: dict) -> bool: + changed = False + + for key, value in source.items(): + if value == "": + continue + + current_value = target.get(key) + if isinstance(value, dict): + if not isinstance(current_value, dict): + target[key] = {} + current_value = target[key] + changed = True + + if merge_settings(current_value, value): + changed = True + elif current_value != value: + target[key] = value + changed = True + + return changed + + +def get_config_target(config: dict) -> dict: + nested_args = config.get("args") + if isinstance(nested_args, dict): + return nested_args + return config + + +def check_installed(request_id: str) -> dict: + installed = shutil.which("gh") is not None or shutil.which("gh.exe") is not None + return { + "requestId": request_id, + "success": True, + "changed": False, + "data": installed, + } + + +def apply_config(request_id: str, args: dict, context: dict) -> dict: + dry_run = bool(context.get("dryRun", False)) + updates = {key: value for key, value in args.items() if key != "dryRun"} + + config_path = get_config_path() + if yaml is None: + raise RuntimeError("PyYAML is required to read or write gh config") + + current_config = read_yaml(config_path) + target = get_config_target(current_config) + changed = merge_settings(target, updates) + + if dry_run: + if changed: + log(f"dry_run: would update {config_path}") + else: + log(f"dry_run: no changes for {config_path}") + return { + "requestId": request_id, + "success": True, + "changed": changed, + } + + if changed: + write_yaml(config_path, current_config) + + return { + "requestId": request_id, + "success": True, + "changed": changed, + } + + +def handle(request: dict) -> dict: + request_id = request.get("requestId", "unknown") + command = request.get("command") + args = request.get("args", {}) + context = request.get("context", {}) + + if command == "check_installed": + return check_installed(request_id) + if command == "apply": + if not isinstance(args, dict): + raise ValueError("args must be an object") + if not isinstance(context, dict): + raise ValueError("context must be an object") + return apply_config(request_id, args, context) + + raise ValueError(f"Unknown command: {command}") + + +def main() -> None: + raw = sys.stdin.read() + if not raw: + return + + try: + request = json.loads(raw) + result = handle(request) + except Exception as error: + result = { + "requestId": request.get("requestId", "unknown") + if "request" in locals() and isinstance(request, dict) + else "unknown", + "success": False, + "changed": False, + "error": str(error), + } + + sys.stdout.write(json.dumps(result) + "\n") + sys.stdout.flush() + + +if __name__ == "__main__": + main() diff --git a/plugins/gh/test/test_gh.py b/plugins/gh/test/test_gh.py index 1243e1a9..b998ebc8 100644 --- a/plugins/gh/test/test_gh.py +++ b/plugins/gh/test/test_gh.py @@ -1,213 +1,213 @@ -#!/usr/bin/env python3 -# /// script -# dependencies = [ -# "pyyaml", -# ] -# /// - -import json -import os -import sys -import tempfile -import unittest -from io import StringIO -from unittest.mock import patch - -import yaml - -sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "src"))) -import plugin - - -class TestGhPlugin(unittest.TestCase): - def run_main(self, payload: dict) -> dict: - stdin = StringIO(json.dumps(payload) + "\n") - stdout = StringIO() - with patch("sys.stdin", stdin), patch("sys.stdout", stdout): - plugin.main() - return json.loads(stdout.getvalue().strip()) - - def test_check_installed_returns_true_when_gh_is_found(self): - with patch( - "plugin.shutil.which", - side_effect=lambda name: "C:/Program Files/gh/gh.exe" if name in {"gh", "gh.exe"} else None, - ): - response = self.run_main({"requestId": "req-1", "command": "check_installed", "args": {}}) - - self.assertEqual(response["requestId"], "req-1") - self.assertTrue(response["success"]) - self.assertFalse(response["changed"]) - self.assertTrue(response["data"]) - - def test_check_installed_returns_false_when_gh_is_missing(self): - with patch("plugin.shutil.which", return_value=None): - response = self.run_main({"requestId": "req-2", "command": "check_installed", "args": {}}) - - self.assertEqual(response["requestId"], "req-2") - self.assertTrue(response["success"]) - self.assertFalse(response["changed"]) - self.assertFalse(response["data"]) - - def test_apply_writes_merged_config_and_returns_changed_true(self): - with tempfile.TemporaryDirectory() as tmp_dir: - config_dir = os.path.join(tmp_dir, "GitHub CLI") - config_path = os.path.join(config_dir, "config.yml") - os.makedirs(config_dir, exist_ok=True) - with open(config_path, "w", encoding="utf-8") as file_handle: - yaml.dump( - {"prompt": "disabled", "existing": "keep"}, - file_handle, - default_flow_style=False, - sort_keys=False, - ) - - with patch("plugin.get_config_path", return_value=config_path): - response = self.run_main( - { - "requestId": "req-3", - "command": "apply", - "args": { - "git_protocol": "https", - "editor": "code --wait", - "prompt": "enabled", - "pager": "less", - "http_unix_socket": "", - "browser": "", - }, - "context": {"dryRun": False}, - } - ) - - self.assertEqual(response["requestId"], "req-3") - self.assertTrue(response["success"]) - self.assertTrue(response["changed"]) - - with open(config_path, "r", encoding="utf-8") as file_handle: - content = yaml.safe_load(file_handle) - - self.assertEqual(content["git_protocol"], "https") - self.assertEqual(content["editor"], "code --wait") - self.assertEqual(content["prompt"], "enabled") - self.assertEqual(content["pager"], "less") - self.assertNotIn("http_unix_socket", content) - self.assertNotIn("browser", content) - self.assertEqual(content["existing"], "keep") - - def test_apply_with_no_changes_returns_changed_false(self): - with tempfile.TemporaryDirectory() as tmp_dir: - config_dir = os.path.join(tmp_dir, "GitHub CLI") - config_path = os.path.join(config_dir, "config.yml") - os.makedirs(config_dir, exist_ok=True) - initial_content = { - "git_protocol": "https", - "editor": "code --wait", - "prompt": "enabled", - "pager": "less", - } - with open(config_path, "w", encoding="utf-8") as file_handle: - yaml.dump( - initial_content, - file_handle, - default_flow_style=False, - sort_keys=False, - ) - - with patch("plugin.get_config_path", return_value=config_path): - response = self.run_main( - { - "requestId": "req-4", - "command": "apply", - "args": { - "git_protocol": "https", - "editor": "code --wait", - "prompt": "enabled", - "pager": "less", - "http_unix_socket": "", - "browser": "", - }, - "context": {"dryRun": False}, - } - ) - - self.assertEqual(response["requestId"], "req-4") - self.assertTrue(response["success"]) - self.assertFalse(response["changed"]) - - with open(config_path, "r", encoding="utf-8") as file_handle: - content = yaml.safe_load(file_handle) - - self.assertEqual(content, initial_content) - - def test_apply_with_dry_run_does_not_write_file(self): - with tempfile.TemporaryDirectory() as tmp_dir: - config_path = os.path.join(tmp_dir, "GitHub CLI", "config.yml") - - with patch("plugin.get_config_path", return_value=config_path): - response = self.run_main( - { - "requestId": "req-5", - "command": "apply", - "args": {"git_protocol": "https", "dry_run": True}, - "context": {"dryRun": True}, - } - ) - - self.assertEqual(response["requestId"], "req-5") - self.assertTrue(response["success"]) - self.assertTrue(response["changed"]) - self.assertFalse(os.path.exists(config_path)) - - def test_apply_creates_missing_directory(self): - with tempfile.TemporaryDirectory() as tmp_dir: - config_path = os.path.join(tmp_dir, "GitHub CLI", "config.yml") - self.assertFalse(os.path.exists(os.path.dirname(config_path))) - - with patch("plugin.get_config_path", return_value=config_path): - response = self.run_main( - { - "requestId": "req-6", - "command": "apply", - "args": {"git_protocol": "https"}, - "context": {"dryRun": False}, - } - ) - - self.assertEqual(response["requestId"], "req-6") - self.assertTrue(response["success"]) - self.assertTrue(response["changed"]) - self.assertTrue(os.path.isdir(os.path.dirname(config_path))) - self.assertTrue(os.path.exists(config_path)) - - def test_apply_returns_error_when_pyyaml_is_missing(self): - with patch.object(plugin, "yaml", None): - response = self.run_main( - { - "requestId": "req-7", - "command": "apply", - "args": {"git_protocol": "https"}, - "context": {"dryRun": False}, - } - ) - - self.assertEqual(response["requestId"], "req-7") - self.assertFalse(response["success"]) - self.assertFalse(response["changed"]) - self.assertIn("PyYAML", response["error"]) - - def test_unknown_command_returns_error(self): - response = self.run_main( - { - "requestId": "req-8", - "command": "explode", - "args": {}, - "context": {}, - } - ) - self.assertEqual(response["requestId"], "req-8") - self.assertFalse(response["success"]) - self.assertFalse(response["changed"]) - self.assertIn("Unknown command", response["error"]) - - -if __name__ == "__main__": - unittest.main() +#!/usr/bin/env python3 +# /// script +# dependencies = [ +# "pyyaml", +# ] +# /// + +import json +import os +import sys +import tempfile +import unittest +from io import StringIO +from unittest.mock import patch + +import yaml + +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "src"))) +import plugin + + +class TestGhPlugin(unittest.TestCase): + def run_main(self, payload: dict) -> dict: + stdin = StringIO(json.dumps(payload) + "\n") + stdout = StringIO() + with patch("sys.stdin", stdin), patch("sys.stdout", stdout): + plugin.main() + return json.loads(stdout.getvalue().strip()) + + def test_check_installed_returns_true_when_gh_is_found(self): + with patch( + "plugin.shutil.which", + side_effect=lambda name: "C:/Program Files/gh/gh.exe" if name in {"gh", "gh.exe"} else None, + ): + response = self.run_main({"requestId": "req-1", "command": "check_installed", "args": {}}) + + self.assertEqual(response["requestId"], "req-1") + self.assertTrue(response["success"]) + self.assertFalse(response["changed"]) + self.assertTrue(response["data"]) + + def test_check_installed_returns_false_when_gh_is_missing(self): + with patch("plugin.shutil.which", return_value=None): + response = self.run_main({"requestId": "req-2", "command": "check_installed", "args": {}}) + + self.assertEqual(response["requestId"], "req-2") + self.assertTrue(response["success"]) + self.assertFalse(response["changed"]) + self.assertFalse(response["data"]) + + def test_apply_writes_merged_config_and_returns_changed_true(self): + with tempfile.TemporaryDirectory() as tmp_dir: + config_dir = os.path.join(tmp_dir, "GitHub CLI") + config_path = os.path.join(config_dir, "config.yml") + os.makedirs(config_dir, exist_ok=True) + with open(config_path, "w", encoding="utf-8") as file_handle: + yaml.dump( + {"prompt": "disabled", "existing": "keep"}, + file_handle, + default_flow_style=False, + sort_keys=False, + ) + + with patch("plugin.get_config_path", return_value=config_path): + response = self.run_main( + { + "requestId": "req-3", + "command": "apply", + "args": { + "git_protocol": "https", + "editor": "code --wait", + "prompt": "enabled", + "pager": "less", + "http_unix_socket": "", + "browser": "", + }, + "context": {"dryRun": False}, + } + ) + + self.assertEqual(response["requestId"], "req-3") + self.assertTrue(response["success"]) + self.assertTrue(response["changed"]) + + with open(config_path, "r", encoding="utf-8") as file_handle: + content = yaml.safe_load(file_handle) + + self.assertEqual(content["git_protocol"], "https") + self.assertEqual(content["editor"], "code --wait") + self.assertEqual(content["prompt"], "enabled") + self.assertEqual(content["pager"], "less") + self.assertNotIn("http_unix_socket", content) + self.assertNotIn("browser", content) + self.assertEqual(content["existing"], "keep") + + def test_apply_with_no_changes_returns_changed_false(self): + with tempfile.TemporaryDirectory() as tmp_dir: + config_dir = os.path.join(tmp_dir, "GitHub CLI") + config_path = os.path.join(config_dir, "config.yml") + os.makedirs(config_dir, exist_ok=True) + initial_content = { + "git_protocol": "https", + "editor": "code --wait", + "prompt": "enabled", + "pager": "less", + } + with open(config_path, "w", encoding="utf-8") as file_handle: + yaml.dump( + initial_content, + file_handle, + default_flow_style=False, + sort_keys=False, + ) + + with patch("plugin.get_config_path", return_value=config_path): + response = self.run_main( + { + "requestId": "req-4", + "command": "apply", + "args": { + "git_protocol": "https", + "editor": "code --wait", + "prompt": "enabled", + "pager": "less", + "http_unix_socket": "", + "browser": "", + }, + "context": {"dryRun": False}, + } + ) + + self.assertEqual(response["requestId"], "req-4") + self.assertTrue(response["success"]) + self.assertFalse(response["changed"]) + + with open(config_path, "r", encoding="utf-8") as file_handle: + content = yaml.safe_load(file_handle) + + self.assertEqual(content, initial_content) + + def test_apply_with_dry_run_does_not_write_file(self): + with tempfile.TemporaryDirectory() as tmp_dir: + config_path = os.path.join(tmp_dir, "GitHub CLI", "config.yml") + + with patch("plugin.get_config_path", return_value=config_path): + response = self.run_main( + { + "requestId": "req-5", + "command": "apply", + "args": {"git_protocol": "https", "dryRun": True}, + "context": {"dryRun": True}, + } + ) + + self.assertEqual(response["requestId"], "req-5") + self.assertTrue(response["success"]) + self.assertTrue(response["changed"]) + self.assertFalse(os.path.exists(config_path)) + + def test_apply_creates_missing_directory(self): + with tempfile.TemporaryDirectory() as tmp_dir: + config_path = os.path.join(tmp_dir, "GitHub CLI", "config.yml") + self.assertFalse(os.path.exists(os.path.dirname(config_path))) + + with patch("plugin.get_config_path", return_value=config_path): + response = self.run_main( + { + "requestId": "req-6", + "command": "apply", + "args": {"git_protocol": "https"}, + "context": {"dryRun": False}, + } + ) + + self.assertEqual(response["requestId"], "req-6") + self.assertTrue(response["success"]) + self.assertTrue(response["changed"]) + self.assertTrue(os.path.isdir(os.path.dirname(config_path))) + self.assertTrue(os.path.exists(config_path)) + + def test_apply_returns_error_when_pyyaml_is_missing(self): + with patch.object(plugin, "yaml", None): + response = self.run_main( + { + "requestId": "req-7", + "command": "apply", + "args": {"git_protocol": "https"}, + "context": {"dryRun": False}, + } + ) + + self.assertEqual(response["requestId"], "req-7") + self.assertFalse(response["success"]) + self.assertFalse(response["changed"]) + self.assertIn("PyYAML", response["error"]) + + def test_unknown_command_returns_error(self): + response = self.run_main( + { + "requestId": "req-8", + "command": "explode", + "args": {}, + "context": {}, + } + ) + self.assertEqual(response["requestId"], "req-8") + self.assertFalse(response["success"]) + self.assertFalse(response["changed"]) + self.assertIn("Unknown command", response["error"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/plugins/helix-editor/src/plugin.py b/plugins/helix-editor/src/plugin.py index fac4bf13..9c5dfa98 100644 --- a/plugins/helix-editor/src/plugin.py +++ b/plugins/helix-editor/src/plugin.py @@ -133,7 +133,11 @@ def merge_settings(target: dict, source: dict) -> bool: target[key].append(item) changed = True else: - if key not in target or target[key] != value: + if value == "": + if key in target: + del target[key] + changed = True + elif key not in target or target[key] != value: target[key] = value changed = True return changed diff --git a/plugins/lazygit/src/plugin.py b/plugins/lazygit/src/plugin.py index fb65a2a3..97d189a0 100644 --- a/plugins/lazygit/src/plugin.py +++ b/plugins/lazygit/src/plugin.py @@ -1,156 +1,160 @@ -# /// script -# dependencies = [ -# "pyyaml", -# ] -# /// - -import json -import os -import shutil -import sys - -import yaml - - -def log(msg): - sys.stderr.write(f"[lazygit-plugin] {msg}\n") - sys.stderr.flush() - - -def get_config_path(): - appdata = os.getenv("APPDATA") - if not appdata: - raise Exception("APPDATA environment variable not found") - - config_dir = os.path.join(appdata, "lazygit") - return os.path.join(config_dir, "config.yml") - - -def read_yaml(file_path: str) -> dict: - if not os.path.exists(file_path): - return {} - - try: - with open(file_path, "r", encoding="utf-8") as f: - data = yaml.safe_load(f) - return data if isinstance(data, dict) else {} - except Exception as e: - raise Exception(f"Could not parse {file_path}: {e}") from e - - -def write_yaml(file_path: str, data: dict) -> None: - os.makedirs(os.path.dirname(file_path), exist_ok=True) - with open(file_path, "w", encoding="utf-8") as f: - yaml.dump(data, f, default_flow_style=False, sort_keys=False) - - -def merge_settings(target: dict, source: dict) -> bool: - changed = False - for key, value in source.items(): - if isinstance(value, dict): - if key not in target or not isinstance(target.get(key), dict): - target[key] = {} - changed = True - - # Recursive merge for deep dictionaries - if merge_settings(target[key], value): - changed = True - else: - if key not in target or target[key] != value: - target[key] = value - changed = True - return changed - - -def check_installed(args: dict, request_id: str) -> dict: - installed = shutil.which("lazygit.exe") is not None or shutil.which("lazygit") is not None - return { - "requestId": request_id, - "success": True, - "changed": False, - "data": {"installed": installed}, - } - - -def apply_config(args: dict, context: dict, request_id: str) -> dict: - dry_run = context.get("dryRun", False) - settings = args - - try: - config_path = get_config_path() - current_config = read_yaml(config_path) - - changed = merge_settings(current_config, settings) - - if not changed: - return { - "requestId": request_id, - "success": True, - "changed": False, - } - - if dry_run: - log(f"Would update {config_path} with new settings") - return { - "requestId": request_id, - "success": True, - "changed": False, - } - - write_yaml(config_path, current_config) - log(f"Updated lazygit config: {config_path}") - - return { - "requestId": request_id, - "success": True, - "changed": True, - } - - except Exception as e: - log(f"Failed to apply config: {e}") - return { - "requestId": request_id, - "success": False, - "changed": False, - "error": str(e), - } - - -def main(): - input_data = sys.stdin.read() - if not input_data: - return - - try: - request = json.loads(input_data) - except Exception as e: - log(f"Failed to parse request: {e}") - sys.exit(1) - - request_id = request.get("requestId", "unknown") - command = request.get("command") - args = request.get("args", {}) - context = request.get("context", {}) - - response = { - "requestId": request_id, - "success": False, - "changed": False, - } - - try: - if command == "check_installed": - response = check_installed(args, request_id) - elif command == "apply": - response = apply_config(args, context, request_id) - else: - response["error"] = f"Unknown command: {command}" - except Exception as fatal_err: - response["error"] = f"Internal Script Error: {str(fatal_err)}" - - sys.stdout.write(json.dumps(response) + "\n") - sys.stdout.flush() - - -if __name__ == "__main__": - main() +# /// script +# dependencies = [ +# "pyyaml", +# ] +# /// + +import json +import os +import shutil +import sys + +import yaml + + +def log(msg): + sys.stderr.write(f"[lazygit-plugin] {msg}\n") + sys.stderr.flush() + + +def get_config_path(): + appdata = os.getenv("APPDATA") + if not appdata: + raise Exception("APPDATA environment variable not found") + + config_dir = os.path.join(appdata, "lazygit") + return os.path.join(config_dir, "config.yml") + + +def read_yaml(file_path: str) -> dict: + if not os.path.exists(file_path): + return {} + + try: + with open(file_path, "r", encoding="utf-8") as f: + data = yaml.safe_load(f) + return data if isinstance(data, dict) else {} + except Exception as e: + raise Exception(f"Could not parse {file_path}: {e}") from e + + +def write_yaml(file_path: str, data: dict) -> None: + os.makedirs(os.path.dirname(file_path), exist_ok=True) + with open(file_path, "w", encoding="utf-8") as f: + yaml.dump(data, f, default_flow_style=False, sort_keys=False) + + +def merge_settings(target: dict, source: dict) -> bool: + changed = False + for key, value in source.items(): + if isinstance(value, dict): + if key not in target or not isinstance(target.get(key), dict): + target[key] = {} + changed = True + + # Recursive merge for deep dictionaries + if merge_settings(target[key], value): + changed = True + else: + if value == "": + if key in target: + del target[key] + changed = True + elif key not in target or target[key] != value: + target[key] = value + changed = True + return changed + + +def check_installed(args: dict, request_id: str) -> dict: + installed = shutil.which("lazygit.exe") is not None or shutil.which("lazygit") is not None + return { + "requestId": request_id, + "success": True, + "changed": False, + "data": {"installed": installed}, + } + + +def apply_config(args: dict, context: dict, request_id: str) -> dict: + dry_run = context.get("dryRun", False) + settings = args + + try: + config_path = get_config_path() + current_config = read_yaml(config_path) + + changed = merge_settings(current_config, settings) + + if not changed: + return { + "requestId": request_id, + "success": True, + "changed": False, + } + + if dry_run: + log(f"Would update {config_path} with new settings") + return { + "requestId": request_id, + "success": True, + "changed": False, + } + + write_yaml(config_path, current_config) + log(f"Updated lazygit config: {config_path}") + + return { + "requestId": request_id, + "success": True, + "changed": True, + } + + except Exception as e: + log(f"Failed to apply config: {e}") + return { + "requestId": request_id, + "success": False, + "changed": False, + "error": str(e), + } + + +def main(): + input_data = sys.stdin.read() + if not input_data: + return + + try: + request = json.loads(input_data) + except Exception as e: + log(f"Failed to parse request: {e}") + sys.exit(1) + + request_id = request.get("requestId", "unknown") + command = request.get("command") + args = request.get("args", {}) + context = request.get("context", {}) + + response = { + "requestId": request_id, + "success": False, + "changed": False, + } + + try: + if command == "check_installed": + response = check_installed(args, request_id) + elif command == "apply": + response = apply_config(args, context, request_id) + else: + response["error"] = f"Unknown command: {command}" + except Exception as fatal_err: + response["error"] = f"Internal Script Error: {str(fatal_err)}" + + sys.stdout.write(json.dumps(response) + "\n") + sys.stdout.flush() + + +if __name__ == "__main__": + main() diff --git a/plugins/mise/src/plugin.py b/plugins/mise/src/plugin.py index 488965e9..401dd93b 100644 --- a/plugins/mise/src/plugin.py +++ b/plugins/mise/src/plugin.py @@ -1,245 +1,249 @@ -import json -import os -import shutil -import sys -import tempfile -import uuid - - -def log(msg): - sys.stderr.write(f"[mise-plugin] {msg}\n") - sys.stderr.flush() - - -def get_config_path() -> str: - userprofile = os.environ.get("USERPROFILE") - if userprofile and sys.platform == "win32": - return os.path.join(userprofile, ".config", "mise", "config.toml") - return os.path.expanduser("~/.config/mise/config.toml") - - -def read_toml(file_path: str) -> dict: - if not os.path.exists(file_path): - return {} - - try: - import tomllib - except ImportError: - try: - import tomli as tomllib - except ImportError: - raise RuntimeError("tomllib (Python 3.11+) or tomli is required") - - try: - with open(file_path, "rb") as f: - return tomllib.load(f) - except Exception as e: - # Corruption backup - backup_path = f"{file_path}.{uuid.uuid4().hex}.bak" - log(f"Failed to parse config.toml: {e}. Backing up to {backup_path}") - try: - shutil.copy2(file_path, backup_path) - except Exception as backup_err: - log(f"Failed to create backup: {backup_err}") - return {} - - -def write_toml(file_path: str, data: dict) -> None: - os.makedirs(os.path.dirname(file_path), exist_ok=True) - lines = [] - - # Write top-level key-value pairs - for key, value in data.items(): - if not isinstance(value, dict): - lines.append(f"{key} = {toml_value(value)}") - - # Write sections (nested dicts) - def write_section(section_name, section_data): - lines.append(f"\n[{section_name}]") - for k, v in section_data.items(): - if isinstance(v, dict): - # Nested sections like [tools.something] - write_section(f"{section_name}.{k}", v) - else: - lines.append(f"{k} = {toml_value(v)}") - - for section, contents in data.items(): - if isinstance(contents, dict): - write_section(section, contents) - - fd, temp_path = tempfile.mkstemp(dir=os.path.dirname(file_path), prefix="config.toml.") - try: - with os.fdopen(fd, "w", encoding="utf-8") as f: - f.write("\n".join(lines).strip() + "\n") - os.replace(temp_path, file_path) - except Exception as e: - os.unlink(temp_path) - raise e - - -def toml_value(value) -> str: - if isinstance(value, bool): - return "true" if value else "false" - elif isinstance(value, int) or isinstance(value, float): - return str(value) - elif isinstance(value, str): - # Escape quotes in strings - escaped = value.replace('"', '\\"') - return f'"{escaped}"' - elif isinstance(value, list): - items = ", ".join(toml_value(i) for i in value) - return f"[{items}]" - elif value is None: - return '""' - else: - return f'"{str(value)}"' - - -def merge_settings(target: dict, source: dict) -> bool: - changed = False - for key, value in source.items(): - if isinstance(value, dict): - if key not in target or not isinstance(target.get(key), dict): - target[key] = {} - changed = True - if merge_settings(target[key], value): - changed = True - else: - if key not in target or target[key] != value: - target[key] = value - changed = True - return changed - - -def check_installed(args: dict, request_id: str) -> dict: - installed = shutil.which("mise.exe") is not None or shutil.which("mise") is not None - return { - "requestId": request_id, - "success": True, - "changed": False, - "data": installed, - } - - -def apply_config(args: dict, context: dict, request_id: str) -> dict: - dry_run = bool(context.get("dryRun", False)) - settings = args.get("settings", {}) - - try: - config_path = get_config_path() - current_config = read_toml(config_path) - changed = merge_settings(current_config, settings) - - if not changed: - return { - "requestId": request_id, - "success": True, - "changed": False, - "data": None, - } - - if dry_run: - log(f"dry_run: would update {config_path}") - return { - "requestId": request_id, - "success": True, - "changed": changed, - "data": None, - } - - write_toml(config_path, current_config) - log(f"Updated config: {config_path}") - - return { - "requestId": request_id, - "success": True, - "changed": True, - "data": None, - } - - except Exception as e: - log(f"Failed to apply config: {e}") - return { - "requestId": request_id, - "success": False, - "changed": False, - "error": str(e), - "data": None, - } - - -def handle(request: dict) -> dict: - request_id = request.get("requestId", "unknown") - command = request.get("command") - args = request.get("args", {}) - context = request.get("context", {}) - - if command == "check_installed": - return check_installed(args, request_id) - if command == "apply": - if not isinstance(args, dict): - return { - "requestId": request_id, - "success": False, - "changed": False, - "error": "args must be an object", - "data": None, - } - if not isinstance(context, dict): - return { - "requestId": request_id, - "success": False, - "changed": False, - "error": "context must be an object", - "data": None, - } - return apply_config(args, context, request_id) - - return { - "requestId": request_id, - "success": False, - "changed": False, - "error": f"Unknown command: {command}", - "data": None, - } - - -def main() -> None: - raw = sys.stdin.read() - if not raw or not raw.strip(): - sys.stdout.write( - json.dumps( - { - "requestId": "unknown", - "success": False, - "changed": False, - "error": "Empty input", - "data": None, - } - ) - + "\n" - ) - sys.stdout.flush() - return - - try: - request = json.loads(raw) - result = handle(request) - except Exception as error: - request_id = "unknown" - if "request" in locals() and isinstance(request, dict): - request_id = request.get("requestId", "unknown") - result = { - "requestId": request_id, - "success": False, - "changed": False, - "error": str(error), - "data": None, - } - - sys.stdout.write(json.dumps(result) + "\n") - sys.stdout.flush() - - -if __name__ == "__main__": - main() +import json +import os +import shutil +import sys +import tempfile +import uuid + + +def log(msg): + sys.stderr.write(f"[mise-plugin] {msg}\n") + sys.stderr.flush() + + +def get_config_path() -> str: + userprofile = os.environ.get("USERPROFILE") + if userprofile and sys.platform == "win32": + return os.path.join(userprofile, ".config", "mise", "config.toml") + return os.path.expanduser("~/.config/mise/config.toml") + + +def read_toml(file_path: str) -> dict: + if not os.path.exists(file_path): + return {} + + try: + import tomllib + except ImportError: + try: + import tomli as tomllib + except ImportError: + raise RuntimeError("tomllib (Python 3.11+) or tomli is required") + + try: + with open(file_path, "rb") as f: + return tomllib.load(f) + except Exception as e: + # Corruption backup + backup_path = f"{file_path}.{uuid.uuid4().hex}.bak" + log(f"Failed to parse config.toml: {e}. Backing up to {backup_path}") + try: + shutil.copy2(file_path, backup_path) + except Exception as backup_err: + log(f"Failed to create backup: {backup_err}") + return {} + + +def write_toml(file_path: str, data: dict) -> None: + os.makedirs(os.path.dirname(file_path), exist_ok=True) + lines = [] + + # Write top-level key-value pairs + for key, value in data.items(): + if not isinstance(value, dict): + lines.append(f"{key} = {toml_value(value)}") + + # Write sections (nested dicts) + def write_section(section_name, section_data): + lines.append(f"\n[{section_name}]") + for k, v in section_data.items(): + if isinstance(v, dict): + # Nested sections like [tools.something] + write_section(f"{section_name}.{k}", v) + else: + lines.append(f"{k} = {toml_value(v)}") + + for section, contents in data.items(): + if isinstance(contents, dict): + write_section(section, contents) + + fd, temp_path = tempfile.mkstemp(dir=os.path.dirname(file_path), prefix="config.toml.") + try: + with os.fdopen(fd, "w", encoding="utf-8") as f: + f.write("\n".join(lines).strip() + "\n") + os.replace(temp_path, file_path) + except Exception as e: + os.unlink(temp_path) + raise e + + +def toml_value(value) -> str: + if isinstance(value, bool): + return "true" if value else "false" + elif isinstance(value, int) or isinstance(value, float): + return str(value) + elif isinstance(value, str): + # Escape quotes in strings + escaped = value.replace('"', '\\"') + return f'"{escaped}"' + elif isinstance(value, list): + items = ", ".join(toml_value(i) for i in value) + return f"[{items}]" + elif value is None: + return '""' + else: + return f'"{str(value)}"' + + +def merge_settings(target: dict, source: dict) -> bool: + changed = False + for key, value in source.items(): + if isinstance(value, dict): + if key not in target or not isinstance(target.get(key), dict): + target[key] = {} + changed = True + if merge_settings(target[key], value): + changed = True + else: + if value == "": + if key in target: + del target[key] + changed = True + elif key not in target or target[key] != value: + target[key] = value + changed = True + return changed + + +def check_installed(args: dict, request_id: str) -> dict: + installed = shutil.which("mise.exe") is not None or shutil.which("mise") is not None + return { + "requestId": request_id, + "success": True, + "changed": False, + "data": installed, + } + + +def apply_config(args: dict, context: dict, request_id: str) -> dict: + dry_run = bool(context.get("dryRun", False)) + settings = args.get("settings", {}) + + try: + config_path = get_config_path() + current_config = read_toml(config_path) + changed = merge_settings(current_config, settings) + + if not changed: + return { + "requestId": request_id, + "success": True, + "changed": False, + "data": None, + } + + if dry_run: + log(f"dry_run: would update {config_path}") + return { + "requestId": request_id, + "success": True, + "changed": changed, + "data": None, + } + + write_toml(config_path, current_config) + log(f"Updated config: {config_path}") + + return { + "requestId": request_id, + "success": True, + "changed": True, + "data": None, + } + + except Exception as e: + log(f"Failed to apply config: {e}") + return { + "requestId": request_id, + "success": False, + "changed": False, + "error": str(e), + "data": None, + } + + +def handle(request: dict) -> dict: + request_id = request.get("requestId", "unknown") + command = request.get("command") + args = request.get("args", {}) + context = request.get("context", {}) + + if command == "check_installed": + return check_installed(args, request_id) + if command == "apply": + if not isinstance(args, dict): + return { + "requestId": request_id, + "success": False, + "changed": False, + "error": "args must be an object", + "data": None, + } + if not isinstance(context, dict): + return { + "requestId": request_id, + "success": False, + "changed": False, + "error": "context must be an object", + "data": None, + } + return apply_config(args, context, request_id) + + return { + "requestId": request_id, + "success": False, + "changed": False, + "error": f"Unknown command: {command}", + "data": None, + } + + +def main() -> None: + raw = sys.stdin.read() + if not raw or not raw.strip(): + sys.stdout.write( + json.dumps( + { + "requestId": "unknown", + "success": False, + "changed": False, + "error": "Empty input", + "data": None, + } + ) + + "\n" + ) + sys.stdout.flush() + return + + try: + request = json.loads(raw) + result = handle(request) + except Exception as error: + request_id = "unknown" + if "request" in locals() and isinstance(request, dict): + request_id = request.get("requestId", "unknown") + result = { + "requestId": request_id, + "success": False, + "changed": False, + "error": str(error), + "data": None, + } + + sys.stdout.write(json.dumps(result) + "\n") + sys.stdout.flush() + + +if __name__ == "__main__": + main() diff --git a/plugins/nvm/src/plugin.py b/plugins/nvm/src/plugin.py index 59e80b38..abc38579 100644 --- a/plugins/nvm/src/plugin.py +++ b/plugins/nvm/src/plugin.py @@ -17,7 +17,7 @@ def log(msg: str) -> None: def get_appdata_dir() -> str: - appdata = os.environ.get("APPDATA") + appdata = (os.environ.get("APPDATA") or os.path.join(os.path.expanduser("~"), "AppData", "Roaming")) if appdata: return appdata diff --git a/plugins/rainmeter/test/test_rainmeter.py b/plugins/rainmeter/test/test_rainmeter.py index d7c56dae..c9d69ea0 100644 --- a/plugins/rainmeter/test/test_rainmeter.py +++ b/plugins/rainmeter/test/test_rainmeter.py @@ -19,7 +19,7 @@ def setUp(self): self.temp_dir = tempfile.mkdtemp() self.appdata = os.path.join(self.temp_dir, "AppData") os.makedirs(self.appdata) - self.old_appdata = os.environ.get("APPDATA") + self.old_appdata = (os.environ.get("APPDATA") or os.path.join(os.path.expanduser("~"), "AppData", "Roaming")) os.environ["APPDATA"] = self.appdata self.config_dir = os.path.join(self.appdata, "Rainmeter") self.config_file = os.path.join(self.config_dir, "Rainmeter.ini") diff --git a/plugins/scoop/src/plugin.py b/plugins/scoop/src/plugin.py index ec974cc7..14aa25ce 100644 --- a/plugins/scoop/src/plugin.py +++ b/plugins/scoop/src/plugin.py @@ -1,209 +1,213 @@ -import datetime -import json -import os -import shutil -import sys -import tempfile -import uuid - - -def log(msg): - sys.stderr.write(f"[scoop-plugin] {msg}\n") - sys.stderr.flush() - - -def get_config_path(): - # Scoop reads config from $env:XDG_CONFIG_HOME\scoop\config.json if set, - # otherwise from %USERPROFILE%\.config\scoop\config.json. - # Reference: https://github.com/ScoopInstaller/Scoop/blob/master/lib/core.ps1 - xdg_config = os.getenv("XDG_CONFIG_HOME") - if xdg_config: - return os.path.join(xdg_config, "scoop", "config.json") - - user_profile = os.getenv("USERPROFILE") - if not user_profile: - user_profile = os.path.expanduser("~") - - return os.path.join(user_profile, ".config", "scoop", "config.json") - - -def _backup_corrupt_config(file_path: str, reason: str): - timestamp = datetime.datetime.now(datetime.timezone.utc).strftime("%Y%m%d%H%M%S") - suffix = uuid.uuid4().hex[:8] - backup_path = f"{file_path}.corrupted.{timestamp}.{suffix}" - log(f"Config read failed ({reason}). Backing up to {backup_path} and starting fresh.") - try: - shutil.move(file_path, backup_path) - except Exception as backup_e: - log(f"Failed to backup corrupted config: {backup_e}") - - -def read_json(file_path: str) -> dict: - if not os.path.exists(file_path): - return {} - - try: - with open(file_path, "r", encoding="utf-8") as f: - data = json.load(f) - return data if isinstance(data, dict) else {} - except json.JSONDecodeError as e: - _backup_corrupt_config(file_path, f"JSONDecodeError: {e}") - return {} - except OSError as e: - _backup_corrupt_config(file_path, f"OSError: {e}") - return {} - - -def write_json(file_path: str, data: dict) -> None: - dir_path = os.path.dirname(file_path) - if dir_path: - os.makedirs(dir_path, exist_ok=True) - fd, temp_path = tempfile.mkstemp(prefix="scoop-", dir=dir_path or ".") - try: - with os.fdopen(fd, "w", encoding="utf-8") as f: - json.dump(data, f, indent=2) - os.replace(temp_path, file_path) - except Exception: - try: - os.unlink(temp_path) - except OSError: - pass - raise - - -def merge_settings(target: dict, source: dict) -> bool: - changed = False - for key, value in source.items(): - if isinstance(value, dict): - if key not in target or not isinstance(target.get(key), dict): - target[key] = {} - changed = True - - if merge_settings(target[key], value): - changed = True - else: - if key not in target or target[key] != value: - target[key] = value - changed = True - return changed - - -def check_installed(args: dict, request_id: str) -> dict: - installed = ( - shutil.which("scoop.exe") is not None - or shutil.which("scoop.ps1") is not None - or shutil.which("scoop.cmd") is not None - or shutil.which("scoop") is not None - ) - - return { - "requestId": request_id, - "success": True, - "changed": False, - "data": installed, - } - - -def apply_config(args: dict, context: dict, request_id: str) -> dict: - dry_run = context.get("dryRun", False) - settings = args.get("settings", {}) - - try: - config_path = get_config_path() - current_config = read_json(config_path) - - changed = merge_settings(current_config, settings) - - if not changed: - return { - "requestId": request_id, - "success": True, - "changed": False, - "data": None, - } - - if dry_run: - log(f"Would update {config_path} with new settings") - return { - "requestId": request_id, - "success": True, - "changed": changed, - "data": None, - } - - write_json(config_path, current_config) - log(f"Updated Scoop config: {config_path}") - - return { - "requestId": request_id, - "success": True, - "changed": True, - "data": None, - } - - except Exception as e: - log(f"Failed to apply config: {e}") - return { - "requestId": request_id, - "success": False, - "changed": False, - "error": str(e), - "data": None, - } - - -def main(): - input_data = sys.stdin.read() - if not input_data: - response = { - "requestId": "unknown", - "success": False, - "changed": False, - "error": "No input provided on stdin", - "data": None, - } - sys.stdout.write(json.dumps(response) + "\n") - sys.stdout.flush() - return - - try: - request = json.loads(input_data) - except Exception as e: - log(f"Failed to parse request: {e}") - response = { - "requestId": "unknown", - "success": False, - "changed": False, - "error": f"Failed to parse JSON request: {str(e)}", - } - sys.stdout.write(json.dumps(response) + "\n") - sys.stdout.flush() - return - - request_id = request.get("requestId", "unknown") - command = request.get("command") - args = request.get("args", {}) - context = request.get("context", {}) - - response = { - "requestId": request_id, - "success": False, - "changed": False, - } - - try: - if command == "check_installed": - response = check_installed(args, request_id) - elif command == "apply": - response = apply_config(args, context, request_id) - else: - response["error"] = f"Unknown command: {command}" - except Exception as fatal_err: - response["error"] = f"Internal Script Error: {str(fatal_err)}" - - sys.stdout.write(json.dumps(response) + "\n") - sys.stdout.flush() - - -if __name__ == "__main__": - main() +import datetime +import json +import os +import shutil +import sys +import tempfile +import uuid + + +def log(msg): + sys.stderr.write(f"[scoop-plugin] {msg}\n") + sys.stderr.flush() + + +def get_config_path(): + # Scoop reads config from $env:XDG_CONFIG_HOME\scoop\config.json if set, + # otherwise from %USERPROFILE%\.config\scoop\config.json. + # Reference: https://github.com/ScoopInstaller/Scoop/blob/master/lib/core.ps1 + xdg_config = os.getenv("XDG_CONFIG_HOME") + if xdg_config: + return os.path.join(xdg_config, "scoop", "config.json") + + user_profile = os.getenv("USERPROFILE") + if not user_profile: + user_profile = os.path.expanduser("~") + + return os.path.join(user_profile, ".config", "scoop", "config.json") + + +def _backup_corrupt_config(file_path: str, reason: str): + timestamp = datetime.datetime.now(datetime.timezone.utc).strftime("%Y%m%d%H%M%S") + suffix = uuid.uuid4().hex[:8] + backup_path = f"{file_path}.corrupted.{timestamp}.{suffix}" + log(f"Config read failed ({reason}). Backing up to {backup_path} and starting fresh.") + try: + shutil.move(file_path, backup_path) + except Exception as backup_e: + log(f"Failed to backup corrupted config: {backup_e}") + + +def read_json(file_path: str) -> dict: + if not os.path.exists(file_path): + return {} + + try: + with open(file_path, "r", encoding="utf-8") as f: + data = json.load(f) + return data if isinstance(data, dict) else {} + except json.JSONDecodeError as e: + _backup_corrupt_config(file_path, f"JSONDecodeError: {e}") + return {} + except OSError as e: + _backup_corrupt_config(file_path, f"OSError: {e}") + return {} + + +def write_json(file_path: str, data: dict) -> None: + dir_path = os.path.dirname(file_path) + if dir_path: + os.makedirs(dir_path, exist_ok=True) + fd, temp_path = tempfile.mkstemp(prefix="scoop-", dir=dir_path or ".") + try: + with os.fdopen(fd, "w", encoding="utf-8") as f: + json.dump(data, f, indent=2) + os.replace(temp_path, file_path) + except Exception: + try: + os.unlink(temp_path) + except OSError: + pass + raise + + +def merge_settings(target: dict, source: dict) -> bool: + changed = False + for key, value in source.items(): + if isinstance(value, dict): + if key not in target or not isinstance(target.get(key), dict): + target[key] = {} + changed = True + + if merge_settings(target[key], value): + changed = True + else: + if value == "": + if key in target: + del target[key] + changed = True + elif key not in target or target[key] != value: + target[key] = value + changed = True + return changed + + +def check_installed(args: dict, request_id: str) -> dict: + installed = ( + shutil.which("scoop.exe") is not None + or shutil.which("scoop.ps1") is not None + or shutil.which("scoop.cmd") is not None + or shutil.which("scoop") is not None + ) + + return { + "requestId": request_id, + "success": True, + "changed": False, + "data": installed, + } + + +def apply_config(args: dict, context: dict, request_id: str) -> dict: + dry_run = context.get("dryRun", False) + settings = args.get("settings", {}) + + try: + config_path = get_config_path() + current_config = read_json(config_path) + + changed = merge_settings(current_config, settings) + + if not changed: + return { + "requestId": request_id, + "success": True, + "changed": False, + "data": None, + } + + if dry_run: + log(f"Would update {config_path} with new settings") + return { + "requestId": request_id, + "success": True, + "changed": changed, + "data": None, + } + + write_json(config_path, current_config) + log(f"Updated Scoop config: {config_path}") + + return { + "requestId": request_id, + "success": True, + "changed": True, + "data": None, + } + + except Exception as e: + log(f"Failed to apply config: {e}") + return { + "requestId": request_id, + "success": False, + "changed": False, + "error": str(e), + "data": None, + } + + +def main(): + input_data = sys.stdin.read() + if not input_data: + response = { + "requestId": "unknown", + "success": False, + "changed": False, + "error": "No input provided on stdin", + "data": None, + } + sys.stdout.write(json.dumps(response) + "\n") + sys.stdout.flush() + return + + try: + request = json.loads(input_data) + except Exception as e: + log(f"Failed to parse request: {e}") + response = { + "requestId": "unknown", + "success": False, + "changed": False, + "error": f"Failed to parse JSON request: {str(e)}", + } + sys.stdout.write(json.dumps(response) + "\n") + sys.stdout.flush() + return + + request_id = request.get("requestId", "unknown") + command = request.get("command") + args = request.get("args", {}) + context = request.get("context", {}) + + response = { + "requestId": request_id, + "success": False, + "changed": False, + } + + try: + if command == "check_installed": + response = check_installed(args, request_id) + elif command == "apply": + response = apply_config(args, context, request_id) + else: + response["error"] = f"Unknown command: {command}" + except Exception as fatal_err: + response["error"] = f"Internal Script Error: {str(fatal_err)}" + + sys.stdout.write(json.dumps(response) + "\n") + sys.stdout.flush() + + +if __name__ == "__main__": + main() diff --git a/plugins/starship/src/plugin.py b/plugins/starship/src/plugin.py index a49baf00..49a9572c 100644 --- a/plugins/starship/src/plugin.py +++ b/plugins/starship/src/plugin.py @@ -93,7 +93,11 @@ def merge_settings(target: dict, source: dict) -> bool: if merge_settings(target[key], value): changed = True else: - if key not in target or target[key] != value: + if value == "": + if key in target: + del target[key] + changed = True + elif key not in target or target[key] != value: target[key] = value changed = True return changed diff --git a/plugins/topgrade/src/plugin.py b/plugins/topgrade/src/plugin.py index ed168789..f9ef9857 100644 --- a/plugins/topgrade/src/plugin.py +++ b/plugins/topgrade/src/plugin.py @@ -19,7 +19,7 @@ def log(msg): def get_topgrade_config_path(): - appdata = os.environ.get("APPDATA") + appdata = (os.environ.get("APPDATA") or os.path.join(os.path.expanduser("~"), "AppData", "Roaming")) if not appdata: return None diff --git a/plugins/winget/src/plugin.py b/plugins/winget/src/plugin.py index ad7ac342..9e72c6c8 100644 --- a/plugins/winget/src/plugin.py +++ b/plugins/winget/src/plugin.py @@ -1,173 +1,177 @@ -import json -import os -import shutil -import sys - -PACKAGE_FAMILY = "Microsoft.DesktopAppInstaller_8wekyb3d8bbwe" - - -def log(msg: str) -> None: - sys.stderr.write(f"[winget-plugin] {msg}\n") - sys.stderr.flush() - - -def get_settings_path() -> str: - local_app_data = os.getenv("LOCALAPPDATA") - if not local_app_data: - raise Exception("LOCALAPPDATA environment variable not found") - - return os.path.join( - local_app_data, - "Packages", - PACKAGE_FAMILY, - "LocalState", - "settings.json", - ) - - -def read_json(file_path: str) -> dict: - if not os.path.exists(file_path): - return {} - - try: - with open(file_path, "r", encoding="utf-8-sig") as f: - data = json.load(f) - return data if isinstance(data, dict) else {} - except json.JSONDecodeError as e: - raise Exception(f"Could not parse {file_path}: {e}") from e - - -def write_json(file_path: str, data: dict) -> None: - os.makedirs(os.path.dirname(file_path), exist_ok=True) - tmp_path = file_path + ".tmp" - with open(tmp_path, "w", encoding="utf-8") as f: - json.dump(data, f, indent=4) - f.write("\n") - os.replace(tmp_path, file_path) - - -def deep_merge(target: dict, source: dict) -> bool: - changed = False - - for key, value in source.items(): - if isinstance(value, dict): - if key not in target or not isinstance(target.get(key), dict): - target[key] = {} - changed = True - - if deep_merge(target[key], value): - changed = True - else: - if key not in target or target[key] != value: - target[key] = value - changed = True - - return changed - - -def check_installed(args: dict, request_id: str) -> dict: - installed = shutil.which("winget.exe") is not None or shutil.which("winget") is not None - return { - "requestId": request_id, - "success": True, - "changed": False, - "data": {"installed": installed}, - } - - -def apply_config(args: dict, context: dict, request_id: str) -> dict: - dry_run = context.get("dryRun", False) - settings = args.get("settings", args) - - if not isinstance(settings, dict): - return { - "requestId": request_id, - "success": False, - "changed": False, - "error": "settings must be an object", - } - - try: - settings_path = get_settings_path() - current_settings = read_json(settings_path) - changed = deep_merge(current_settings, settings) - - if not changed: - return { - "requestId": request_id, - "success": True, - "changed": False, - } - - if dry_run: - log(f"Would update winget settings: {settings_path}") - return { - "requestId": request_id, - "success": True, - "changed": True, - } - - write_json(settings_path, current_settings) - log(f"Updated winget settings: {settings_path}") - - return { - "requestId": request_id, - "success": True, - "changed": True, - } - except Exception as e: - log(f"Failed to apply config: {e}") - return { - "requestId": request_id, - "success": False, - "changed": False, - "error": str(e), - } - - -def main() -> None: - input_data = sys.stdin.read() - if not input_data: - return - - try: - request = json.loads(input_data) - except Exception as e: - log(f"Failed to parse request: {e}") - response = { - "requestId": "unknown", - "success": False, - "changed": False, - "error": f"Failed to parse request: {e}", - } - sys.stdout.write(json.dumps(response) + "\n") - sys.stdout.flush() - return - - request_id = request.get("requestId", "unknown") - command = request.get("command") - args = request.get("args", {}) - context = request.get("context", {}) - - response = { - "requestId": request_id, - "success": False, - "changed": False, - } - - try: - if command == "check_installed": - response = check_installed(args, request_id) - elif command == "apply": - response = apply_config(args, context, request_id) - else: - response["error"] = f"Unknown command: {command}" - except Exception as fatal_err: - response["error"] = f"Internal Script Error: {str(fatal_err)}" - - sys.stdout.write(json.dumps(response) + "\n") - sys.stdout.flush() - - -if __name__ == "__main__": - main() +import json +import os +import shutil +import sys + +PACKAGE_FAMILY = "Microsoft.DesktopAppInstaller_8wekyb3d8bbwe" + + +def log(msg: str) -> None: + sys.stderr.write(f"[winget-plugin] {msg}\n") + sys.stderr.flush() + + +def get_settings_path() -> str: + local_app_data = os.getenv("LOCALAPPDATA") + if not local_app_data: + raise Exception("LOCALAPPDATA environment variable not found") + + return os.path.join( + local_app_data, + "Packages", + PACKAGE_FAMILY, + "LocalState", + "settings.json", + ) + + +def read_json(file_path: str) -> dict: + if not os.path.exists(file_path): + return {} + + try: + with open(file_path, "r", encoding="utf-8-sig") as f: + data = json.load(f) + return data if isinstance(data, dict) else {} + except json.JSONDecodeError as e: + raise Exception(f"Could not parse {file_path}: {e}") from e + + +def write_json(file_path: str, data: dict) -> None: + os.makedirs(os.path.dirname(file_path), exist_ok=True) + tmp_path = file_path + ".tmp" + with open(tmp_path, "w", encoding="utf-8") as f: + json.dump(data, f, indent=4) + f.write("\n") + os.replace(tmp_path, file_path) + + +def deep_merge(target: dict, source: dict) -> bool: + changed = False + + for key, value in source.items(): + if isinstance(value, dict): + if key not in target or not isinstance(target.get(key), dict): + target[key] = {} + changed = True + + if deep_merge(target[key], value): + changed = True + else: + if value == "": + if key in target: + del target[key] + changed = True + elif key not in target or target[key] != value: + target[key] = value + changed = True + + return changed + + +def check_installed(args: dict, request_id: str) -> dict: + installed = shutil.which("winget.exe") is not None or shutil.which("winget") is not None + return { + "requestId": request_id, + "success": True, + "changed": False, + "data": {"installed": installed}, + } + + +def apply_config(args: dict, context: dict, request_id: str) -> dict: + dry_run = context.get("dryRun", False) + settings = args.get("settings", args) + + if not isinstance(settings, dict): + return { + "requestId": request_id, + "success": False, + "changed": False, + "error": "settings must be an object", + } + + try: + settings_path = get_settings_path() + current_settings = read_json(settings_path) + changed = deep_merge(current_settings, settings) + + if not changed: + return { + "requestId": request_id, + "success": True, + "changed": False, + } + + if dry_run: + log(f"Would update winget settings: {settings_path}") + return { + "requestId": request_id, + "success": True, + "changed": True, + } + + write_json(settings_path, current_settings) + log(f"Updated winget settings: {settings_path}") + + return { + "requestId": request_id, + "success": True, + "changed": True, + } + except Exception as e: + log(f"Failed to apply config: {e}") + return { + "requestId": request_id, + "success": False, + "changed": False, + "error": str(e), + } + + +def main() -> None: + input_data = sys.stdin.read() + if not input_data: + return + + try: + request = json.loads(input_data) + except Exception as e: + log(f"Failed to parse request: {e}") + response = { + "requestId": "unknown", + "success": False, + "changed": False, + "error": f"Failed to parse request: {e}", + } + sys.stdout.write(json.dumps(response) + "\n") + sys.stdout.flush() + return + + request_id = request.get("requestId", "unknown") + command = request.get("command") + args = request.get("args", {}) + context = request.get("context", {}) + + response = { + "requestId": request_id, + "success": False, + "changed": False, + } + + try: + if command == "check_installed": + response = check_installed(args, request_id) + elif command == "apply": + response = apply_config(args, context, request_id) + else: + response["error"] = f"Unknown command: {command}" + except Exception as fatal_err: + response["error"] = f"Internal Script Error: {str(fatal_err)}" + + sys.stdout.write(json.dumps(response) + "\n") + sys.stdout.flush() + + +if __name__ == "__main__": + main() diff --git a/plugins/yasb/src/plugin.py b/plugins/yasb/src/plugin.py index 69f9d779..c61cfd91 100644 --- a/plugins/yasb/src/plugin.py +++ b/plugins/yasb/src/plugin.py @@ -1,223 +1,227 @@ -# /// script -# dependencies = [ -# "pyyaml", -# ] -# /// - -import copy -import json -import os -import shutil -import sys -import tempfile -import uuid - -import yaml - -PLUGIN_NAME = "yasb" - - -def log(message: str) -> None: - sys.stderr.write(f"[{PLUGIN_NAME}-plugin] {message}\n") - sys.stderr.flush() - - -def get_user_profile() -> str: - user_profile = os.getenv("USERPROFILE") or os.getenv("HOME") - - if not user_profile: - raise Exception("USERPROFILE environment variable not found") - - return user_profile - - -def get_config_dir() -> str: - return os.path.join(get_user_profile(), ".config", "yasb") - - -def get_config_path() -> str: - return os.path.join(get_config_dir(), "config.yaml") - - -def read_yaml(file_path: str) -> dict: - if not os.path.exists(file_path): - return {} - - try: - with open(file_path, "r", encoding="utf-8") as file_handle: - data = yaml.safe_load(file_handle) - return data if isinstance(data, dict) else {} - except Exception as error: - backup_path = f"{file_path}.{uuid.uuid4().hex}.bak" - - try: - shutil.copy2(file_path, backup_path) - log(f"Warning: could not parse {file_path}: {error}. Backed up to {backup_path} and starting fresh.") - except Exception as backup_error: - log(f"Warning: could not parse {file_path}: {error}. Failed to back it up: {backup_error}. Starting fresh.") - - return {} - - -def write_yaml(file_path: str, data: dict) -> None: - os.makedirs(os.path.dirname(file_path), exist_ok=True) - - temp_fd, temp_path = tempfile.mkstemp(prefix="yasb-", dir=os.path.dirname(file_path)) - try: - with os.fdopen(temp_fd, "w", encoding="utf-8") as file_handle: - yaml.safe_dump(data, file_handle, default_flow_style=False, sort_keys=False) - - os.replace(temp_path, file_path) - finally: - if os.path.exists(temp_path): - try: - os.remove(temp_path) - except OSError: - pass - - -def merge_settings(target: dict, source: dict) -> bool: - """Deep-merge `source` into `target`. - - Behavior: - - Dict values are merged recursively. - - Scalar values are overwritten when different or missing. - - Returns True if `target` was modified. - """ - changed = False - - for key, value in source.items(): - if isinstance(value, dict): - if key not in target or not isinstance(target[key], dict): - target[key] = {} - changed = True - - if merge_settings(target[key], value): - changed = True - else: - if key not in target or target[key] != value: - target[key] = value - changed = True - - return changed - - -def check_installed(request_id: str) -> dict: - installed = ( - shutil.which("yasb") is not None or shutil.which("yasb.exe") is not None or os.path.isdir(get_config_dir()) - ) - - return { - "requestId": request_id, - "success": True, - "changed": False, - "data": installed, - } - - -def apply_config(request_id: str, args: dict, context: dict) -> dict: - dry_run = bool(context.get("dryRun", False)) - - if not isinstance(args, dict): - raise ValueError("args must be an object") - - settings = args.get("settings", {}) - - if not isinstance(settings, dict): - raise ValueError("settings must be an object") - - config_path = get_config_path() - current_config = read_yaml(config_path) - - # Securely create a deep copy to evaluate alterations cleanly - updated_config = copy.deepcopy(current_config) - changed = merge_settings(updated_config, settings) - - if dry_run: - log(f"Would update {config_path}" if changed else f"No changes for {config_path}") - return { - "requestId": request_id, - "success": True, - "changed": changed, - "data": {"wouldChange": changed}, - } - - if changed: - # Write the mutated copy - write_yaml(config_path, updated_config) - log(f"Updated yasb config: {config_path}") - - return { - "requestId": request_id, - "success": True, - "changed": changed, - "data": None, - } - - -def main() -> None: - input_data = sys.stdin.read() - - if not input_data: - sys.stdout.write( - json.dumps( - { - "requestId": "unknown", - "success": False, - "changed": False, - "data": None, - "error": "Failed to parse request: empty stdin", - } - ) - + "\n" - ) - sys.stdout.flush() - return - - try: - request = json.loads(input_data) - except Exception as error: - sys.stdout.write( - json.dumps( - { - "requestId": "unknown", - "success": False, - "changed": False, - "data": None, - "error": f"Failed to parse request: {error}", - } - ) - + "\n" - ) - sys.stdout.flush() - return - - request_id = request.get("requestId", "unknown") - command = request.get("command") - args = request.get("args", {}) - context = request.get("context", {}) - - response = { - "requestId": request_id, - "success": False, - "changed": False, - "data": None, - } - - try: - if command == "check_installed": - response = check_installed(request_id) - elif command == "apply": - response = apply_config(request_id, args, context) - else: - response["error"] = f"Unknown command: {command}" - except Exception as fatal_error: - response["error"] = f"Internal Script Error: {str(fatal_error)}" - - sys.stdout.write(json.dumps(response) + "\n") - sys.stdout.flush() - - -if __name__ == "__main__": - main() +# /// script +# dependencies = [ +# "pyyaml", +# ] +# /// + +import copy +import json +import os +import shutil +import sys +import tempfile +import uuid + +import yaml + +PLUGIN_NAME = "yasb" + + +def log(message: str) -> None: + sys.stderr.write(f"[{PLUGIN_NAME}-plugin] {message}\n") + sys.stderr.flush() + + +def get_user_profile() -> str: + user_profile = os.getenv("USERPROFILE") or os.getenv("HOME") + + if not user_profile: + raise Exception("USERPROFILE environment variable not found") + + return user_profile + + +def get_config_dir() -> str: + return os.path.join(get_user_profile(), ".config", "yasb") + + +def get_config_path() -> str: + return os.path.join(get_config_dir(), "config.yaml") + + +def read_yaml(file_path: str) -> dict: + if not os.path.exists(file_path): + return {} + + try: + with open(file_path, "r", encoding="utf-8") as file_handle: + data = yaml.safe_load(file_handle) + return data if isinstance(data, dict) else {} + except Exception as error: + backup_path = f"{file_path}.{uuid.uuid4().hex}.bak" + + try: + shutil.copy2(file_path, backup_path) + log(f"Warning: could not parse {file_path}: {error}. Backed up to {backup_path} and starting fresh.") + except Exception as backup_error: + log(f"Warning: could not parse {file_path}: {error}. Failed to back it up: {backup_error}. Starting fresh.") + + return {} + + +def write_yaml(file_path: str, data: dict) -> None: + os.makedirs(os.path.dirname(file_path), exist_ok=True) + + temp_fd, temp_path = tempfile.mkstemp(prefix="yasb-", dir=os.path.dirname(file_path)) + try: + with os.fdopen(temp_fd, "w", encoding="utf-8") as file_handle: + yaml.safe_dump(data, file_handle, default_flow_style=False, sort_keys=False) + + os.replace(temp_path, file_path) + finally: + if os.path.exists(temp_path): + try: + os.remove(temp_path) + except OSError: + pass + + +def merge_settings(target: dict, source: dict) -> bool: + """Deep-merge `source` into `target`. + + Behavior: + - Dict values are merged recursively. + - Scalar values are overwritten when different or missing. + + Returns True if `target` was modified. + """ + changed = False + + for key, value in source.items(): + if isinstance(value, dict): + if key not in target or not isinstance(target[key], dict): + target[key] = {} + changed = True + + if merge_settings(target[key], value): + changed = True + else: + if value == "": + if key in target: + del target[key] + changed = True + elif key not in target or target[key] != value: + target[key] = value + changed = True + + return changed + + +def check_installed(request_id: str) -> dict: + installed = ( + shutil.which("yasb") is not None or shutil.which("yasb.exe") is not None or os.path.isdir(get_config_dir()) + ) + + return { + "requestId": request_id, + "success": True, + "changed": False, + "data": installed, + } + + +def apply_config(request_id: str, args: dict, context: dict) -> dict: + dry_run = bool(context.get("dryRun", False)) + + if not isinstance(args, dict): + raise ValueError("args must be an object") + + settings = args.get("settings", {}) + + if not isinstance(settings, dict): + raise ValueError("settings must be an object") + + config_path = get_config_path() + current_config = read_yaml(config_path) + + # Securely create a deep copy to evaluate alterations cleanly + updated_config = copy.deepcopy(current_config) + changed = merge_settings(updated_config, settings) + + if dry_run: + log(f"Would update {config_path}" if changed else f"No changes for {config_path}") + return { + "requestId": request_id, + "success": True, + "changed": changed, + "data": {"wouldChange": changed}, + } + + if changed: + # Write the mutated copy + write_yaml(config_path, updated_config) + log(f"Updated yasb config: {config_path}") + + return { + "requestId": request_id, + "success": True, + "changed": changed, + "data": None, + } + + +def main() -> None: + input_data = sys.stdin.read() + + if not input_data: + sys.stdout.write( + json.dumps( + { + "requestId": "unknown", + "success": False, + "changed": False, + "data": None, + "error": "Failed to parse request: empty stdin", + } + ) + + "\n" + ) + sys.stdout.flush() + return + + try: + request = json.loads(input_data) + except Exception as error: + sys.stdout.write( + json.dumps( + { + "requestId": "unknown", + "success": False, + "changed": False, + "data": None, + "error": f"Failed to parse request: {error}", + } + ) + + "\n" + ) + sys.stdout.flush() + return + + request_id = request.get("requestId", "unknown") + command = request.get("command") + args = request.get("args", {}) + context = request.get("context", {}) + + response = { + "requestId": request_id, + "success": False, + "changed": False, + "data": None, + } + + try: + if command == "check_installed": + response = check_installed(request_id) + elif command == "apply": + response = apply_config(request_id, args, context) + else: + response["error"] = f"Unknown command: {command}" + except Exception as fatal_error: + response["error"] = f"Internal Script Error: {str(fatal_error)}" + + sys.stdout.write(json.dumps(response) + "\n") + sys.stdout.flush() + + +if __name__ == "__main__": + main() diff --git a/plugins/yazi/src/plugin.py b/plugins/yazi/src/plugin.py index 852e64ce..aeec89d8 100644 --- a/plugins/yazi/src/plugin.py +++ b/plugins/yazi/src/plugin.py @@ -149,7 +149,11 @@ def merge_settings(target: dict, source: dict) -> bool: target[key].append(item) changed = True else: - if key not in target or target[key] != value: + if value == "": + if key in target: + del target[key] + changed = True + elif key not in target or target[key] != value: target[key] = value changed = True diff --git a/plugins/zoxide/src/plugin.py b/plugins/zoxide/src/plugin.py index 308831f9..00f1a55e 100644 --- a/plugins/zoxide/src/plugin.py +++ b/plugins/zoxide/src/plugin.py @@ -1,226 +1,226 @@ -import json -import os -import shutil -import subprocess -import sys -from pathlib import Path - -ENV_VARS = ( - "_ZO_MAX_DEPTH", - "_ZO_ECHO", - "_ZO_EXCLUDE_DIRS", - "_ZO_RESOLVE_SYMLINKS", -) - - -def log(message: str) -> None: - sys.stderr.write(f"[zoxide-plugin] {message}\n") - sys.stderr.flush() - - -def get_user_home() -> Path: - return Path(os.environ.get("USERPROFILE") or str(Path.home())) - - -def get_powershell_profile_path() -> Path: - home = get_user_home() - if sys.platform == "win32": - return home / "Documents" / "PowerShell" / "Microsoft.PowerShell_profile.ps1" - return home / ".config" / "powershell" / "profile.ps1" - - -def get_bash_profile_path() -> Path: - home = get_user_home() - bashrc = home / ".bashrc" - bash_profile = home / ".bash_profile" - - if bashrc.exists(): - return bashrc - if bash_profile.exists(): - return bash_profile - return bashrc - - -def build_init_line(shell: str, init_args: dict) -> str: - flags = [] - - cmd = init_args.get("cmd") - hook = init_args.get("hook") - no_cmd = init_args.get("no_cmd", False) - - if cmd: - flags.append(f"--cmd {cmd}") - if hook and hook != "pwd": - flags.append(f"--hook {hook}") - if no_cmd: - flags.append("--no-cmd") - - flag_text = f" {' '.join(flags)}" if flags else "" - - if shell == "powershell": - return f"Invoke-Expression (& {{ (zoxide init powershell{flag_text}) }})" - if shell == "bash": - return f'eval "$(zoxide init bash{flag_text})"' - - raise ValueError(f"Unsupported shell: {shell}") - - -def update_profile_content(existing_text: str, desired_line: str) -> tuple[str, bool]: - current_lines = existing_text.splitlines() - matching_lines = [line for line in current_lines if "zoxide init" in line and not line.lstrip().startswith("#")] - updated_lines = [line for line in current_lines if "zoxide init" not in line or line.lstrip().startswith("#")] - - if matching_lines == [desired_line] and len(updated_lines) == len(current_lines) - 1: - return existing_text, False - - updated_lines.append(desired_line) - updated_text = "\n".join(updated_lines) + "\n" - return updated_text, updated_text != existing_text - - -def update_profile_file(profile_path: Path, desired_line: str, dry_run: bool) -> bool: - existing_text = "" - if profile_path.exists(): - with open(profile_path, "r", encoding="utf-8") as handle: - existing_text = handle.read() - - updated_text, changed = update_profile_content(existing_text, desired_line) - - if not changed: - return False - - if dry_run: - if profile_path.exists(): - log(f"Would update profile: {profile_path}") - else: - log(f"Would create profile: {profile_path}") - log(f"Would set init line: {desired_line}") - return True - - profile_path.parent.mkdir(parents=True, exist_ok=True) - with open(profile_path, "w", encoding="utf-8") as handle: - handle.write(updated_text) - - if profile_path.exists(): - log(f"Updated profile: {profile_path}") - else: - log(f"Created profile: {profile_path}") - - return True - - -def run_setx(var_name: str, value: str) -> None: - if sys.platform != "win32": - log(f"Skipping setx for {var_name} on non-Windows platform") - return - - result = subprocess.run(["setx", var_name, value], capture_output=True, text=True) - if result.returncode != 0: - stderr = (result.stderr or result.stdout or "setx failed").strip() - raise RuntimeError(f"setx {var_name} failed: {stderr}") - - -def check_installed(_args: dict, request_id: str) -> dict: - installed = shutil.which("zoxide.exe") is not None or shutil.which("zoxide") is not None - return { - "requestId": request_id, - "success": True, - "changed": False, - "data": {"installed": installed}, - } - - -def apply_config(args: dict, dry_run: bool, request_id: str) -> dict: - changed = False - - env_vars = args.get("env_vars", {}) - init_args = args.get("init", {}) - - for var_name in ENV_VARS: - if var_name not in env_vars: - continue - - requested_value = str(env_vars[var_name]) - current_value = os.environ.get(var_name) - - if current_value == requested_value: - continue - - changed = True - if dry_run: - log(f"Would set {var_name} to {requested_value}") - else: - log(f"Setting {var_name} to {requested_value}") - run_setx(var_name, requested_value) - - ps_profile = get_powershell_profile_path() - bash_profile = get_bash_profile_path() - ps_line = build_init_line("powershell", init_args) - bash_line = build_init_line("bash", init_args) - - if update_profile_file(ps_profile, ps_line, dry_run): - changed = True - if update_profile_file(bash_profile, bash_line, dry_run): - changed = True - - return { - "requestId": request_id, - "success": True, - "changed": changed, - } - - -def process_request(request: dict) -> dict: - request_id = request.get("requestId", "unknown") - command = request.get("command") - args = request.get("args", {}) - context = request.get("context", {}) - dry_run = bool(request.get("dry_run", context.get("dryRun", False))) - - try: - if command == "check_installed": - return check_installed(args, request_id) - if command == "apply": - return apply_config(args, dry_run, request_id) - return { - "requestId": request_id, - "success": False, - "changed": False, - "error": f"Unknown command: {command}", - } - except Exception as exc: - log(f"Failed to handle command '{command}': {exc}") - return { - "requestId": request_id, - "success": False, - "changed": False, - "error": str(exc), - } - - -def main() -> None: - input_data = sys.stdin.read() - if not input_data: - return - - try: - request = json.loads(input_data) - except Exception as exc: - log(f"Failed to parse request: {exc}") - response = { - "requestId": "unknown", - "success": False, - "changed": False, - "error": f"Invalid JSON: {exc}", - } - sys.stdout.write(json.dumps(response) + "\n") - sys.stdout.flush() - return - - response = process_request(request) - sys.stdout.write(json.dumps(response) + "\n") - sys.stdout.flush() - - -if __name__ == "__main__": - main() +import json +import os +import shutil +import subprocess +import sys +from pathlib import Path + +ENV_VARS = ( + "_ZO_MAX_DEPTH", + "_ZO_ECHO", + "_ZO_EXCLUDE_DIRS", + "_ZO_RESOLVE_SYMLINKS", +) + + +def log(message: str) -> None: + sys.stderr.write(f"[zoxide-plugin] {message}\n") + sys.stderr.flush() + + +def get_user_home() -> Path: + return Path(os.environ.get("USERPROFILE") or str(Path.home())) + + +def get_powershell_profile_path() -> Path: + home = get_user_home() + if sys.platform == "win32": + return home / "Documents" / "PowerShell" / "Microsoft.PowerShell_profile.ps1" + return home / ".config" / "powershell" / "profile.ps1" + + +def get_bash_profile_path() -> Path: + home = get_user_home() + bashrc = home / ".bashrc" + bash_profile = home / ".bash_profile" + + if bashrc.exists(): + return bashrc + if bash_profile.exists(): + return bash_profile + return bashrc + + +def build_init_line(shell: str, init_args: dict) -> str: + flags = [] + + cmd = init_args.get("cmd") + hook = init_args.get("hook") + no_cmd = init_args.get("no_cmd", False) + + if cmd: + flags.append(f"--cmd {cmd}") + if hook and hook != "pwd": + flags.append(f"--hook {hook}") + if no_cmd: + flags.append("--no-cmd") + + flag_text = f" {' '.join(flags)}" if flags else "" + + if shell == "powershell": + return f"Invoke-Expression (& {{ (zoxide init powershell{flag_text}) }})" + if shell == "bash": + return f'eval "$(zoxide init bash{flag_text})"' + + raise ValueError(f"Unsupported shell: {shell}") + + +def update_profile_content(existing_text: str, desired_line: str) -> tuple[str, bool]: + current_lines = existing_text.splitlines() + matching_lines = [line for line in current_lines if "zoxide init" in line and not line.lstrip().startswith("#")] + updated_lines = [line for line in current_lines if "zoxide init" not in line or line.lstrip().startswith("#")] + + if matching_lines == [desired_line] and len(updated_lines) == len(current_lines) - 1: + return existing_text, False + + updated_lines.append(desired_line) + updated_text = "\n".join(updated_lines) + "\n" + return updated_text, updated_text != existing_text + + +def update_profile_file(profile_path: Path, desired_line: str, dry_run: bool) -> bool: + existing_text = "" + if profile_path.exists(): + with open(profile_path, "r", encoding="utf-8") as handle: + existing_text = handle.read() + + updated_text, changed = update_profile_content(existing_text, desired_line) + + if not changed: + return False + + if dry_run: + if profile_path.exists(): + log(f"Would update profile: {profile_path}") + else: + log(f"Would create profile: {profile_path}") + log(f"Would set init line: {desired_line}") + return True + + profile_path.parent.mkdir(parents=True, exist_ok=True) + with open(profile_path, "w", encoding="utf-8") as handle: + handle.write(updated_text) + + if profile_path.exists(): + log(f"Updated profile: {profile_path}") + else: + log(f"Created profile: {profile_path}") + + return True + + +def run_setx(var_name: str, value: str) -> None: + if sys.platform != "win32": + log(f"Skipping setx for {var_name} on non-Windows platform") + return + + result = subprocess.run(["setx", var_name, value], capture_output=True, text=True) + if result.returncode != 0: + stderr = (result.stderr or result.stdout or "setx failed").strip() + raise RuntimeError(f"setx {var_name} failed: {stderr}") + + +def check_installed(_args: dict, request_id: str) -> dict: + installed = shutil.which("zoxide.exe") is not None or shutil.which("zoxide") is not None + return { + "requestId": request_id, + "success": True, + "changed": False, + "data": {"installed": installed}, + } + + +def apply_config(args: dict, dry_run: bool, request_id: str) -> dict: + changed = False + + env_vars = args.get("env_vars", {}) + init_args = args.get("init", {}) + + for var_name in ENV_VARS: + if var_name not in env_vars: + continue + + requested_value = str(env_vars[var_name]) + current_value = os.environ.get(var_name) + + if current_value == requested_value: + continue + + changed = True + if dry_run: + log(f"Would set {var_name} to {requested_value}") + else: + log(f"Setting {var_name} to {requested_value}") + run_setx(var_name, requested_value) + + ps_profile = get_powershell_profile_path() + bash_profile = get_bash_profile_path() + ps_line = build_init_line("powershell", init_args) + bash_line = build_init_line("bash", init_args) + + if update_profile_file(ps_profile, ps_line, dry_run): + changed = True + if update_profile_file(bash_profile, bash_line, dry_run): + changed = True + + return { + "requestId": request_id, + "success": True, + "changed": changed, + } + + +def process_request(request: dict) -> dict: + request_id = request.get("requestId", "unknown") + command = request.get("command") + args = request.get("args", {}) + context = request.get("context", {}) + dry_run = bool(request.get("dryRun", context.get("dryRun", False))) + + try: + if command == "check_installed": + return check_installed(args, request_id) + if command == "apply": + return apply_config(args, dry_run, request_id) + return { + "requestId": request_id, + "success": False, + "changed": False, + "error": f"Unknown command: {command}", + } + except Exception as exc: + log(f"Failed to handle command '{command}': {exc}") + return { + "requestId": request_id, + "success": False, + "changed": False, + "error": str(exc), + } + + +def main() -> None: + input_data = sys.stdin.read() + if not input_data: + return + + try: + request = json.loads(input_data) + except Exception as exc: + log(f"Failed to parse request: {exc}") + response = { + "requestId": "unknown", + "success": False, + "changed": False, + "error": f"Invalid JSON: {exc}", + } + sys.stdout.write(json.dumps(response) + "\n") + sys.stdout.flush() + return + + response = process_request(request) + sys.stdout.write(json.dumps(response) + "\n") + sys.stdout.flush() + + +if __name__ == "__main__": + main() From 1f8f97706680b3a833ce9e4c38ecff8a29051f64 Mon Sep 17 00:00:00 2001 From: Bhagyashri Date: Sun, 12 Jul 2026 20:16:00 +0530 Subject: [PATCH 02/10] style: fix linting, whitespace, and encoding errors --- src/Engine.cs | 4 ++-- src/Infrastructure/AppRunner.cs | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Engine.cs b/src/Engine.cs index decb95cc..fd3cad88 100644 --- a/src/Engine.cs +++ b/src/Engine.cs @@ -722,8 +722,8 @@ private async Task WaitForNetwork(int timeoutSeconds = 30, CancellationTok try { using var response = await _httpClient.GetAsync( - ConnectivityCheckUri, - HttpCompletionOption.ResponseHeadersRead, + ConnectivityCheckUri, + HttpCompletionOption.ResponseHeadersRead, cancellationToken); if (response.IsSuccessStatusCode) diff --git a/src/Infrastructure/AppRunner.cs b/src/Infrastructure/AppRunner.cs index 8b6ad58e..f332a415 100644 --- a/src/Infrastructure/AppRunner.cs +++ b/src/Infrastructure/AppRunner.cs @@ -1,4 +1,4 @@ -using WinHome.Infrastructure.Helpers; +using WinHome.Infrastructure.Helpers; using WinHome.Interfaces; using WinHome.Models; using WinHome.Services.Logging; From 913ec7785b994264d77987685ac0136245526835 Mon Sep 17 00:00:00 2001 From: Bhagyashri Date: Sun, 12 Jul 2026 20:20:53 +0530 Subject: [PATCH 03/10] style: fix 2-space indentation for CI linter --- src/Infrastructure/Helpers/AdminGuard.cs | 48 ++++++++++++------------ tests/WinHome.Tests/AdminGuardTests.cs | 42 ++++++++++----------- 2 files changed, 45 insertions(+), 45 deletions(-) diff --git a/src/Infrastructure/Helpers/AdminGuard.cs b/src/Infrastructure/Helpers/AdminGuard.cs index 0bb2a062..cef3cfd3 100644 --- a/src/Infrastructure/Helpers/AdminGuard.cs +++ b/src/Infrastructure/Helpers/AdminGuard.cs @@ -7,33 +7,33 @@ namespace WinHome.Infrastructure.Helpers; [SupportedOSPlatform("windows")] public static class AdminGuard { - internal static Func IsAdministrator = () => - { - using var identity = WindowsIdentity.GetCurrent(); - var principal = new WindowsPrincipal(identity); - return principal.IsInRole(WindowsBuiltInRole.Administrator); - }; + internal static Func IsAdministrator = () => + { + using var identity = WindowsIdentity.GetCurrent(); + var principal = new WindowsPrincipal(identity); + return principal.IsInRole(WindowsBuiltInRole.Administrator); + }; - /// Throws if the current process is not running as Administrator. - /// Thrown when not running with admin privileges. - public static void EnsureAdministrator() + /// Throws if the current process is not running as Administrator. + /// Thrown when not running with admin privileges. + public static void EnsureAdministrator() + { + if (!IsAdministrator()) { - if (!IsAdministrator()) - { - throw new UnauthorizedAccessException( - "Error: WinHome requires Administrative Privileges to manage system configurations. " + - "Please re-run this command from an elevated (Administrator) terminal."); - } + throw new UnauthorizedAccessException( + "Error: WinHome requires Administrative Privileges to manage system configurations. " + + "Please re-run this command from an elevated (Administrator) terminal."); } + } - /// Resets the administrator check delegate to its default implementation (used for testing). - internal static void ResetAdminCheck() + /// Resets the administrator check delegate to its default implementation (used for testing). + internal static void ResetAdminCheck() + { + IsAdministrator = () => { - IsAdministrator = () => - { - using var identity = WindowsIdentity.GetCurrent(); - var principal = new WindowsPrincipal(identity); - return principal.IsInRole(WindowsBuiltInRole.Administrator); - }; - } + using var identity = WindowsIdentity.GetCurrent(); + var principal = new WindowsPrincipal(identity); + return principal.IsInRole(WindowsBuiltInRole.Administrator); + }; + } } \ No newline at end of file diff --git a/tests/WinHome.Tests/AdminGuardTests.cs b/tests/WinHome.Tests/AdminGuardTests.cs index 16793c3f..456c1824 100644 --- a/tests/WinHome.Tests/AdminGuardTests.cs +++ b/tests/WinHome.Tests/AdminGuardTests.cs @@ -6,27 +6,27 @@ namespace WinHome.Tests; [Collection("SequentialTests")] public class AdminGuardTests { - [Fact] - public void EnsureAdministrator_DoesNotThrow_WhenAdmin() - { - AdminGuard.IsAdministrator = () => true; - var ex = Record.Exception(() => AdminGuard.EnsureAdministrator()); - Assert.Null(ex); - } + [Fact] + public void EnsureAdministrator_DoesNotThrow_WhenAdmin() + { + AdminGuard.IsAdministrator = () => true; + var ex = Record.Exception(() => AdminGuard.EnsureAdministrator()); + Assert.Null(ex); + } - [Fact] - public void EnsureAdministrator_ThrowsUnauthorizedAccessException_WhenNotAdmin() - { - AdminGuard.IsAdministrator = () => false; - var ex = Assert.Throws(() => AdminGuard.EnsureAdministrator()); - Assert.Contains("Administrative Privileges", ex.Message); - } + [Fact] + public void EnsureAdministrator_ThrowsUnauthorizedAccessException_WhenNotAdmin() + { + AdminGuard.IsAdministrator = () => false; + var ex = Assert.Throws(() => AdminGuard.EnsureAdministrator()); + Assert.Contains("Administrative Privileges", ex.Message); + } - [Fact] - public void ResetAdminCheck_RestoresDefaultDelegate() - { - AdminGuard.IsAdministrator = () => false; - AdminGuard.ResetAdminCheck(); - Assert.NotNull(AdminGuard.IsAdministrator); - } + [Fact] + public void ResetAdminCheck_RestoresDefaultDelegate() + { + AdminGuard.IsAdministrator = () => false; + AdminGuard.ResetAdminCheck(); + Assert.NotNull(AdminGuard.IsAdministrator); + } } \ No newline at end of file From 7acecef9c47d220c32d3fac805485829978349f1 Mon Sep 17 00:00:00 2001 From: Bhagyashri Date: Sun, 12 Jul 2026 20:28:00 +0530 Subject: [PATCH 04/10] style: fix engine indentation and add final newlines --- src/Engine.cs | 2 +- src/Infrastructure/Helpers/AdminGuard.cs | 2 +- tests/WinHome.Tests/AdminGuardTests.cs | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Engine.cs b/src/Engine.cs index fd3cad88..ab179757 100644 --- a/src/Engine.cs +++ b/src/Engine.cs @@ -736,7 +736,7 @@ private async Task WaitForNetwork(int timeoutSeconds = 30, CancellationTok catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) { /* HttpClient timeout - will retry */ } _logger.LogInfo("[Engine] Waiting for network..."); - await Task.Delay(2000, cancellationToken); + await Task.Delay(2000, cancellationToken); } return false; } diff --git a/src/Infrastructure/Helpers/AdminGuard.cs b/src/Infrastructure/Helpers/AdminGuard.cs index cef3cfd3..06608866 100644 --- a/src/Infrastructure/Helpers/AdminGuard.cs +++ b/src/Infrastructure/Helpers/AdminGuard.cs @@ -36,4 +36,4 @@ internal static void ResetAdminCheck() return principal.IsInRole(WindowsBuiltInRole.Administrator); }; } -} \ No newline at end of file +} diff --git a/tests/WinHome.Tests/AdminGuardTests.cs b/tests/WinHome.Tests/AdminGuardTests.cs index 456c1824..24daa006 100644 --- a/tests/WinHome.Tests/AdminGuardTests.cs +++ b/tests/WinHome.Tests/AdminGuardTests.cs @@ -29,4 +29,4 @@ public void ResetAdminCheck_RestoresDefaultDelegate() AdminGuard.ResetAdminCheck(); Assert.NotNull(AdminGuard.IsAdministrator); } -} \ No newline at end of file +} From 2d09c02a65668e93478b2684d540e95fa9d029a2 Mon Sep 17 00:00:00 2001 From: Bhagyashri Date: Wed, 22 Jul 2026 08:17:16 +0530 Subject: [PATCH 05/10] style: fix engine whitespace and indentation --- src/Engine.cs | 26 ++++++++++++------------ src/Infrastructure/Helpers/AdminGuard.cs | 4 ---- 2 files changed, 13 insertions(+), 17 deletions(-) diff --git a/src/Engine.cs b/src/Engine.cs index ab179757..1603931d 100644 --- a/src/Engine.cs +++ b/src/Engine.cs @@ -720,22 +720,22 @@ private async Task WaitForNetwork(int timeoutSeconds = 30, CancellationTok while ((DateTime.Now - start).TotalSeconds < timeoutSeconds) { try - { - using var response = await _httpClient.GetAsync( - ConnectivityCheckUri, - HttpCompletionOption.ResponseHeadersRead, - cancellationToken); - - if (response.IsSuccessStatusCode) { - _logger.LogSuccess("[Engine] Internet connection verified."); - return true; + using var response = await _httpClient.GetAsync( + ConnectivityCheckUri, + HttpCompletionOption.ResponseHeadersRead, + cancellationToken); + + if (response.IsSuccessStatusCode) + { + _logger.LogSuccess("[Engine] Internet connection verified."); + return true; + } } - } - catch (HttpRequestException) { /* Request failed - will retry */ } - catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) { /* HttpClient timeout - will retry */ } + catch (HttpRequestException) { /* Request failed - will retry */ } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) { /* HttpClient timeout - will retry */ } - _logger.LogInfo("[Engine] Waiting for network..."); + _logger.LogInfo("[Engine] Waiting for network..."); await Task.Delay(2000, cancellationToken); } return false; diff --git a/src/Infrastructure/Helpers/AdminGuard.cs b/src/Infrastructure/Helpers/AdminGuard.cs index 33d21d58..06608866 100644 --- a/src/Infrastructure/Helpers/AdminGuard.cs +++ b/src/Infrastructure/Helpers/AdminGuard.cs @@ -36,8 +36,4 @@ internal static void ResetAdminCheck() return principal.IsInRole(WindowsBuiltInRole.Administrator); }; } -<<<<<<< HEAD } -======= -} ->>>>>>> 928a1cddf87a9cf690221542f1bcd77633ef6175 From 2ffbf843fd8e6a97f91100296401e3c51e0e2862 Mon Sep 17 00:00:00 2001 From: Bhagyashri Date: Wed, 22 Jul 2026 08:27:26 +0530 Subject: [PATCH 06/10] style: fix final whitespace formatting in Engine.cs --- src/Engine.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Engine.cs b/src/Engine.cs index 1603931d..07a2145f 100644 --- a/src/Engine.cs +++ b/src/Engine.cs @@ -718,8 +718,8 @@ private async Task WaitForNetwork(int timeoutSeconds = 30, CancellationTok _logger.LogInfo("[Engine] Checking for internet connectivity..."); var start = DateTime.Now; while ((DateTime.Now - start).TotalSeconds < timeoutSeconds) - { - try + { + try { using var response = await _httpClient.GetAsync( ConnectivityCheckUri, @@ -737,7 +737,7 @@ private async Task WaitForNetwork(int timeoutSeconds = 30, CancellationTok _logger.LogInfo("[Engine] Waiting for network..."); await Task.Delay(2000, cancellationToken); - } + } return false; } } From 0cc03fbe9192dcff9cc1834cf4595a0ce3f188e0 Mon Sep 17 00:00:00 2001 From: Bhagyashri Date: Wed, 22 Jul 2026 08:37:36 +0530 Subject: [PATCH 07/10] style: fix while loop indentation in Engine.cs --- src/Engine.cs | 33 +++++++++++++++++---------------- 1 file changed, 17 insertions(+), 16 deletions(-) diff --git a/src/Engine.cs b/src/Engine.cs index 07a2145f..0ba2449a 100644 --- a/src/Engine.cs +++ b/src/Engine.cs @@ -718,27 +718,28 @@ private async Task WaitForNetwork(int timeoutSeconds = 30, CancellationTok _logger.LogInfo("[Engine] Checking for internet connectivity..."); var start = DateTime.Now; while ((DateTime.Now - start).TotalSeconds < timeoutSeconds) + { + try { - try - { - using var response = await _httpClient.GetAsync( - ConnectivityCheckUri, - HttpCompletionOption.ResponseHeadersRead, - cancellationToken); + using var response = await _httpClient.GetAsync( + ConnectivityCheckUri, + HttpCompletionOption.ResponseHeadersRead, + cancellationToken); - if (response.IsSuccessStatusCode) - { - _logger.LogSuccess("[Engine] Internet connection verified."); - return true; - } + if (response.IsSuccessStatusCode) + { + _logger.LogSuccess("[Engine] Internet connection verified."); + return true; } - catch (HttpRequestException) { /* Request failed - will retry */ } - catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) { /* HttpClient timeout - will retry */ } - - _logger.LogInfo("[Engine] Waiting for network..."); - await Task.Delay(2000, cancellationToken); } + catch (HttpRequestException) { /* Request failed - will retry */ } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) { /* HttpClient timeout - will retry */ } + + _logger.LogInfo("[Engine] Waiting for network..."); + await Task.Delay(2000, cancellationToken); + } return false; + } } } From c4270820f1298c2b72f27ffca9d28411611d2719 Mon Sep 17 00:00:00 2001 From: Bhagyashri Date: Wed, 22 Jul 2026 08:44:48 +0530 Subject: [PATCH 08/10] style: remove trailing whitespace at end of file --- src/Engine.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Engine.cs b/src/Engine.cs index 0ba2449a..9ec335f4 100644 --- a/src/Engine.cs +++ b/src/Engine.cs @@ -739,7 +739,6 @@ private async Task WaitForNetwork(int timeoutSeconds = 30, CancellationTok await Task.Delay(2000, cancellationToken); } return false; - } } } From cd2a4ad5eb8a2596992de33c88f05e0225b48b91 Mon Sep 17 00:00:00 2001 From: Bhagyashri Date: Wed, 22 Jul 2026 08:50:32 +0530 Subject: [PATCH 09/10] style: format python files with black --- plugins/7-zip/src/plugin.py | 543 ++++++------ plugins/7-zip/test/test_7zip.py | 513 ++++++------ plugins/alacritty/src/plugin.py | 9 +- plugins/alacritty/test/test_alacritty.py | 372 ++++----- plugins/audacity/src/plugin.py | 4 +- plugins/audacity/test/test_audacity.py | 4 +- plugins/autohotkey/src/plugin.py | 682 +++++++-------- plugins/autohotkey/test/test_autohotkey.py | 4 +- plugins/bat/src/plugin.py | 38 +- plugins/betterdiscord/src/plugin.py | 400 ++++----- .../betterdiscord/test/test_betterdiscord.py | 4 +- plugins/cargo/src/plugin.py | 13 +- plugins/cargo/test/test_cargo.py | 326 ++++---- plugins/chezmoi/src/plugin.py | 8 +- plugins/chezmoi/test/test_chezmoi.py | 394 ++++----- plugins/chocolatey/src/plugin.py | 444 +++++----- plugins/chocolatey/test/test_chocolatey.py | 474 +++++------ plugins/curl/src/plugin.py | 466 +++++------ plugins/deno/src/plugin.py | 9 +- plugins/deno/test/test_deno.py | 18 +- plugins/discord/test/test_discord.py | 4 +- plugins/ditto/src/plugin.py | 5 +- plugins/ditto/test/test_ditto.py | 8 +- plugins/docker/src/plugin.py | 8 +- plugins/docker/test/test_docker.py | 282 +++---- plugins/espanso/src/plugin.py | 778 ++++++++--------- plugins/espanso/test/test_espanso.py | 24 +- plugins/everything/src/plugin.py | 26 +- plugins/everything/test/test_everything.py | 10 +- plugins/flow-launcher/src/plugin.py | 8 +- plugins/flow-launcher/test/test_plugin.py | 21 +- plugins/fzf/src/plugin.py | 647 ++++++++------- plugins/fzf/test/test_plugin.py | 563 ++++++------- plugins/gh-dash/src/plugin.py | 722 ++++++++-------- plugins/gh-dash/test/test_gh_dash.py | 782 +++++++++--------- plugins/gh/src/plugin.py | 8 +- plugins/gh/test/test_gh.py | 16 +- plugins/github-desktop/src/plugin.py | 12 +- .../test/test_github_desktop.py | 4 +- plugins/go/src/plugin.py | 24 +- plugins/go/test/test_go.py | 36 +- plugins/greenshot/src/plugin.py | 4 +- plugins/helix-editor/src/plugin.py | 14 +- plugins/helix-editor/test/test_plugin.py | 256 +++--- plugins/irfanview/src/plugin.py | 45 +- plugins/irfanview/test/test_irfanview.py | 9 +- plugins/joplin/src/plugin.py | 6 +- plugins/keepassxc/src/plugin.py | 624 +++++++------- plugins/keepassxc/test/test_keepassxc.py | 278 +++---- plugins/lazydocker/src/plugin.py | 405 ++++----- plugins/lazydocker/test/test_lazydocker.py | 262 +++--- plugins/lazygit/src/plugin.py | 4 +- plugins/lazygit/test/test_lazygit.py | 292 +++---- plugins/miniconda/src/plugin.py | 4 +- plugins/miniconda/test/test_plugin.py | 12 +- plugins/mise/src/plugin.py | 4 +- plugins/mise/test/test_mise.py | 348 ++++---- .../test/test_notepadplusplus.py | 8 +- plugins/npm/test/test_npm.py | 250 +++--- plugins/nuget/src/plugin.py | 26 +- plugins/nuget/test/test_nuget.py | 32 +- plugins/nvm/src/plugin.py | 16 +- plugins/nvm/test/test_nvm.py | 4 +- plugins/obs-studio/src/plugin.py | 710 ++++++++-------- plugins/obs-studio/test/test_obs_studio.py | 550 ++++++------ plugins/obsidian/src/plugin.py | 20 +- plugins/obsidian/test/test_obsidian.py | 20 +- plugins/ohmyposh/src/plugin.py | 27 +- plugins/ohmyposh/test/test_omp.py | 13 +- plugins/opencode/src/plugin.py | 522 ++++++------ plugins/opencode/test/test_opencode.py | 586 ++++++------- plugins/openssh/src/plugin.py | 590 ++++++------- plugins/pip/src/plugin.py | 335 ++++---- plugins/pip/test/test_pip.py | 260 +++--- plugins/pnpm/src/plugin.py | 4 +- plugins/pnpm/test/test_pnpm.py | 370 +++++---- plugins/powershell/src/plugin.py | 496 +++++------ plugins/powertoys/src/main.py | 4 +- plugins/powertoys/test/test_powertoys.py | 4 +- plugins/rainmeter/src/plugin.py | 9 +- plugins/rainmeter/test/test_rainmeter.py | 26 +- plugins/rclone/src/plugin.py | 550 ++++++------ plugins/rclone/test/test_rclone.py | 252 +++--- plugins/ripgrep/src/plugin.py | 544 ++++++------ plugins/ripgrep/test/test_plugin.py | 229 ++--- plugins/rustup/src/plugin.py | 25 +- plugins/scoop/src/plugin.py | 4 +- plugins/scoop/test/test_scoop.py | 394 ++++----- plugins/sdkman/src/plugin.py | 410 ++++----- plugins/sharex/src/plugin.py | 340 ++++---- plugins/sharex/test/test_sharex.py | 316 +++---- plugins/spicetify/src/plugin.py | 489 +++++------ plugins/spicetify/test/test_spicetify.py | 40 +- plugins/spotify/test/test_spotify.py | 8 +- plugins/starship/src/plugin.py | 8 +- plugins/starship/test/test_starship.py | 264 +++--- plugins/sublime-text/src/plugin.py | 5 +- .../sublime-text/test/test_sublime_text.py | 4 +- plugins/syncthing/src/plugin.py | 4 +- plugins/syncthing/test/test_syncthing.py | 16 +- plugins/topgrade/src/plugin.py | 9 +- plugins/topgrade/test/test_topgrade.py | 10 +- plugins/vim/src/main.py | 4 +- plugins/vim/tests/test_vim.py | 8 +- plugins/vlc/src/plugin.py | 9 +- plugins/vlc/test/test_vlc.py | 34 +- plugins/wallpaper-engine/src/plugin.py | 45 +- .../test/test_wallpaper_engine.py | 20 +- plugins/windows-explorer/src/plugin.py | 13 +- .../test/test_windows_explorer.py | 20 +- plugins/windows-sandbox/src/plugin.py | 4 +- .../test/test_windows_sandbox.py | 4 +- plugins/winget/src/plugin.py | 4 +- plugins/yarn/src/plugin.py | 41 +- plugins/yarn/test/test_yarn.py | 4 +- plugins/yasb/src/plugin.py | 22 +- plugins/yasb/test/test_yasb.py | 560 +++++++------ plugins/yazi/test/test_yazi.py | 14 +- plugins/zed/src/plugin.py | 656 ++++++++------- plugins/zed/test/test_zed.py | 674 +++++++-------- plugins/zoxide/src/plugin.py | 23 +- plugins/zoxide/test/test_zoxide.py | 696 ++++++++-------- tests/TestPlugin/src/main.py | 5 +- 123 files changed, 11995 insertions(+), 10948 deletions(-) diff --git a/plugins/7-zip/src/plugin.py b/plugins/7-zip/src/plugin.py index 085aa920..42e84cd4 100644 --- a/plugins/7-zip/src/plugin.py +++ b/plugins/7-zip/src/plugin.py @@ -1,258 +1,285 @@ -import datetime -import json -import os -import shutil -import subprocess -import sys -import uuid - -# On non-Windows platforms (like CI environments), winreg doesn't exist. -# Let's import it gracefully to allow testing and cross-platform compatibility. -try: - import winreg -except ImportError: - winreg = None - -REG_PATH = r"Software\7-Zip" - -KEY_TYPES = { - "CompressionLevel": 4, # winreg.REG_DWORD - "CompressionMethod": 1, # winreg.REG_SZ - "EncryptHeaders": 4, # winreg.REG_DWORD - "ContextMenu": 4, # winreg.REG_DWORD - "InstallDir": 1, # winreg.REG_SZ -} - - -def log(msg): - sys.stderr.write(f"[7-zip-plugin] {msg}\n") - sys.stderr.flush() - - -def _backup_corrupt_registry(reason): - timestamp = datetime.datetime.now(datetime.timezone.utc).strftime("%Y%m%d%H%M%S") - suffix = uuid.uuid4().hex[:8] - user_profile = os.getenv("USERPROFILE") or os.path.expanduser("~") - backup_path = os.path.join(user_profile, f"7zip_registry.corrupted.{timestamp}.{suffix}.reg") - log(f"Registry read failed ({reason}). Backing up HKCU\\{REG_PATH} to {backup_path}") - try: - subprocess.run(["reg.exe", "export", f"HKCU\\{REG_PATH}", backup_path, "/y"], capture_output=True, check=True) - except Exception as backup_e: - log(f"Failed to backup registry key: {backup_e}") - - -def read_settings(): - settings = {} - if winreg is None: - log("winreg module not available.") - return settings - - try: - with winreg.OpenKey(winreg.HKEY_CURRENT_USER, REG_PATH, 0, winreg.KEY_READ) as key: - i = 0 - while True: - try: - name, value, val_type = winreg.EnumValue(key, i) - if name in KEY_TYPES: - if KEY_TYPES[name] == 4: # REG_DWORD - if name == "EncryptHeaders": - settings[name] = bool(value) - else: - settings[name] = int(value) - else: - settings[name] = str(value) - else: - # Fallback parsing for unknown keys - if val_type == 4: - settings[name] = int(value) - elif val_type in (1, 2): # REG_SZ, REG_EXPAND_SZ - settings[name] = str(value) - else: - settings[name] = value - i += 1 - except OSError: - break - except FileNotFoundError: - # Key doesn't exist yet, return empty dict - pass - except Exception as e: - log(f"Error reading registry: {e}") - _backup_corrupt_registry(e) - return settings - - -def check_installed(args, request_id): - is_installed = False - - # Check PATH first - if shutil.which("7z.exe") or shutil.which("7z"): - is_installed = True - elif winreg is not None: - # Check registry key - try: - with winreg.OpenKey(winreg.HKEY_CURRENT_USER, REG_PATH, 0, winreg.KEY_READ): - is_installed = True - except FileNotFoundError: - pass - except Exception as e: - log(f"Error checking registry: {e}") - - return {"requestId": request_id, "success": True, "changed": False, "data": is_installed} - - -def apply_config(args, context, request_id): - dry_run = context.get("dryRun", False) - desired = args.get("settings", {}) - if not isinstance(desired, dict): - return {"requestId": request_id, "success": False, "changed": False, "error": "settings must be a dictionary"} - - current = read_settings() - changed = False - to_update = {} - - for key, val in desired.items(): - # Validate values - if key == "CompressionLevel": - if not isinstance(val, int) or not (0 <= val <= 9): - return { - "requestId": request_id, - "success": False, - "changed": False, - "error": f"Invalid CompressionLevel: {val}. Must be an integer between 0 and 9.", - } - elif key == "CompressionMethod": - if not isinstance(val, str): - return { - "requestId": request_id, - "success": False, - "changed": False, - "error": f"Invalid CompressionMethod: {val}. Must be a string.", - } - elif key == "EncryptHeaders": - if not isinstance(val, bool): - return { - "requestId": request_id, - "success": False, - "changed": False, - "error": f"Invalid EncryptHeaders: {val}. Must be a boolean.", - } - elif key == "ContextMenu": - if not isinstance(val, int): - return { - "requestId": request_id, - "success": False, - "changed": False, - "error": f"Invalid ContextMenu: {val}. Must be an integer.", - } - elif key == "InstallDir": - if not isinstance(val, str): - return { - "requestId": request_id, - "success": False, - "changed": False, - "error": f"Invalid InstallDir: {val}. Must be a string.", - } - - # Compare values - if current.get(key) != val: - to_update[key] = val - changed = True - - if not changed: - return {"requestId": request_id, "success": True, "changed": False} - - if dry_run: - log(f"Dry Run: Would update registry values: {to_update}") - return {"requestId": request_id, "success": True, "changed": True} - - if winreg is None: - return { - "requestId": request_id, - "success": False, - "changed": False, - "error": "winreg module not available on this platform.", - } - - try: - with winreg.CreateKeyEx(winreg.HKEY_CURRENT_USER, REG_PATH, 0, winreg.KEY_SET_VALUE) as key: - for k, v in to_update.items(): - reg_type = KEY_TYPES.get(k) - if reg_type is None: - if isinstance(v, bool): - reg_type = 4 # REG_DWORD - reg_val = 1 if v else 0 - elif isinstance(v, int): - reg_type = 4 # REG_DWORD - reg_val = v - else: - reg_type = 1 # REG_SZ - reg_val = str(v) - else: - if reg_type == 4: - if isinstance(v, bool): - reg_val = 1 if v else 0 - else: - reg_val = int(v) - else: - reg_val = str(v) - - winreg.SetValueEx(key, k, 0, reg_type, reg_val) - log(f"Updated registry: {k} = {reg_val}") - - return {"requestId": request_id, "success": True, "changed": True} - except Exception as e: - return { - "requestId": request_id, - "success": False, - "changed": False, - "error": f"Failed to write to registry: {e}", - } - - -def main(): - input_data = sys.stdin.read() - if not input_data or not input_data.strip(): - response = { - "requestId": "unknown", - "success": False, - "changed": False, - "error": "Empty input", - "data": None, - } - sys.stdout.write(json.dumps(response) + "\n") - sys.stdout.flush() - return - - try: - request = json.loads(input_data) - except Exception as e: - log(f"Failed to parse request: {e}") - response = { - "requestId": "unknown", - "success": False, - "changed": False, - "error": f"Failed to parse JSON request: {str(e)}", - "data": None, - } - sys.stdout.write(json.dumps(response) + "\n") - sys.stdout.flush() - return - - command = request.get("command") - args = request.get("args", {}) - context = request.get("context", {}) - request_id = request.get("requestId", "") - - if command == "apply": - response = apply_config(args, context, request_id) - elif command == "check_installed": - response = check_installed(args, request_id) - else: - response = {"requestId": request_id, "success": False, "changed": False, "error": f"Unknown command: {command}"} - - sys.stdout.write(json.dumps(response) + "\n") - sys.stdout.flush() - - -if __name__ == "__main__": - main() +import datetime +import json +import os +import shutil +import subprocess +import sys +import uuid + +# On non-Windows platforms (like CI environments), winreg doesn't exist. +# Let's import it gracefully to allow testing and cross-platform compatibility. +try: + import winreg +except ImportError: + winreg = None + +REG_PATH = r"Software\7-Zip" + +KEY_TYPES = { + "CompressionLevel": 4, # winreg.REG_DWORD + "CompressionMethod": 1, # winreg.REG_SZ + "EncryptHeaders": 4, # winreg.REG_DWORD + "ContextMenu": 4, # winreg.REG_DWORD + "InstallDir": 1, # winreg.REG_SZ +} + + +def log(msg): + sys.stderr.write(f"[7-zip-plugin] {msg}\n") + sys.stderr.flush() + + +def _backup_corrupt_registry(reason): + timestamp = datetime.datetime.now(datetime.timezone.utc).strftime("%Y%m%d%H%M%S") + suffix = uuid.uuid4().hex[:8] + user_profile = os.getenv("USERPROFILE") or os.path.expanduser("~") + backup_path = os.path.join( + user_profile, f"7zip_registry.corrupted.{timestamp}.{suffix}.reg" + ) + log( + f"Registry read failed ({reason}). Backing up HKCU\\{REG_PATH} to {backup_path}" + ) + try: + subprocess.run( + ["reg.exe", "export", f"HKCU\\{REG_PATH}", backup_path, "/y"], + capture_output=True, + check=True, + ) + except Exception as backup_e: + log(f"Failed to backup registry key: {backup_e}") + + +def read_settings(): + settings = {} + if winreg is None: + log("winreg module not available.") + return settings + + try: + with winreg.OpenKey( + winreg.HKEY_CURRENT_USER, REG_PATH, 0, winreg.KEY_READ + ) as key: + i = 0 + while True: + try: + name, value, val_type = winreg.EnumValue(key, i) + if name in KEY_TYPES: + if KEY_TYPES[name] == 4: # REG_DWORD + if name == "EncryptHeaders": + settings[name] = bool(value) + else: + settings[name] = int(value) + else: + settings[name] = str(value) + else: + # Fallback parsing for unknown keys + if val_type == 4: + settings[name] = int(value) + elif val_type in (1, 2): # REG_SZ, REG_EXPAND_SZ + settings[name] = str(value) + else: + settings[name] = value + i += 1 + except OSError: + break + except FileNotFoundError: + # Key doesn't exist yet, return empty dict + pass + except Exception as e: + log(f"Error reading registry: {e}") + _backup_corrupt_registry(e) + return settings + + +def check_installed(args, request_id): + is_installed = False + + # Check PATH first + if shutil.which("7z.exe") or shutil.which("7z"): + is_installed = True + elif winreg is not None: + # Check registry key + try: + with winreg.OpenKey(winreg.HKEY_CURRENT_USER, REG_PATH, 0, winreg.KEY_READ): + is_installed = True + except FileNotFoundError: + pass + except Exception as e: + log(f"Error checking registry: {e}") + + return { + "requestId": request_id, + "success": True, + "changed": False, + "data": is_installed, + } + + +def apply_config(args, context, request_id): + dry_run = context.get("dryRun", False) + desired = args.get("settings", {}) + if not isinstance(desired, dict): + return { + "requestId": request_id, + "success": False, + "changed": False, + "error": "settings must be a dictionary", + } + + current = read_settings() + changed = False + to_update = {} + + for key, val in desired.items(): + # Validate values + if key == "CompressionLevel": + if not isinstance(val, int) or not (0 <= val <= 9): + return { + "requestId": request_id, + "success": False, + "changed": False, + "error": f"Invalid CompressionLevel: {val}. Must be an integer between 0 and 9.", + } + elif key == "CompressionMethod": + if not isinstance(val, str): + return { + "requestId": request_id, + "success": False, + "changed": False, + "error": f"Invalid CompressionMethod: {val}. Must be a string.", + } + elif key == "EncryptHeaders": + if not isinstance(val, bool): + return { + "requestId": request_id, + "success": False, + "changed": False, + "error": f"Invalid EncryptHeaders: {val}. Must be a boolean.", + } + elif key == "ContextMenu": + if not isinstance(val, int): + return { + "requestId": request_id, + "success": False, + "changed": False, + "error": f"Invalid ContextMenu: {val}. Must be an integer.", + } + elif key == "InstallDir": + if not isinstance(val, str): + return { + "requestId": request_id, + "success": False, + "changed": False, + "error": f"Invalid InstallDir: {val}. Must be a string.", + } + + # Compare values + if current.get(key) != val: + to_update[key] = val + changed = True + + if not changed: + return {"requestId": request_id, "success": True, "changed": False} + + if dry_run: + log(f"Dry Run: Would update registry values: {to_update}") + return {"requestId": request_id, "success": True, "changed": True} + + if winreg is None: + return { + "requestId": request_id, + "success": False, + "changed": False, + "error": "winreg module not available on this platform.", + } + + try: + with winreg.CreateKeyEx( + winreg.HKEY_CURRENT_USER, REG_PATH, 0, winreg.KEY_SET_VALUE + ) as key: + for k, v in to_update.items(): + reg_type = KEY_TYPES.get(k) + if reg_type is None: + if isinstance(v, bool): + reg_type = 4 # REG_DWORD + reg_val = 1 if v else 0 + elif isinstance(v, int): + reg_type = 4 # REG_DWORD + reg_val = v + else: + reg_type = 1 # REG_SZ + reg_val = str(v) + else: + if reg_type == 4: + if isinstance(v, bool): + reg_val = 1 if v else 0 + else: + reg_val = int(v) + else: + reg_val = str(v) + + winreg.SetValueEx(key, k, 0, reg_type, reg_val) + log(f"Updated registry: {k} = {reg_val}") + + return {"requestId": request_id, "success": True, "changed": True} + except Exception as e: + return { + "requestId": request_id, + "success": False, + "changed": False, + "error": f"Failed to write to registry: {e}", + } + + +def main(): + input_data = sys.stdin.read() + if not input_data or not input_data.strip(): + response = { + "requestId": "unknown", + "success": False, + "changed": False, + "error": "Empty input", + "data": None, + } + sys.stdout.write(json.dumps(response) + "\n") + sys.stdout.flush() + return + + try: + request = json.loads(input_data) + except Exception as e: + log(f"Failed to parse request: {e}") + response = { + "requestId": "unknown", + "success": False, + "changed": False, + "error": f"Failed to parse JSON request: {str(e)}", + "data": None, + } + sys.stdout.write(json.dumps(response) + "\n") + sys.stdout.flush() + return + + command = request.get("command") + args = request.get("args", {}) + context = request.get("context", {}) + request_id = request.get("requestId", "") + + if command == "apply": + response = apply_config(args, context, request_id) + elif command == "check_installed": + response = check_installed(args, request_id) + else: + response = { + "requestId": request_id, + "success": False, + "changed": False, + "error": f"Unknown command: {command}", + } + + sys.stdout.write(json.dumps(response) + "\n") + sys.stdout.flush() + + +if __name__ == "__main__": + main() diff --git a/plugins/7-zip/test/test_7zip.py b/plugins/7-zip/test/test_7zip.py index 040f58e9..940764d6 100644 --- a/plugins/7-zip/test/test_7zip.py +++ b/plugins/7-zip/test/test_7zip.py @@ -1,249 +1,264 @@ -import importlib.util -import unittest -from pathlib import Path -from unittest.mock import MagicMock, patch - -PLUGIN_PATH = Path(__file__).resolve().parents[1] / "src" / "plugin.py" - -spec = importlib.util.spec_from_file_location("7zip_plugin", PLUGIN_PATH) -plugin = importlib.util.module_from_spec(spec) -assert spec and spec.loader -spec.loader.exec_module(plugin) - - -class Test7ZipPlugin(unittest.TestCase): - def test_check_installed_returns_true_when_7z_is_in_path(self): - with patch.object(plugin.shutil, "which", return_value="C:/Program Files/7-Zip/7z.exe"): - result = plugin.check_installed({}, "req-1") - - self.assertEqual(result["requestId"], "req-1") - self.assertTrue(result["success"]) - self.assertFalse(result["changed"]) - self.assertTrue(result["data"]) - - def test_check_installed_returns_true_when_registry_key_exists(self): - mock_winreg = MagicMock() - mock_winreg.OpenKey.return_value = MagicMock() - plugin.winreg = mock_winreg - - with patch.object(plugin.shutil, "which", return_value=None): - result = plugin.check_installed({}, "req-2") - - self.assertEqual(result["requestId"], "req-2") - self.assertTrue(result["success"]) - self.assertFalse(result["changed"]) - self.assertTrue(result["data"]) - - def test_check_installed_returns_false_when_missing(self): - mock_winreg = MagicMock() - mock_winreg.OpenKey.side_effect = FileNotFoundError() - plugin.winreg = mock_winreg - - with patch.object(plugin.shutil, "which", return_value=None): - result = plugin.check_installed({}, "req-3") - - self.assertEqual(result["requestId"], "req-3") - self.assertTrue(result["success"]) - self.assertFalse(result["changed"]) - self.assertFalse(result["data"]) - - def test_apply_config_skips_when_no_changes_needed(self): - mock_winreg = MagicMock() - registry_values = [ - ("CompressionLevel", 5, 4), - ("CompressionMethod", "LZMA2", 1), - ("EncryptHeaders", 1, 4), - ] - - def enum_val(key, idx): - if idx < len(registry_values): - return registry_values[idx] - raise OSError("No more values") - - mock_winreg.EnumValue.side_effect = enum_val - plugin.winreg = mock_winreg - - result = plugin.apply_config( - {"settings": {"CompressionLevel": 5, "CompressionMethod": "LZMA2", "EncryptHeaders": True}}, - {"dryRun": False}, - "req-4", - ) - - self.assertEqual(result["requestId"], "req-4") - self.assertTrue(result["success"]) - self.assertFalse(result["changed"]) - mock_winreg.SetValueEx.assert_not_called() - - def test_apply_config_updates_when_changes_needed(self): - mock_winreg = MagicMock() - registry_values = [ - ("CompressionLevel", 5, 4), - ] - - def enum_val(key, idx): - if idx < len(registry_values): - return registry_values[idx] - raise OSError("No more values") - - mock_winreg.EnumValue.side_effect = enum_val - plugin.winreg = mock_winreg - - result = plugin.apply_config( - { - "settings": { - "CompressionLevel": 9, - } - }, - {"dryRun": False}, - "req-5", - ) - - self.assertEqual(result["requestId"], "req-5") - self.assertTrue(result["success"]) - self.assertTrue(result["changed"]) - - mock_winreg.SetValueEx.assert_called_once() - args = mock_winreg.SetValueEx.call_args[0] - self.assertEqual(args[1], "CompressionLevel") - self.assertEqual(args[3], 4) # REG_DWORD - self.assertEqual(args[4], 9) - - def test_apply_config_dry_run_does_not_modify_registry(self): - mock_winreg = MagicMock() - registry_values = [ - ("CompressionLevel", 5, 4), - ] - - def enum_val(key, idx): - if idx < len(registry_values): - return registry_values[idx] - raise OSError("No more values") - - mock_winreg.EnumValue.side_effect = enum_val - plugin.winreg = mock_winreg - - result = plugin.apply_config( - { - "settings": { - "CompressionLevel": 9, - } - }, - {"dryRun": True}, - "req-6", - ) - - self.assertEqual(result["requestId"], "req-6") - self.assertTrue(result["success"]) - self.assertTrue(result["changed"]) - mock_winreg.SetValueEx.assert_not_called() - - def test_apply_config_validates_inputs(self): - plugin.winreg = MagicMock() - - # Invalid CompressionLevel type - res = plugin.apply_config({"settings": {"CompressionLevel": "high"}}, {}, "req-7") - self.assertFalse(res["success"]) - self.assertIn("CompressionLevel", res["error"]) - - # Invalid CompressionLevel range - res = plugin.apply_config({"settings": {"CompressionLevel": 10}}, {}, "req-8") - self.assertFalse(res["success"]) - self.assertIn("CompressionLevel", res["error"]) - - # Invalid CompressionMethod type - res = plugin.apply_config({"settings": {"CompressionMethod": 123}}, {}, "req-9") - self.assertFalse(res["success"]) - self.assertIn("CompressionMethod", res["error"]) - - # Invalid EncryptHeaders type - res = plugin.apply_config({"settings": {"EncryptHeaders": "True"}}, {}, "req-10") - self.assertFalse(res["success"]) - self.assertIn("EncryptHeaders", res["error"]) - - # Invalid ContextMenu type - res = plugin.apply_config({"settings": {"ContextMenu": "style1"}}, {}, "req-11") - self.assertFalse(res["success"]) - self.assertIn("ContextMenu", res["error"]) - - # Invalid InstallDir type - res = plugin.apply_config({"settings": {"InstallDir": ["C:/"]}}, {}, "req-12") - self.assertFalse(res["success"]) - self.assertIn("InstallDir", res["error"]) - - def test_apply_config_handles_write_errors(self): - mock_winreg = MagicMock() - mock_winreg.OpenKey.side_effect = FileNotFoundError() - mock_winreg.CreateKeyEx.side_effect = PermissionError("Access denied") - plugin.winreg = mock_winreg - - res = plugin.apply_config( - { - "settings": { - "CompressionLevel": 9, - } - }, - {"dryRun": False}, - "req-13", - ) - - self.assertFalse(res["success"]) - self.assertIn("Access denied", res["error"]) - - def test_apply_config_handles_missing_winreg_module(self): - plugin.winreg = None - - res = plugin.apply_config( - { - "settings": { - "CompressionLevel": 9, - } - }, - {"dryRun": False}, - "req-14", - ) - - self.assertFalse(res["success"]) - self.assertIn("winreg module not available", res["error"]) - - def test_main_empty_input(self): - import io - import json - - with patch("sys.stdin", io.StringIO("")), patch("sys.stdout", new_callable=io.StringIO) as mock_stdout: - plugin.main() - output = json.loads(mock_stdout.getvalue().strip()) - self.assertEqual(output["requestId"], "unknown") - self.assertFalse(output["success"]) - self.assertIn("Empty input", output["error"]) - - def test_main_invalid_json(self): - import io - import json - - with ( - patch("sys.stdin", io.StringIO("{invalid json")), - patch("sys.stdout", new_callable=io.StringIO) as mock_stdout, - ): - plugin.main() - output = json.loads(mock_stdout.getvalue().strip()) - self.assertEqual(output["requestId"], "unknown") - self.assertFalse(output["success"]) - self.assertIn("Failed to parse JSON request", output["error"]) - - def test_read_settings_registry_corruption_triggers_backup(self): - mock_winreg = MagicMock() - mock_winreg.OpenKey.side_effect = Exception("Registry corruption error") - plugin.winreg = mock_winreg - - with patch("subprocess.run") as mock_run: - settings = plugin.read_settings() - self.assertEqual(settings, {}) - mock_run.assert_called_once() - args = mock_run.call_args[0][0] - self.assertEqual(args[0], "reg.exe") - self.assertEqual(args[1], "export") - self.assertEqual(args[2], "HKCU\\Software\\7-Zip") - - -if __name__ == "__main__": - unittest.main() +import importlib.util +import unittest +from pathlib import Path +from unittest.mock import MagicMock, patch + +PLUGIN_PATH = Path(__file__).resolve().parents[1] / "src" / "plugin.py" + +spec = importlib.util.spec_from_file_location("7zip_plugin", PLUGIN_PATH) +plugin = importlib.util.module_from_spec(spec) +assert spec and spec.loader +spec.loader.exec_module(plugin) + + +class Test7ZipPlugin(unittest.TestCase): + def test_check_installed_returns_true_when_7z_is_in_path(self): + with patch.object( + plugin.shutil, "which", return_value="C:/Program Files/7-Zip/7z.exe" + ): + result = plugin.check_installed({}, "req-1") + + self.assertEqual(result["requestId"], "req-1") + self.assertTrue(result["success"]) + self.assertFalse(result["changed"]) + self.assertTrue(result["data"]) + + def test_check_installed_returns_true_when_registry_key_exists(self): + mock_winreg = MagicMock() + mock_winreg.OpenKey.return_value = MagicMock() + plugin.winreg = mock_winreg + + with patch.object(plugin.shutil, "which", return_value=None): + result = plugin.check_installed({}, "req-2") + + self.assertEqual(result["requestId"], "req-2") + self.assertTrue(result["success"]) + self.assertFalse(result["changed"]) + self.assertTrue(result["data"]) + + def test_check_installed_returns_false_when_missing(self): + mock_winreg = MagicMock() + mock_winreg.OpenKey.side_effect = FileNotFoundError() + plugin.winreg = mock_winreg + + with patch.object(plugin.shutil, "which", return_value=None): + result = plugin.check_installed({}, "req-3") + + self.assertEqual(result["requestId"], "req-3") + self.assertTrue(result["success"]) + self.assertFalse(result["changed"]) + self.assertFalse(result["data"]) + + def test_apply_config_skips_when_no_changes_needed(self): + mock_winreg = MagicMock() + registry_values = [ + ("CompressionLevel", 5, 4), + ("CompressionMethod", "LZMA2", 1), + ("EncryptHeaders", 1, 4), + ] + + def enum_val(key, idx): + if idx < len(registry_values): + return registry_values[idx] + raise OSError("No more values") + + mock_winreg.EnumValue.side_effect = enum_val + plugin.winreg = mock_winreg + + result = plugin.apply_config( + { + "settings": { + "CompressionLevel": 5, + "CompressionMethod": "LZMA2", + "EncryptHeaders": True, + } + }, + {"dryRun": False}, + "req-4", + ) + + self.assertEqual(result["requestId"], "req-4") + self.assertTrue(result["success"]) + self.assertFalse(result["changed"]) + mock_winreg.SetValueEx.assert_not_called() + + def test_apply_config_updates_when_changes_needed(self): + mock_winreg = MagicMock() + registry_values = [ + ("CompressionLevel", 5, 4), + ] + + def enum_val(key, idx): + if idx < len(registry_values): + return registry_values[idx] + raise OSError("No more values") + + mock_winreg.EnumValue.side_effect = enum_val + plugin.winreg = mock_winreg + + result = plugin.apply_config( + { + "settings": { + "CompressionLevel": 9, + } + }, + {"dryRun": False}, + "req-5", + ) + + self.assertEqual(result["requestId"], "req-5") + self.assertTrue(result["success"]) + self.assertTrue(result["changed"]) + + mock_winreg.SetValueEx.assert_called_once() + args = mock_winreg.SetValueEx.call_args[0] + self.assertEqual(args[1], "CompressionLevel") + self.assertEqual(args[3], 4) # REG_DWORD + self.assertEqual(args[4], 9) + + def test_apply_config_dry_run_does_not_modify_registry(self): + mock_winreg = MagicMock() + registry_values = [ + ("CompressionLevel", 5, 4), + ] + + def enum_val(key, idx): + if idx < len(registry_values): + return registry_values[idx] + raise OSError("No more values") + + mock_winreg.EnumValue.side_effect = enum_val + plugin.winreg = mock_winreg + + result = plugin.apply_config( + { + "settings": { + "CompressionLevel": 9, + } + }, + {"dryRun": True}, + "req-6", + ) + + self.assertEqual(result["requestId"], "req-6") + self.assertTrue(result["success"]) + self.assertTrue(result["changed"]) + mock_winreg.SetValueEx.assert_not_called() + + def test_apply_config_validates_inputs(self): + plugin.winreg = MagicMock() + + # Invalid CompressionLevel type + res = plugin.apply_config( + {"settings": {"CompressionLevel": "high"}}, {}, "req-7" + ) + self.assertFalse(res["success"]) + self.assertIn("CompressionLevel", res["error"]) + + # Invalid CompressionLevel range + res = plugin.apply_config({"settings": {"CompressionLevel": 10}}, {}, "req-8") + self.assertFalse(res["success"]) + self.assertIn("CompressionLevel", res["error"]) + + # Invalid CompressionMethod type + res = plugin.apply_config({"settings": {"CompressionMethod": 123}}, {}, "req-9") + self.assertFalse(res["success"]) + self.assertIn("CompressionMethod", res["error"]) + + # Invalid EncryptHeaders type + res = plugin.apply_config( + {"settings": {"EncryptHeaders": "True"}}, {}, "req-10" + ) + self.assertFalse(res["success"]) + self.assertIn("EncryptHeaders", res["error"]) + + # Invalid ContextMenu type + res = plugin.apply_config({"settings": {"ContextMenu": "style1"}}, {}, "req-11") + self.assertFalse(res["success"]) + self.assertIn("ContextMenu", res["error"]) + + # Invalid InstallDir type + res = plugin.apply_config({"settings": {"InstallDir": ["C:/"]}}, {}, "req-12") + self.assertFalse(res["success"]) + self.assertIn("InstallDir", res["error"]) + + def test_apply_config_handles_write_errors(self): + mock_winreg = MagicMock() + mock_winreg.OpenKey.side_effect = FileNotFoundError() + mock_winreg.CreateKeyEx.side_effect = PermissionError("Access denied") + plugin.winreg = mock_winreg + + res = plugin.apply_config( + { + "settings": { + "CompressionLevel": 9, + } + }, + {"dryRun": False}, + "req-13", + ) + + self.assertFalse(res["success"]) + self.assertIn("Access denied", res["error"]) + + def test_apply_config_handles_missing_winreg_module(self): + plugin.winreg = None + + res = plugin.apply_config( + { + "settings": { + "CompressionLevel": 9, + } + }, + {"dryRun": False}, + "req-14", + ) + + self.assertFalse(res["success"]) + self.assertIn("winreg module not available", res["error"]) + + def test_main_empty_input(self): + import io + import json + + with ( + patch("sys.stdin", io.StringIO("")), + patch("sys.stdout", new_callable=io.StringIO) as mock_stdout, + ): + plugin.main() + output = json.loads(mock_stdout.getvalue().strip()) + self.assertEqual(output["requestId"], "unknown") + self.assertFalse(output["success"]) + self.assertIn("Empty input", output["error"]) + + def test_main_invalid_json(self): + import io + import json + + with ( + patch("sys.stdin", io.StringIO("{invalid json")), + patch("sys.stdout", new_callable=io.StringIO) as mock_stdout, + ): + plugin.main() + output = json.loads(mock_stdout.getvalue().strip()) + self.assertEqual(output["requestId"], "unknown") + self.assertFalse(output["success"]) + self.assertIn("Failed to parse JSON request", output["error"]) + + def test_read_settings_registry_corruption_triggers_backup(self): + mock_winreg = MagicMock() + mock_winreg.OpenKey.side_effect = Exception("Registry corruption error") + plugin.winreg = mock_winreg + + with patch("subprocess.run") as mock_run: + settings = plugin.read_settings() + self.assertEqual(settings, {}) + mock_run.assert_called_once() + args = mock_run.call_args[0][0] + self.assertEqual(args[0], "reg.exe") + self.assertEqual(args[1], "export") + self.assertEqual(args[2], "HKCU\\Software\\7-Zip") + + +if __name__ == "__main__": + unittest.main() diff --git a/plugins/alacritty/src/plugin.py b/plugins/alacritty/src/plugin.py index de797f20..666f62d5 100644 --- a/plugins/alacritty/src/plugin.py +++ b/plugins/alacritty/src/plugin.py @@ -13,7 +13,9 @@ def log(msg): def get_config_path() -> str: if sys.platform == "win32": - appdata = (os.environ.get("APPDATA") or os.path.join(os.path.expanduser("~"), "AppData", "Roaming")) + appdata = os.environ.get("APPDATA") or os.path.join( + os.path.expanduser("~"), "AppData", "Roaming" + ) if appdata: return os.path.join(appdata, "alacritty", "alacritty.toml") return os.path.expanduser("~/.config/alacritty/alacritty.toml") @@ -115,7 +117,10 @@ def merge_settings(target: dict, source: dict) -> bool: def check_installed(args: dict, request_id: str) -> dict: - installed = shutil.which("alacritty.exe") is not None or shutil.which("alacritty") is not None + installed = ( + shutil.which("alacritty.exe") is not None + or shutil.which("alacritty") is not None + ) return { "requestId": request_id, "success": True, diff --git a/plugins/alacritty/test/test_alacritty.py b/plugins/alacritty/test/test_alacritty.py index 044c7a88..b8bf0179 100644 --- a/plugins/alacritty/test/test_alacritty.py +++ b/plugins/alacritty/test/test_alacritty.py @@ -1,184 +1,188 @@ -import json -import os -import sys -import tempfile -from unittest.mock import patch - -import pytest - -src_path = os.path.join(os.path.dirname(__file__), "..", "src") -sys.path.append(src_path) -try: - import plugin -finally: - sys.path.remove(src_path) - - -@pytest.fixture -def mock_context(): - return { - "os": "windows", - "arch": "x64", - "dryRun": False, - } - - -@patch("plugin.shutil.which") -def test_check_installed_true(mock_which): - mock_which.return_value = "/usr/local/bin/alacritty" - result = plugin.handle({"requestId": "req-1", "command": "check_installed", "args": {}}) - - assert result["success"] is True - assert result["data"] is True - - -@patch("plugin.shutil.which") -def test_check_installed_false(mock_which): - mock_which.return_value = None - result = plugin.handle({"requestId": "req-1", "command": "check_installed", "args": {}}) - - assert result["success"] is True - assert result["data"] is False - - -@patch("plugin.get_config_path") -def test_apply_config_creates_new(mock_get_config_path, mock_context): - with tempfile.TemporaryDirectory() as temp_dir: - config_path = os.path.join(temp_dir, "alacritty.toml") - mock_get_config_path.return_value = config_path - - settings = {"window": {"opacity": 0.95}, "font": {"size": 11.5}} - - result = plugin.handle( - { - "requestId": "req-2", - "command": "apply", - "args": {"settings": settings}, - "context": mock_context, - } - ) - - assert result["success"] is True - assert result["changed"] is True - assert os.path.exists(config_path) - - with open(config_path, "r") as f: - content = f.read() - assert "[window]" in content - assert "opacity = 0.95" in content - assert "[font]" in content - assert "size = 11.5" in content - - -@patch("plugin.get_config_path") -def test_apply_config_dry_run(mock_get_config_path): - with tempfile.TemporaryDirectory() as temp_dir: - config_path = os.path.join(temp_dir, "alacritty.toml") - mock_get_config_path.return_value = config_path - - settings = {"window": {"opacity": 1.0}} - - result = plugin.handle( - { - "requestId": "req-3", - "command": "apply", - "args": {"settings": settings}, - "context": {"dryRun": True}, - } - ) - - assert result["success"] is True - assert result["changed"] is True - assert not os.path.exists(config_path) - - -@patch("plugin.get_config_path") -def test_apply_config_invalid_toml_backup(mock_get_config_path, mock_context): - with tempfile.TemporaryDirectory() as temp_dir: - config_path = os.path.join(temp_dir, "alacritty.toml") - mock_get_config_path.return_value = config_path - - with open(config_path, "w") as f: - f.write("invalid_toml = [") - - settings = {"font": {"size": 12}} - - result = plugin.handle( - { - "requestId": "req-4", - "command": "apply", - "args": {"settings": settings}, - "context": mock_context, - } - ) - - assert result["success"] is True - assert result["changed"] is True - - backups = [f for f in os.listdir(temp_dir) if f.endswith(".bak")] - assert len(backups) == 1 - - with open(os.path.join(temp_dir, backups[0]), "r") as f: - assert f.read() == "invalid_toml = [" - - -def test_empty_stdin(): - with patch("sys.stdin.read", return_value=""): - with patch("sys.stdout.write") as mock_stdout: - plugin.main() - written = mock_stdout.call_args[0][0] - result = json.loads(written) - assert result["success"] is False - assert "Empty input" in result["error"] - - -def test_apply_config_invalid_args(): - result = plugin.handle( - { - "requestId": "req-invalid-args", - "command": "apply", - "args": "not-a-dict", - "context": {}, - } - ) - assert result["success"] is False - assert "args must be an object" in result["error"] - - -def test_apply_config_invalid_context(): - result = plugin.handle( - { - "requestId": "req-invalid-context", - "command": "apply", - "args": {}, - "context": "not-a-dict", - } - ) - assert result["success"] is False - assert "context must be an object" in result["error"] - - -def test_apply_config_invalid_settings(): - result = plugin.handle( - { - "requestId": "req-invalid-settings", - "command": "apply", - "args": {"settings": "not-a-dict"}, - "context": {}, - } - ) - assert result["success"] is False - assert "settings must be an object" in result["error"] - - -def test_unknown_command(): - result = plugin.handle( - { - "requestId": "req-unknown", - "command": "some_unknown_cmd", - "args": {}, - "context": {}, - } - ) - assert result["success"] is False - assert "Unknown command: some_unknown_cmd" in result["error"] +import json +import os +import sys +import tempfile +from unittest.mock import patch + +import pytest + +src_path = os.path.join(os.path.dirname(__file__), "..", "src") +sys.path.append(src_path) +try: + import plugin +finally: + sys.path.remove(src_path) + + +@pytest.fixture +def mock_context(): + return { + "os": "windows", + "arch": "x64", + "dryRun": False, + } + + +@patch("plugin.shutil.which") +def test_check_installed_true(mock_which): + mock_which.return_value = "/usr/local/bin/alacritty" + result = plugin.handle( + {"requestId": "req-1", "command": "check_installed", "args": {}} + ) + + assert result["success"] is True + assert result["data"] is True + + +@patch("plugin.shutil.which") +def test_check_installed_false(mock_which): + mock_which.return_value = None + result = plugin.handle( + {"requestId": "req-1", "command": "check_installed", "args": {}} + ) + + assert result["success"] is True + assert result["data"] is False + + +@patch("plugin.get_config_path") +def test_apply_config_creates_new(mock_get_config_path, mock_context): + with tempfile.TemporaryDirectory() as temp_dir: + config_path = os.path.join(temp_dir, "alacritty.toml") + mock_get_config_path.return_value = config_path + + settings = {"window": {"opacity": 0.95}, "font": {"size": 11.5}} + + result = plugin.handle( + { + "requestId": "req-2", + "command": "apply", + "args": {"settings": settings}, + "context": mock_context, + } + ) + + assert result["success"] is True + assert result["changed"] is True + assert os.path.exists(config_path) + + with open(config_path, "r") as f: + content = f.read() + assert "[window]" in content + assert "opacity = 0.95" in content + assert "[font]" in content + assert "size = 11.5" in content + + +@patch("plugin.get_config_path") +def test_apply_config_dry_run(mock_get_config_path): + with tempfile.TemporaryDirectory() as temp_dir: + config_path = os.path.join(temp_dir, "alacritty.toml") + mock_get_config_path.return_value = config_path + + settings = {"window": {"opacity": 1.0}} + + result = plugin.handle( + { + "requestId": "req-3", + "command": "apply", + "args": {"settings": settings}, + "context": {"dryRun": True}, + } + ) + + assert result["success"] is True + assert result["changed"] is True + assert not os.path.exists(config_path) + + +@patch("plugin.get_config_path") +def test_apply_config_invalid_toml_backup(mock_get_config_path, mock_context): + with tempfile.TemporaryDirectory() as temp_dir: + config_path = os.path.join(temp_dir, "alacritty.toml") + mock_get_config_path.return_value = config_path + + with open(config_path, "w") as f: + f.write("invalid_toml = [") + + settings = {"font": {"size": 12}} + + result = plugin.handle( + { + "requestId": "req-4", + "command": "apply", + "args": {"settings": settings}, + "context": mock_context, + } + ) + + assert result["success"] is True + assert result["changed"] is True + + backups = [f for f in os.listdir(temp_dir) if f.endswith(".bak")] + assert len(backups) == 1 + + with open(os.path.join(temp_dir, backups[0]), "r") as f: + assert f.read() == "invalid_toml = [" + + +def test_empty_stdin(): + with patch("sys.stdin.read", return_value=""): + with patch("sys.stdout.write") as mock_stdout: + plugin.main() + written = mock_stdout.call_args[0][0] + result = json.loads(written) + assert result["success"] is False + assert "Empty input" in result["error"] + + +def test_apply_config_invalid_args(): + result = plugin.handle( + { + "requestId": "req-invalid-args", + "command": "apply", + "args": "not-a-dict", + "context": {}, + } + ) + assert result["success"] is False + assert "args must be an object" in result["error"] + + +def test_apply_config_invalid_context(): + result = plugin.handle( + { + "requestId": "req-invalid-context", + "command": "apply", + "args": {}, + "context": "not-a-dict", + } + ) + assert result["success"] is False + assert "context must be an object" in result["error"] + + +def test_apply_config_invalid_settings(): + result = plugin.handle( + { + "requestId": "req-invalid-settings", + "command": "apply", + "args": {"settings": "not-a-dict"}, + "context": {}, + } + ) + assert result["success"] is False + assert "settings must be an object" in result["error"] + + +def test_unknown_command(): + result = plugin.handle( + { + "requestId": "req-unknown", + "command": "some_unknown_cmd", + "args": {}, + "context": {}, + } + ) + assert result["success"] is False + assert "Unknown command: some_unknown_cmd" in result["error"] diff --git a/plugins/audacity/src/plugin.py b/plugins/audacity/src/plugin.py index fc8bd5f8..79a78de9 100644 --- a/plugins/audacity/src/plugin.py +++ b/plugins/audacity/src/plugin.py @@ -119,7 +119,9 @@ def merge_settings( parser.set(section, key, str_value) changed = True else: - existing = parser.get(section, key) if parser.has_option(section, key) else None + existing = ( + parser.get(section, key) if parser.has_option(section, key) else None + ) if existing != str_value: parser.set(section, key, str_value) diff --git a/plugins/audacity/test/test_audacity.py b/plugins/audacity/test/test_audacity.py index 7dc3b0ee..7dcf9d32 100644 --- a/plugins/audacity/test/test_audacity.py +++ b/plugins/audacity/test/test_audacity.py @@ -5,7 +5,9 @@ import sys import tempfile -PLUGIN = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "src", "plugin.py")) +PLUGIN = os.path.abspath( + os.path.join(os.path.dirname(__file__), "..", "src", "plugin.py") +) # --------------------------------------------------------------------------- # Helpers diff --git a/plugins/autohotkey/src/plugin.py b/plugins/autohotkey/src/plugin.py index f3fc5f76..d7b965bb 100644 --- a/plugins/autohotkey/src/plugin.py +++ b/plugins/autohotkey/src/plugin.py @@ -1,337 +1,345 @@ -import json -import os -import re -import shutil -import sys -import tempfile - -HEADER_LINE = "#Requires AutoHotkey v2.0" -SETTINGS_START = "; WinHome managed settings start" -SETTINGS_END = "; WinHome managed settings end" -HOTKEYS_START = "; WinHome managed hotkeys start" -HOTKEYS_END = "; WinHome managed hotkeys end" -HOTSTRINGS_START = "; WinHome managed hotstrings start" -HOTSTRINGS_END = "; WinHome managed hotstrings end" - - -def log(msg): - sys.stderr.write(f"[autohotkey-plugin] {msg}\n") - sys.stderr.flush() - - -def normalize_newlines(text: str) -> str: - return text.replace("\r\n", "\n").replace("\r", "\n") - - -def read_text(file_path: str) -> str: - if not os.path.exists(file_path): - return "" - - with open(file_path, "r", encoding="utf-8") as handle: - return handle.read() - - -def write_text(file_path: str, content: str) -> None: - directory = os.path.dirname(file_path) - os.makedirs(directory, exist_ok=True) - - temp_file = None - try: - with tempfile.NamedTemporaryFile( - mode="w", - encoding="utf-8", - dir=directory, - delete=False, - newline="\n", - ) as handle: - temp_file = handle.name - handle.write(content) - - os.replace(temp_file, file_path) - except Exception: - if temp_file and os.path.exists(temp_file): - os.unlink(temp_file) - raise - - -def find_ahk_executable() -> bool: - if shutil.which("AutoHotkey64.exe") or shutil.which("AutoHotkey.exe"): - return True - - search_roots = [] - - program_files = os.getenv("PROGRAMFILES") - if program_files: - search_roots.append(os.path.join(program_files, "AutoHotkey", "v2")) - - program_files_x86 = os.getenv("PROGRAMFILES(X86)") - if program_files_x86: - search_roots.append(os.path.join(program_files_x86, "AutoHotkey", "v2")) - - local_app_data = os.getenv("LOCALAPPDATA") - if local_app_data: - search_roots.append(os.path.join(local_app_data, "Programs", "AutoHotkey")) - - executable_names = ["AutoHotkey64.exe", "AutoHotkey.exe"] - - for root in search_roots: - if not root: - continue - - for executable_name in executable_names: - candidate = os.path.join(root, executable_name) - if os.path.exists(candidate): - return True - - return False - - -def check_installed(args: dict, request_id: str) -> dict: - return { - "requestId": request_id, - "success": True, - "changed": False, - "data": find_ahk_executable(), - } - - -def build_settings_block(settings: dict) -> list[str]: - lines = [SETTINGS_START] - - if settings.get("persistent"): - lines.append("Persistent") - - detect_hidden_windows = settings.get("detect_hidden_windows") - if detect_hidden_windows: - lines.append(f'DetectHiddenWindows "{detect_hidden_windows}"') - - icon_tip = settings.get("icon_tip") - if icon_tip: - lines.append(f'TrayTip "{icon_tip}"') - - lines.append(SETTINGS_END) - return lines - - -def build_hotkey_block(trigger: str, action: str) -> list[str]: - return [ - HOTKEYS_START, - f"{trigger}::", - "{", - f" {action}", - "}", - HOTKEYS_END, - ] - - -def build_hotstring_lines(trigger: str, replacement: str) -> list[str]: - normalized_replacement = replacement.replace("\r\n", "\n").replace("\r", "\n") - - if "\n" not in normalized_replacement: - return [ - HOTSTRINGS_START, - f"{trigger}{normalized_replacement}", - HOTSTRINGS_END, - ] - - replacement_lines = normalized_replacement.split("\n") - return [ - HOTSTRINGS_START, - f"{trigger}::", - "(", - *replacement_lines, - ")", - HOTSTRINGS_END, - ] - - -def build_managed_content(args: dict) -> str: - settings = args.get("settings", {}) - hotkeys = args.get("hotkeys", {}) - hotstrings = args.get("hotstrings", {}) - - sections: list[str] = [HEADER_LINE] - - if settings: - sections.extend([""] + build_settings_block(settings)) - - if hotkeys: - for trigger, action in hotkeys.items(): - sections.extend([""] + build_hotkey_block(trigger, action)) - - if hotstrings: - for trigger, replacement in hotstrings.items(): - sections.extend([""] + build_hotstring_lines(trigger, replacement)) - - return "\n".join(sections).rstrip() + "\n" - - -def strip_managed_content(existing_text: str) -> str: - if not existing_text: - return "" - - lines = normalize_newlines(existing_text).split("\n") - kept_lines: list[str] = [] - i = 0 - - while i < len(lines): - line = lines[i] - stripped = line.strip() - - if stripped == HEADER_LINE: - i += 1 - continue - - if stripped in { - SETTINGS_START, - SETTINGS_END, - HOTKEYS_START, - HOTKEYS_END, - HOTSTRINGS_START, - HOTSTRINGS_END, - }: - i += 1 - continue - - if stripped == "Persistent": - i += 1 - continue - - if re.fullmatch(r'DetectHiddenWindows\s+".*"', stripped): - i += 1 - continue - - if re.fullmatch(r'TrayTip\s+".*"', stripped): - i += 1 - continue - - if stripped.endswith("::") and i + 1 < len(lines) and lines[i + 1].strip() == "{": - i += 2 - depth = 1 - while i < len(lines) and depth > 0: - current = lines[i].strip() - if current == "{": - depth += 1 - elif current == "}": - depth -= 1 - i += 1 - continue - - if stripped.endswith("::") and i + 1 < len(lines) and lines[i + 1].strip() == "(": - i += 2 - while i < len(lines) and lines[i].strip() != ")": - i += 1 - if i < len(lines): - i += 1 - continue - - if stripped.startswith("::") and "::" in stripped[2:]: - i += 1 - continue - - kept_lines.append(line) - i += 1 - - while kept_lines and kept_lines[0] == "": - kept_lines.pop(0) - - while kept_lines and kept_lines[-1] == "": - kept_lines.pop() - - if not kept_lines: - return "" - - return "\n".join(kept_lines) + "\n" - - -def apply_config(args: dict, context: dict, request_id: str) -> dict: - dry_run = bool(context.get("dryRun", False)) - script_path = os.path.expandvars(args.get("script_path", "")) - - if not script_path: - raise Exception("script_path is required") - - existing_text = read_text(script_path) - custom_text = strip_managed_content(existing_text) - managed_text = build_managed_content(args) - merged_text = managed_text + ("\n" + custom_text if custom_text else "") - merged_text = merged_text.rstrip() + "\n" - - if merged_text == existing_text: - return { - "requestId": request_id, - "success": True, - "changed": False, - } - - if dry_run: - log(f"Would update script: {script_path}") - return { - "requestId": request_id, - "success": True, - "changed": True, - } - - write_text(script_path, merged_text) - - return { - "requestId": request_id, - "success": True, - "changed": True, - } - - -def process_request(request: dict) -> dict: - request_id = request.get("requestId", "unknown") - command = request.get("command") - args = request.get("args", {}) - context = request.get("context", {}) - - response = { - "requestId": request_id, - "success": False, - "changed": False, - } - - try: - if command == "check_installed": - return check_installed(args, request_id) - - if command == "apply": - return apply_config(args, context, request_id) - - response["error"] = f"Unknown command: {command}" - except Exception as fatal_err: - response["error"] = f"Internal Script Error: {str(fatal_err)}" - - return response - - -def main(): - input_data = sys.stdin.read() - - if not input_data: - return - - try: - request = json.loads(input_data) - except Exception as e: - log(f"Failed to parse request: {e}") - response = { - "requestId": "unknown", - "success": False, - "changed": False, - "error": f"Invalid JSON: {e}", - } - sys.stdout.write(json.dumps(response) + "\n") - sys.stdout.flush() - return - - response = process_request(request) - - sys.stdout.write(json.dumps(response) + "\n") - sys.stdout.flush() - - -if __name__ == "__main__": - main() +import json +import os +import re +import shutil +import sys +import tempfile + +HEADER_LINE = "#Requires AutoHotkey v2.0" +SETTINGS_START = "; WinHome managed settings start" +SETTINGS_END = "; WinHome managed settings end" +HOTKEYS_START = "; WinHome managed hotkeys start" +HOTKEYS_END = "; WinHome managed hotkeys end" +HOTSTRINGS_START = "; WinHome managed hotstrings start" +HOTSTRINGS_END = "; WinHome managed hotstrings end" + + +def log(msg): + sys.stderr.write(f"[autohotkey-plugin] {msg}\n") + sys.stderr.flush() + + +def normalize_newlines(text: str) -> str: + return text.replace("\r\n", "\n").replace("\r", "\n") + + +def read_text(file_path: str) -> str: + if not os.path.exists(file_path): + return "" + + with open(file_path, "r", encoding="utf-8") as handle: + return handle.read() + + +def write_text(file_path: str, content: str) -> None: + directory = os.path.dirname(file_path) + os.makedirs(directory, exist_ok=True) + + temp_file = None + try: + with tempfile.NamedTemporaryFile( + mode="w", + encoding="utf-8", + dir=directory, + delete=False, + newline="\n", + ) as handle: + temp_file = handle.name + handle.write(content) + + os.replace(temp_file, file_path) + except Exception: + if temp_file and os.path.exists(temp_file): + os.unlink(temp_file) + raise + + +def find_ahk_executable() -> bool: + if shutil.which("AutoHotkey64.exe") or shutil.which("AutoHotkey.exe"): + return True + + search_roots = [] + + program_files = os.getenv("PROGRAMFILES") + if program_files: + search_roots.append(os.path.join(program_files, "AutoHotkey", "v2")) + + program_files_x86 = os.getenv("PROGRAMFILES(X86)") + if program_files_x86: + search_roots.append(os.path.join(program_files_x86, "AutoHotkey", "v2")) + + local_app_data = os.getenv("LOCALAPPDATA") + if local_app_data: + search_roots.append(os.path.join(local_app_data, "Programs", "AutoHotkey")) + + executable_names = ["AutoHotkey64.exe", "AutoHotkey.exe"] + + for root in search_roots: + if not root: + continue + + for executable_name in executable_names: + candidate = os.path.join(root, executable_name) + if os.path.exists(candidate): + return True + + return False + + +def check_installed(args: dict, request_id: str) -> dict: + return { + "requestId": request_id, + "success": True, + "changed": False, + "data": find_ahk_executable(), + } + + +def build_settings_block(settings: dict) -> list[str]: + lines = [SETTINGS_START] + + if settings.get("persistent"): + lines.append("Persistent") + + detect_hidden_windows = settings.get("detect_hidden_windows") + if detect_hidden_windows: + lines.append(f'DetectHiddenWindows "{detect_hidden_windows}"') + + icon_tip = settings.get("icon_tip") + if icon_tip: + lines.append(f'TrayTip "{icon_tip}"') + + lines.append(SETTINGS_END) + return lines + + +def build_hotkey_block(trigger: str, action: str) -> list[str]: + return [ + HOTKEYS_START, + f"{trigger}::", + "{", + f" {action}", + "}", + HOTKEYS_END, + ] + + +def build_hotstring_lines(trigger: str, replacement: str) -> list[str]: + normalized_replacement = replacement.replace("\r\n", "\n").replace("\r", "\n") + + if "\n" not in normalized_replacement: + return [ + HOTSTRINGS_START, + f"{trigger}{normalized_replacement}", + HOTSTRINGS_END, + ] + + replacement_lines = normalized_replacement.split("\n") + return [ + HOTSTRINGS_START, + f"{trigger}::", + "(", + *replacement_lines, + ")", + HOTSTRINGS_END, + ] + + +def build_managed_content(args: dict) -> str: + settings = args.get("settings", {}) + hotkeys = args.get("hotkeys", {}) + hotstrings = args.get("hotstrings", {}) + + sections: list[str] = [HEADER_LINE] + + if settings: + sections.extend([""] + build_settings_block(settings)) + + if hotkeys: + for trigger, action in hotkeys.items(): + sections.extend([""] + build_hotkey_block(trigger, action)) + + if hotstrings: + for trigger, replacement in hotstrings.items(): + sections.extend([""] + build_hotstring_lines(trigger, replacement)) + + return "\n".join(sections).rstrip() + "\n" + + +def strip_managed_content(existing_text: str) -> str: + if not existing_text: + return "" + + lines = normalize_newlines(existing_text).split("\n") + kept_lines: list[str] = [] + i = 0 + + while i < len(lines): + line = lines[i] + stripped = line.strip() + + if stripped == HEADER_LINE: + i += 1 + continue + + if stripped in { + SETTINGS_START, + SETTINGS_END, + HOTKEYS_START, + HOTKEYS_END, + HOTSTRINGS_START, + HOTSTRINGS_END, + }: + i += 1 + continue + + if stripped == "Persistent": + i += 1 + continue + + if re.fullmatch(r'DetectHiddenWindows\s+".*"', stripped): + i += 1 + continue + + if re.fullmatch(r'TrayTip\s+".*"', stripped): + i += 1 + continue + + if ( + stripped.endswith("::") + and i + 1 < len(lines) + and lines[i + 1].strip() == "{" + ): + i += 2 + depth = 1 + while i < len(lines) and depth > 0: + current = lines[i].strip() + if current == "{": + depth += 1 + elif current == "}": + depth -= 1 + i += 1 + continue + + if ( + stripped.endswith("::") + and i + 1 < len(lines) + and lines[i + 1].strip() == "(" + ): + i += 2 + while i < len(lines) and lines[i].strip() != ")": + i += 1 + if i < len(lines): + i += 1 + continue + + if stripped.startswith("::") and "::" in stripped[2:]: + i += 1 + continue + + kept_lines.append(line) + i += 1 + + while kept_lines and kept_lines[0] == "": + kept_lines.pop(0) + + while kept_lines and kept_lines[-1] == "": + kept_lines.pop() + + if not kept_lines: + return "" + + return "\n".join(kept_lines) + "\n" + + +def apply_config(args: dict, context: dict, request_id: str) -> dict: + dry_run = bool(context.get("dryRun", False)) + script_path = os.path.expandvars(args.get("script_path", "")) + + if not script_path: + raise Exception("script_path is required") + + existing_text = read_text(script_path) + custom_text = strip_managed_content(existing_text) + managed_text = build_managed_content(args) + merged_text = managed_text + ("\n" + custom_text if custom_text else "") + merged_text = merged_text.rstrip() + "\n" + + if merged_text == existing_text: + return { + "requestId": request_id, + "success": True, + "changed": False, + } + + if dry_run: + log(f"Would update script: {script_path}") + return { + "requestId": request_id, + "success": True, + "changed": True, + } + + write_text(script_path, merged_text) + + return { + "requestId": request_id, + "success": True, + "changed": True, + } + + +def process_request(request: dict) -> dict: + request_id = request.get("requestId", "unknown") + command = request.get("command") + args = request.get("args", {}) + context = request.get("context", {}) + + response = { + "requestId": request_id, + "success": False, + "changed": False, + } + + try: + if command == "check_installed": + return check_installed(args, request_id) + + if command == "apply": + return apply_config(args, context, request_id) + + response["error"] = f"Unknown command: {command}" + except Exception as fatal_err: + response["error"] = f"Internal Script Error: {str(fatal_err)}" + + return response + + +def main(): + input_data = sys.stdin.read() + + if not input_data: + return + + try: + request = json.loads(input_data) + except Exception as e: + log(f"Failed to parse request: {e}") + response = { + "requestId": "unknown", + "success": False, + "changed": False, + "error": f"Invalid JSON: {e}", + } + sys.stdout.write(json.dumps(response) + "\n") + sys.stdout.flush() + return + + response = process_request(request) + + sys.stdout.write(json.dumps(response) + "\n") + sys.stdout.flush() + + +if __name__ == "__main__": + main() diff --git a/plugins/autohotkey/test/test_autohotkey.py b/plugins/autohotkey/test/test_autohotkey.py index 1b373deb..d593306e 100644 --- a/plugins/autohotkey/test/test_autohotkey.py +++ b/plugins/autohotkey/test/test_autohotkey.py @@ -34,7 +34,9 @@ def test_check_installed_reports_true_when_executable_is_found(monkeypatch): plugin = load_plugin_module() monkeypatch.setattr(plugin.shutil, "which", lambda name: None) - monkeypatch.setattr(plugin.os.path, "exists", lambda path: path.endswith("AutoHotkey64.exe")) + monkeypatch.setattr( + plugin.os.path, "exists", lambda path: path.endswith("AutoHotkey64.exe") + ) monkeypatch.setenv("PROGRAMFILES", r"C:\Program Files") monkeypatch.setenv("PROGRAMFILES(X86)", r"C:\Program Files (x86)") monkeypatch.setenv("LOCALAPPDATA", r"C:\Users\Test\AppData\Local") diff --git a/plugins/bat/src/plugin.py b/plugins/bat/src/plugin.py index 12e6d2e0..c6d10774 100644 --- a/plugins/bat/src/plugin.py +++ b/plugins/bat/src/plugin.py @@ -39,7 +39,9 @@ def get_config_path() -> Path: system = platform.system() if system == "Windows": - appdata = (os.environ.get("APPDATA") or os.path.join(os.path.expanduser("~"), "AppData", "Roaming")) + appdata = os.environ.get("APPDATA") or os.path.join( + os.path.expanduser("~"), "AppData", "Roaming" + ) if not appdata: # Keep behavior explicit; host will return structured error. raise RuntimeError("APPDATA environment variable not set") @@ -153,7 +155,9 @@ def build_setting_line(key: str, value: Any) -> str: return f"{key}={s}" -def merge_settings(lines: List[ConfigLine], settings: Dict[str, Any]) -> Tuple[List[ConfigLine], bool]: +def merge_settings( + lines: List[ConfigLine], settings: Dict[str, Any] +) -> Tuple[List[ConfigLine], bool]: # Normalize incoming keys to ensure they start with -- normalized: Dict[str, Any] = {} for k, v in settings.items(): @@ -195,7 +199,14 @@ def merge_settings(lines: List[ConfigLine], settings: Dict[str, Any]) -> Tuple[L # Ensure we add a newline if file doesn't end with one. insertion = new_line if not lines: - lines = [ConfigLine(raw=insertion + "\n", key=key, value=stringify_value(value), managed=True)] + lines = [ + ConfigLine( + raw=insertion + "\n", + key=key, + value=stringify_value(value), + managed=True, + ) + ] changed = True continue last_raw = lines[-1].raw @@ -324,7 +335,12 @@ def handle_set(request: dict) -> Dict[str, Any]: data["backupPath"] = str(backup_path) write_atomic(config_path, proposed) - return make_response(request_id, True, True, data | ({"backupPath": str(backup_path)} if backup_path else {})) + return make_response( + request_id, + True, + True, + data | ({"backupPath": str(backup_path)} if backup_path else {}), + ) except Exception as exc: return make_response(request_id, False, False, {}, str(exc)) @@ -343,7 +359,9 @@ def dispatch(request: dict) -> Any: if command == "set": return handle_set(request) - return make_response(request.get("requestId"), False, False, {}, f"Unknown command: {command}") + return make_response( + request.get("requestId"), False, False, {}, f"Unknown command: {command}" + ) def main() -> None: @@ -368,7 +386,9 @@ def main() -> None: try: request = json.loads(raw) except Exception as exc: - result = make_response(None, False, False, {}, f"Failed to parse request: {exc}") + result = make_response( + None, False, False, {}, f"Failed to parse request: {exc}" + ) sys.stdout.write(json.dumps(result) + "\n") sys.stdout.flush() return @@ -379,7 +399,11 @@ def main() -> None: result = dispatch(request) except Exception as exc: result = make_response( - request.get("requestId", "unknown") if isinstance(request, dict) else "unknown", + ( + request.get("requestId", "unknown") + if isinstance(request, dict) + else "unknown" + ), False, False, {}, diff --git a/plugins/betterdiscord/src/plugin.py b/plugins/betterdiscord/src/plugin.py index 3989eb0a..debb7736 100644 --- a/plugins/betterdiscord/src/plugin.py +++ b/plugins/betterdiscord/src/plugin.py @@ -1,198 +1,202 @@ -import json -import os -import sys -import tempfile - - -def get_settings_path(): - appdata = os.environ.get("APPDATA", "") - return os.path.join(appdata, "BetterDiscord", "data", "settings.json") - - -def log(msg): - sys.stderr.write(f"[betterdiscord-plugin] {msg}\n") - sys.stderr.flush() - - -def read_json(file_path: str) -> dict: - if not file_path or not os.path.exists(file_path): - return {} - - try: - with open(file_path, "r", encoding="utf-8") as f: - return json.load(f) - except Exception as e: - log(f"Failed to read json: {e}") - - try: - import shutil - import uuid - - backup_path = f"{file_path}.{uuid.uuid4()}.bak" - shutil.copy(file_path, backup_path) - - log(f"Corrupted file backed up to: {backup_path}") - - except Exception as backup_error: - log(f"Backup failed: {backup_error}") - - return {} - - -def deep_merge(original: dict, updates: dict) -> dict: - for key, value in updates.items(): - if key in original and isinstance(original[key], dict) and isinstance(value, dict): - deep_merge(original[key], value) - else: - original[key] = value - - return original - - -def write_json_atomic(file_path: str, data: dict): - os.makedirs(os.path.dirname(file_path), exist_ok=True) - - fd, temp_path = tempfile.mkstemp() - - try: - with os.fdopen(fd, "w", encoding="utf-8") as tmp: - json.dump(data, tmp, indent=2) - tmp.write("\n") - - os.replace(temp_path, file_path) - - except Exception: - if os.path.exists(temp_path): - os.remove(temp_path) - raise - - -def check_installed(args: dict, request_id: str) -> dict: - appdata = os.environ.get("APPDATA", "") - install_path = os.path.join(appdata, "BetterDiscord") - - return { - "requestId": request_id, - "success": True, - "changed": False, - "data": os.path.exists(install_path), - } - - -def apply_config(args: dict, context: dict, request_id: str) -> dict: - settings = args.get("settings", {}) - - if not isinstance(settings, dict): - return { - "requestId": request_id, - "success": False, - "changed": False, - "data": None, - "error": "settings must be an object", - } - - dry_run = context.get("dryRun", False) - - settings_path = get_settings_path() - current = read_json(settings_path) - - updated = json.loads(json.dumps(current)) - deep_merge(updated, settings) - - changed = current != updated - - if not changed: - return { - "requestId": request_id, - "success": True, - "changed": False, - "data": None, - } - - if dry_run: - log("Dry run: settings would be updated") - - return { - "requestId": request_id, - "success": True, - "changed": True, - "data": None, - } - - try: - write_json_atomic(settings_path, updated) - - return { - "requestId": request_id, - "success": True, - "changed": True, - "data": None, - } - - except Exception as e: - return { - "requestId": request_id, - "success": False, - "changed": False, - "data": None, - "error": str(e), - } - - -def main(): - input_data = sys.stdin.read() - - if not input_data: - print( - json.dumps( - { - "requestId": "unknown", - "success": False, - "changed": False, - "data": None, - "error": "No input received on stdin", - } - ) - ) - return - - try: - request = json.loads(input_data) - except Exception as e: - print( - json.dumps( - { - "requestId": "unknown", - "success": False, - "changed": False, - "data": None, - "error": f"Failed to parse request: {e}", - } - ) - ) - return - - command = request.get("command") - args = request.get("args", {}) - context = request.get("context", {}) - request_id = request.get("requestId", "unknown") - - if command == "check_installed": - response = check_installed(args, request_id) - elif command == "apply": - response = apply_config(args, context, request_id) - else: - response = { - "requestId": request_id, - "success": False, - "changed": False, - "data": None, - "error": f"Unknown command: {command}", - } - - print(json.dumps(response)) - sys.stdout.flush() - - -if __name__ == "__main__": - main() +import json +import os +import sys +import tempfile + + +def get_settings_path(): + appdata = os.environ.get("APPDATA", "") + return os.path.join(appdata, "BetterDiscord", "data", "settings.json") + + +def log(msg): + sys.stderr.write(f"[betterdiscord-plugin] {msg}\n") + sys.stderr.flush() + + +def read_json(file_path: str) -> dict: + if not file_path or not os.path.exists(file_path): + return {} + + try: + with open(file_path, "r", encoding="utf-8") as f: + return json.load(f) + except Exception as e: + log(f"Failed to read json: {e}") + + try: + import shutil + import uuid + + backup_path = f"{file_path}.{uuid.uuid4()}.bak" + shutil.copy(file_path, backup_path) + + log(f"Corrupted file backed up to: {backup_path}") + + except Exception as backup_error: + log(f"Backup failed: {backup_error}") + + return {} + + +def deep_merge(original: dict, updates: dict) -> dict: + for key, value in updates.items(): + if ( + key in original + and isinstance(original[key], dict) + and isinstance(value, dict) + ): + deep_merge(original[key], value) + else: + original[key] = value + + return original + + +def write_json_atomic(file_path: str, data: dict): + os.makedirs(os.path.dirname(file_path), exist_ok=True) + + fd, temp_path = tempfile.mkstemp() + + try: + with os.fdopen(fd, "w", encoding="utf-8") as tmp: + json.dump(data, tmp, indent=2) + tmp.write("\n") + + os.replace(temp_path, file_path) + + except Exception: + if os.path.exists(temp_path): + os.remove(temp_path) + raise + + +def check_installed(args: dict, request_id: str) -> dict: + appdata = os.environ.get("APPDATA", "") + install_path = os.path.join(appdata, "BetterDiscord") + + return { + "requestId": request_id, + "success": True, + "changed": False, + "data": os.path.exists(install_path), + } + + +def apply_config(args: dict, context: dict, request_id: str) -> dict: + settings = args.get("settings", {}) + + if not isinstance(settings, dict): + return { + "requestId": request_id, + "success": False, + "changed": False, + "data": None, + "error": "settings must be an object", + } + + dry_run = context.get("dryRun", False) + + settings_path = get_settings_path() + current = read_json(settings_path) + + updated = json.loads(json.dumps(current)) + deep_merge(updated, settings) + + changed = current != updated + + if not changed: + return { + "requestId": request_id, + "success": True, + "changed": False, + "data": None, + } + + if dry_run: + log("Dry run: settings would be updated") + + return { + "requestId": request_id, + "success": True, + "changed": True, + "data": None, + } + + try: + write_json_atomic(settings_path, updated) + + return { + "requestId": request_id, + "success": True, + "changed": True, + "data": None, + } + + except Exception as e: + return { + "requestId": request_id, + "success": False, + "changed": False, + "data": None, + "error": str(e), + } + + +def main(): + input_data = sys.stdin.read() + + if not input_data: + print( + json.dumps( + { + "requestId": "unknown", + "success": False, + "changed": False, + "data": None, + "error": "No input received on stdin", + } + ) + ) + return + + try: + request = json.loads(input_data) + except Exception as e: + print( + json.dumps( + { + "requestId": "unknown", + "success": False, + "changed": False, + "data": None, + "error": f"Failed to parse request: {e}", + } + ) + ) + return + + command = request.get("command") + args = request.get("args", {}) + context = request.get("context", {}) + request_id = request.get("requestId", "unknown") + + if command == "check_installed": + response = check_installed(args, request_id) + elif command == "apply": + response = apply_config(args, context, request_id) + else: + response = { + "requestId": request_id, + "success": False, + "changed": False, + "data": None, + "error": f"Unknown command: {command}", + } + + print(json.dumps(response)) + sys.stdout.flush() + + +if __name__ == "__main__": + main() diff --git a/plugins/betterdiscord/test/test_betterdiscord.py b/plugins/betterdiscord/test/test_betterdiscord.py index b62f91f8..08ec7090 100644 --- a/plugins/betterdiscord/test/test_betterdiscord.py +++ b/plugins/betterdiscord/test/test_betterdiscord.py @@ -4,7 +4,9 @@ import sys import tempfile -PLUGIN = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "src", "plugin.py")) +PLUGIN = os.path.abspath( + os.path.join(os.path.dirname(__file__), "..", "src", "plugin.py") +) TMP_DIR = tempfile.mkdtemp() diff --git a/plugins/cargo/src/plugin.py b/plugins/cargo/src/plugin.py index b64ea46a..eb4d0c8a 100644 --- a/plugins/cargo/src/plugin.py +++ b/plugins/cargo/src/plugin.py @@ -90,7 +90,10 @@ def check_installed(args: dict, request_id: str) -> dict: installed = ( shutil.which("cargo.exe") is not None or shutil.which("cargo") is not None - or (cargo_home != "" and os.path.exists(os.path.join(cargo_home, "bin", "cargo.exe"))) + or ( + cargo_home != "" + and os.path.exists(os.path.join(cargo_home, "bin", "cargo.exe")) + ) ) return { "requestId": request_id, @@ -176,9 +179,11 @@ def main() -> None: result = handle(request) except Exception as error: result = { - "requestId": request.get("requestId", "unknown") - if "request" in locals() and isinstance(request, dict) - else "unknown", + "requestId": ( + request.get("requestId", "unknown") + if "request" in locals() and isinstance(request, dict) + else "unknown" + ), "success": False, "changed": False, "error": str(error), diff --git a/plugins/cargo/test/test_cargo.py b/plugins/cargo/test/test_cargo.py index 797e5d77..b63f04aa 100644 --- a/plugins/cargo/test/test_cargo.py +++ b/plugins/cargo/test/test_cargo.py @@ -1,161 +1,165 @@ -import os -import sys -import tempfile -import unittest -from unittest.mock import patch - -sys.path.append(os.path.join(os.path.dirname(__file__), "..", "src")) -import plugin - - -class TestCheckInstalled(unittest.TestCase): - def test_cargo_in_path(self): - with patch( - "shutil.which", - side_effect=lambda x: "/usr/bin/cargo" if x == "cargo" else None, - ): - result = plugin.check_installed({}, "req-1") - self.assertTrue(result["success"]) - self.assertTrue(result["data"]) - - def test_cargo_not_found(self): - with patch("shutil.which", return_value=None): - with patch.dict(os.environ, {"CARGO_HOME": ""}, clear=False): - result = plugin.check_installed({}, "req-2") - self.assertTrue(result["success"]) - self.assertFalse(result["data"]) - - def test_cargo_via_cargo_home(self): - with tempfile.TemporaryDirectory() as tmpdir: - bin_dir = os.path.join(tmpdir, "bin") - os.makedirs(bin_dir) - cargo_exe = os.path.join(bin_dir, "cargo.exe") - open(cargo_exe, "w").close() - with patch("shutil.which", return_value=None): - with patch.dict(os.environ, {"CARGO_HOME": tmpdir}): - result = plugin.check_installed({}, "req-3") - self.assertTrue(result["success"]) - self.assertTrue(result["data"]) - - -class TestTomlValue(unittest.TestCase): - def test_bool_true(self): - self.assertEqual(plugin.toml_value(True), "true") - - def test_bool_false(self): - self.assertEqual(plugin.toml_value(False), "false") - - def test_int(self): - self.assertEqual(plugin.toml_value(8), "8") - - def test_string(self): - self.assertEqual(plugin.toml_value("always"), '"always"') - - def test_list(self): - self.assertEqual(plugin.toml_value(["a", "b"]), '["a", "b"]') - - -class TestMergeSettings(unittest.TestCase): - def test_simple_merge(self): - target = {"http": {"timeout": 10}} - source = {"http": {"timeout": 30, "proxy": "http://proxy:8080"}} - changed = plugin.merge_settings(target, source) - self.assertTrue(changed) - self.assertEqual(target["http"]["timeout"], 30) - self.assertEqual(target["http"]["proxy"], "http://proxy:8080") - - def test_no_change(self): - target = {"build": {"jobs": 8}} - source = {"build": {"jobs": 8}} - changed = plugin.merge_settings(target, source) - self.assertFalse(changed) - - def test_preserves_unmentioned_keys(self): - target = {"net": {"retry": 3, "git-fetch-with-cli": True}} - source = {"net": {"retry": 5}} - plugin.merge_settings(target, source) - self.assertEqual(target["net"]["git-fetch-with-cli"], True) - self.assertEqual(target["net"]["retry"], 5) - - def test_deep_merge(self): - target = {"registry": {"default": "crates-io"}} - source = {"registry": {"default": "mirror"}} - changed = plugin.merge_settings(target, source) - self.assertTrue(changed) - self.assertEqual(target["registry"]["default"], "mirror") - - -class TestApplyConfig(unittest.TestCase): - def test_creates_file_if_not_exists(self): - with tempfile.TemporaryDirectory() as tmpdir: - config_path = os.path.join(tmpdir, ".cargo", "config.toml") - with patch("plugin.get_config_path", return_value=config_path): - result = plugin.apply_config({"settings": {"build": {"jobs": 4}}}, {}, "req-10") - self.assertTrue(result["success"]) - self.assertTrue(result["changed"]) - self.assertTrue(os.path.exists(config_path)) - - def test_no_change_when_same(self): - with tempfile.TemporaryDirectory() as tmpdir: - config_path = os.path.join(tmpdir, ".cargo", "config.toml") - os.makedirs(os.path.dirname(config_path)) - with open(config_path, "w") as f: - f.write("[build]\njobs = 4\n") - with patch("plugin.get_config_path", return_value=config_path): - result = plugin.apply_config({"settings": {"build": {"jobs": 4}}}, {}, "req-11") - self.assertTrue(result["success"]) - self.assertFalse(result["changed"]) - - def test_dry_run_does_not_write(self): - with tempfile.TemporaryDirectory() as tmpdir: - config_path = os.path.join(tmpdir, ".cargo", "config.toml") - with patch("plugin.get_config_path", return_value=config_path): - result = plugin.apply_config( - {"settings": {"term": {"color": "always"}}}, - {"dryRun": True}, - "req-12", - ) - self.assertTrue(result["success"]) - self.assertTrue(result["changed"]) - self.assertFalse(os.path.exists(config_path)) - - def test_idempotent(self): - with tempfile.TemporaryDirectory() as tmpdir: - config_path = os.path.join(tmpdir, ".cargo", "config.toml") - settings = {"settings": {"net": {"retry": 5}}} - with patch("plugin.get_config_path", return_value=config_path): - plugin.apply_config(settings, {}, "req-13") - result2 = plugin.apply_config(settings, {}, "req-14") - self.assertTrue(result2["success"]) - self.assertFalse(result2["changed"]) - - -class TestProtocol(unittest.TestCase): - def test_unknown_command(self): - result = plugin.handle( - { - "requestId": "req-20", - "command": "unknown", - "args": {}, - "context": {}, - } - ) - self.assertFalse(result["success"]) - self.assertIn("Unknown command", result["error"]) - - def test_check_installed_via_handle(self): - with patch("shutil.which", return_value="/usr/bin/cargo"): - result = plugin.handle( - { - "requestId": "req-21", - "command": "check_installed", - "args": {}, - "context": {}, - } - ) - self.assertTrue(result["success"]) - self.assertEqual(result["requestId"], "req-21") - - -if __name__ == "__main__": - unittest.main() +import os +import sys +import tempfile +import unittest +from unittest.mock import patch + +sys.path.append(os.path.join(os.path.dirname(__file__), "..", "src")) +import plugin + + +class TestCheckInstalled(unittest.TestCase): + def test_cargo_in_path(self): + with patch( + "shutil.which", + side_effect=lambda x: "/usr/bin/cargo" if x == "cargo" else None, + ): + result = plugin.check_installed({}, "req-1") + self.assertTrue(result["success"]) + self.assertTrue(result["data"]) + + def test_cargo_not_found(self): + with patch("shutil.which", return_value=None): + with patch.dict(os.environ, {"CARGO_HOME": ""}, clear=False): + result = plugin.check_installed({}, "req-2") + self.assertTrue(result["success"]) + self.assertFalse(result["data"]) + + def test_cargo_via_cargo_home(self): + with tempfile.TemporaryDirectory() as tmpdir: + bin_dir = os.path.join(tmpdir, "bin") + os.makedirs(bin_dir) + cargo_exe = os.path.join(bin_dir, "cargo.exe") + open(cargo_exe, "w").close() + with patch("shutil.which", return_value=None): + with patch.dict(os.environ, {"CARGO_HOME": tmpdir}): + result = plugin.check_installed({}, "req-3") + self.assertTrue(result["success"]) + self.assertTrue(result["data"]) + + +class TestTomlValue(unittest.TestCase): + def test_bool_true(self): + self.assertEqual(plugin.toml_value(True), "true") + + def test_bool_false(self): + self.assertEqual(plugin.toml_value(False), "false") + + def test_int(self): + self.assertEqual(plugin.toml_value(8), "8") + + def test_string(self): + self.assertEqual(plugin.toml_value("always"), '"always"') + + def test_list(self): + self.assertEqual(plugin.toml_value(["a", "b"]), '["a", "b"]') + + +class TestMergeSettings(unittest.TestCase): + def test_simple_merge(self): + target = {"http": {"timeout": 10}} + source = {"http": {"timeout": 30, "proxy": "http://proxy:8080"}} + changed = plugin.merge_settings(target, source) + self.assertTrue(changed) + self.assertEqual(target["http"]["timeout"], 30) + self.assertEqual(target["http"]["proxy"], "http://proxy:8080") + + def test_no_change(self): + target = {"build": {"jobs": 8}} + source = {"build": {"jobs": 8}} + changed = plugin.merge_settings(target, source) + self.assertFalse(changed) + + def test_preserves_unmentioned_keys(self): + target = {"net": {"retry": 3, "git-fetch-with-cli": True}} + source = {"net": {"retry": 5}} + plugin.merge_settings(target, source) + self.assertEqual(target["net"]["git-fetch-with-cli"], True) + self.assertEqual(target["net"]["retry"], 5) + + def test_deep_merge(self): + target = {"registry": {"default": "crates-io"}} + source = {"registry": {"default": "mirror"}} + changed = plugin.merge_settings(target, source) + self.assertTrue(changed) + self.assertEqual(target["registry"]["default"], "mirror") + + +class TestApplyConfig(unittest.TestCase): + def test_creates_file_if_not_exists(self): + with tempfile.TemporaryDirectory() as tmpdir: + config_path = os.path.join(tmpdir, ".cargo", "config.toml") + with patch("plugin.get_config_path", return_value=config_path): + result = plugin.apply_config( + {"settings": {"build": {"jobs": 4}}}, {}, "req-10" + ) + self.assertTrue(result["success"]) + self.assertTrue(result["changed"]) + self.assertTrue(os.path.exists(config_path)) + + def test_no_change_when_same(self): + with tempfile.TemporaryDirectory() as tmpdir: + config_path = os.path.join(tmpdir, ".cargo", "config.toml") + os.makedirs(os.path.dirname(config_path)) + with open(config_path, "w") as f: + f.write("[build]\njobs = 4\n") + with patch("plugin.get_config_path", return_value=config_path): + result = plugin.apply_config( + {"settings": {"build": {"jobs": 4}}}, {}, "req-11" + ) + self.assertTrue(result["success"]) + self.assertFalse(result["changed"]) + + def test_dry_run_does_not_write(self): + with tempfile.TemporaryDirectory() as tmpdir: + config_path = os.path.join(tmpdir, ".cargo", "config.toml") + with patch("plugin.get_config_path", return_value=config_path): + result = plugin.apply_config( + {"settings": {"term": {"color": "always"}}}, + {"dryRun": True}, + "req-12", + ) + self.assertTrue(result["success"]) + self.assertTrue(result["changed"]) + self.assertFalse(os.path.exists(config_path)) + + def test_idempotent(self): + with tempfile.TemporaryDirectory() as tmpdir: + config_path = os.path.join(tmpdir, ".cargo", "config.toml") + settings = {"settings": {"net": {"retry": 5}}} + with patch("plugin.get_config_path", return_value=config_path): + plugin.apply_config(settings, {}, "req-13") + result2 = plugin.apply_config(settings, {}, "req-14") + self.assertTrue(result2["success"]) + self.assertFalse(result2["changed"]) + + +class TestProtocol(unittest.TestCase): + def test_unknown_command(self): + result = plugin.handle( + { + "requestId": "req-20", + "command": "unknown", + "args": {}, + "context": {}, + } + ) + self.assertFalse(result["success"]) + self.assertIn("Unknown command", result["error"]) + + def test_check_installed_via_handle(self): + with patch("shutil.which", return_value="/usr/bin/cargo"): + result = plugin.handle( + { + "requestId": "req-21", + "command": "check_installed", + "args": {}, + "context": {}, + } + ) + self.assertTrue(result["success"]) + self.assertEqual(result["requestId"], "req-21") + + +if __name__ == "__main__": + unittest.main() diff --git a/plugins/chezmoi/src/plugin.py b/plugins/chezmoi/src/plugin.py index 5b469fe7..8cd368d6 100644 --- a/plugins/chezmoi/src/plugin.py +++ b/plugins/chezmoi/src/plugin.py @@ -41,7 +41,9 @@ def read_yaml(file_path: str) -> dict: def write_yaml(file_path: str, data: dict) -> None: os.makedirs(os.path.dirname(file_path), exist_ok=True) - fd, temp_path = tempfile.mkstemp(dir=os.path.dirname(file_path), prefix="chezmoi.yaml.") + fd, temp_path = tempfile.mkstemp( + dir=os.path.dirname(file_path), prefix="chezmoi.yaml." + ) try: import yaml @@ -74,7 +76,9 @@ def merge_settings(target: dict, source: dict) -> bool: def check_installed(args: dict, request_id: str) -> dict: - installed = shutil.which("chezmoi.exe") is not None or shutil.which("chezmoi") is not None + installed = ( + shutil.which("chezmoi.exe") is not None or shutil.which("chezmoi") is not None + ) return { "requestId": request_id, "success": True, diff --git a/plugins/chezmoi/test/test_chezmoi.py b/plugins/chezmoi/test/test_chezmoi.py index e5bbe146..56f26649 100644 --- a/plugins/chezmoi/test/test_chezmoi.py +++ b/plugins/chezmoi/test/test_chezmoi.py @@ -1,195 +1,199 @@ -import json -import os -import sys -import tempfile -from unittest.mock import patch - -import pytest - -src_path = os.path.join(os.path.dirname(__file__), "..", "src") -sys.path.append(src_path) -try: - import plugin -finally: - sys.path.remove(src_path) - - -@pytest.fixture -def mock_context(): - return { - "os": "windows", - "arch": "x64", - "dryRun": False, - } - - -@patch("plugin.shutil.which") -def test_check_installed_true(mock_which): - mock_which.return_value = "/usr/local/bin/chezmoi" - result = plugin.handle({"requestId": "req-1", "command": "check_installed", "args": {}}) - - assert result["success"] is True - assert result["data"] is True - - -@patch("plugin.shutil.which") -def test_check_installed_false(mock_which): - mock_which.return_value = None - result = plugin.handle({"requestId": "req-1", "command": "check_installed", "args": {}}) - - assert result["success"] is True - assert result["data"] is False - - -@patch("plugin.get_config_path") -def test_apply_config_creates_new(mock_get_config_path, mock_context): - with tempfile.TemporaryDirectory() as temp_dir: - config_path = os.path.join(temp_dir, "chezmoi.yaml") - mock_get_config_path.return_value = config_path - - settings = { - "sourceDir": "~/dotfiles", - "merge": { - "command": "nvim", - "args": [ - "-d", - "{{ .Destination }}", - "{{ .Source }}", - "{{ .Target }}", - ], - }, - } - - result = plugin.handle( - { - "requestId": "req-2", - "command": "apply", - "args": {"settings": settings}, - "context": mock_context, - } - ) - - assert result["success"] is True - assert result["changed"] is True - assert os.path.exists(config_path) - - with open(config_path, "r") as f: - content = f.read() - assert "sourceDir: ~/dotfiles" in content - assert "command: nvim" in content - - -@patch("plugin.get_config_path") -def test_apply_config_dry_run(mock_get_config_path): - with tempfile.TemporaryDirectory() as temp_dir: - config_path = os.path.join(temp_dir, "chezmoi.yaml") - mock_get_config_path.return_value = config_path - - settings = {"sourceDir": "~/dotfiles"} - - result = plugin.handle( - { - "requestId": "req-3", - "command": "apply", - "args": {"settings": settings}, - "context": {"dryRun": True}, - } - ) - - assert result["success"] is True - assert result["changed"] is True - assert not os.path.exists(config_path) - - -@patch("plugin.get_config_path") -def test_apply_config_invalid_yaml_backup(mock_get_config_path, mock_context): - with tempfile.TemporaryDirectory() as temp_dir: - config_path = os.path.join(temp_dir, "chezmoi.yaml") - mock_get_config_path.return_value = config_path - - # Write invalid YAML (using unclosed string / bad indentation that crashes parser) - with open(config_path, "w") as f: - f.write("invalid: \n - yaml\n - format") - - settings = {"sourceDir": "~/dotfiles"} - - result = plugin.handle( - { - "requestId": "req-4", - "command": "apply", - "args": {"settings": settings}, - "context": mock_context, - } - ) - - assert result["success"] is True - assert result["changed"] is True - - # Check backup was created - backups = [f for f in os.listdir(temp_dir) if f.endswith(".bak")] - assert len(backups) == 1 - - with open(os.path.join(temp_dir, backups[0]), "r") as f: - assert f.read() == "invalid: \n - yaml\n - format" - - -def test_empty_stdin(): - with patch("sys.stdin.read", return_value=""): - with patch("sys.stdout.write") as mock_stdout: - plugin.main() - written = mock_stdout.call_args[0][0] - result = json.loads(written) - assert result["success"] is False - assert "Empty input" in result["error"] - - -def test_apply_config_invalid_args(): - result = plugin.handle( - { - "requestId": "req-invalid-args", - "command": "apply", - "args": "not-a-dict", - "context": {}, - } - ) - assert result["success"] is False - assert "args must be an object" in result["error"] - - -def test_apply_config_invalid_context(): - result = plugin.handle( - { - "requestId": "req-invalid-context", - "command": "apply", - "args": {}, - "context": "not-a-dict", - } - ) - assert result["success"] is False - assert "context must be an object" in result["error"] - - -def test_apply_config_invalid_settings(): - result = plugin.handle( - { - "requestId": "req-invalid-settings", - "command": "apply", - "args": {"settings": "not-a-dict"}, - "context": {}, - } - ) - assert result["success"] is False - assert "settings must be an object" in result["error"] - - -def test_unknown_command(): - result = plugin.handle( - { - "requestId": "req-unknown", - "command": "some_unknown_cmd", - "args": {}, - "context": {}, - } - ) - assert result["success"] is False - assert "Unknown command: some_unknown_cmd" in result["error"] +import json +import os +import sys +import tempfile +from unittest.mock import patch + +import pytest + +src_path = os.path.join(os.path.dirname(__file__), "..", "src") +sys.path.append(src_path) +try: + import plugin +finally: + sys.path.remove(src_path) + + +@pytest.fixture +def mock_context(): + return { + "os": "windows", + "arch": "x64", + "dryRun": False, + } + + +@patch("plugin.shutil.which") +def test_check_installed_true(mock_which): + mock_which.return_value = "/usr/local/bin/chezmoi" + result = plugin.handle( + {"requestId": "req-1", "command": "check_installed", "args": {}} + ) + + assert result["success"] is True + assert result["data"] is True + + +@patch("plugin.shutil.which") +def test_check_installed_false(mock_which): + mock_which.return_value = None + result = plugin.handle( + {"requestId": "req-1", "command": "check_installed", "args": {}} + ) + + assert result["success"] is True + assert result["data"] is False + + +@patch("plugin.get_config_path") +def test_apply_config_creates_new(mock_get_config_path, mock_context): + with tempfile.TemporaryDirectory() as temp_dir: + config_path = os.path.join(temp_dir, "chezmoi.yaml") + mock_get_config_path.return_value = config_path + + settings = { + "sourceDir": "~/dotfiles", + "merge": { + "command": "nvim", + "args": [ + "-d", + "{{ .Destination }}", + "{{ .Source }}", + "{{ .Target }}", + ], + }, + } + + result = plugin.handle( + { + "requestId": "req-2", + "command": "apply", + "args": {"settings": settings}, + "context": mock_context, + } + ) + + assert result["success"] is True + assert result["changed"] is True + assert os.path.exists(config_path) + + with open(config_path, "r") as f: + content = f.read() + assert "sourceDir: ~/dotfiles" in content + assert "command: nvim" in content + + +@patch("plugin.get_config_path") +def test_apply_config_dry_run(mock_get_config_path): + with tempfile.TemporaryDirectory() as temp_dir: + config_path = os.path.join(temp_dir, "chezmoi.yaml") + mock_get_config_path.return_value = config_path + + settings = {"sourceDir": "~/dotfiles"} + + result = plugin.handle( + { + "requestId": "req-3", + "command": "apply", + "args": {"settings": settings}, + "context": {"dryRun": True}, + } + ) + + assert result["success"] is True + assert result["changed"] is True + assert not os.path.exists(config_path) + + +@patch("plugin.get_config_path") +def test_apply_config_invalid_yaml_backup(mock_get_config_path, mock_context): + with tempfile.TemporaryDirectory() as temp_dir: + config_path = os.path.join(temp_dir, "chezmoi.yaml") + mock_get_config_path.return_value = config_path + + # Write invalid YAML (using unclosed string / bad indentation that crashes parser) + with open(config_path, "w") as f: + f.write("invalid: \n - yaml\n - format") + + settings = {"sourceDir": "~/dotfiles"} + + result = plugin.handle( + { + "requestId": "req-4", + "command": "apply", + "args": {"settings": settings}, + "context": mock_context, + } + ) + + assert result["success"] is True + assert result["changed"] is True + + # Check backup was created + backups = [f for f in os.listdir(temp_dir) if f.endswith(".bak")] + assert len(backups) == 1 + + with open(os.path.join(temp_dir, backups[0]), "r") as f: + assert f.read() == "invalid: \n - yaml\n - format" + + +def test_empty_stdin(): + with patch("sys.stdin.read", return_value=""): + with patch("sys.stdout.write") as mock_stdout: + plugin.main() + written = mock_stdout.call_args[0][0] + result = json.loads(written) + assert result["success"] is False + assert "Empty input" in result["error"] + + +def test_apply_config_invalid_args(): + result = plugin.handle( + { + "requestId": "req-invalid-args", + "command": "apply", + "args": "not-a-dict", + "context": {}, + } + ) + assert result["success"] is False + assert "args must be an object" in result["error"] + + +def test_apply_config_invalid_context(): + result = plugin.handle( + { + "requestId": "req-invalid-context", + "command": "apply", + "args": {}, + "context": "not-a-dict", + } + ) + assert result["success"] is False + assert "context must be an object" in result["error"] + + +def test_apply_config_invalid_settings(): + result = plugin.handle( + { + "requestId": "req-invalid-settings", + "command": "apply", + "args": {"settings": "not-a-dict"}, + "context": {}, + } + ) + assert result["success"] is False + assert "settings must be an object" in result["error"] + + +def test_unknown_command(): + result = plugin.handle( + { + "requestId": "req-unknown", + "command": "some_unknown_cmd", + "args": {}, + "context": {}, + } + ) + assert result["success"] is False + assert "Unknown command: some_unknown_cmd" in result["error"] diff --git a/plugins/chocolatey/src/plugin.py b/plugins/chocolatey/src/plugin.py index 2f215afb..f00f4855 100644 --- a/plugins/chocolatey/src/plugin.py +++ b/plugins/chocolatey/src/plugin.py @@ -1,218 +1,226 @@ -import json -import os -import shutil -import sys -import uuid -import xml.etree.ElementTree as ET - -CHOCO_NS = "http://chocolatey.org/schema/chocolatey-configuration" -ET.register_namespace("", CHOCO_NS) - - -def log(msg): - sys.stderr.write(f"[chocolatey-plugin] {msg}\n") - sys.stderr.flush() - - -def get_config_path(): - choco_install = os.getenv("ChocolateyInstall") - if not choco_install: - program_data = os.getenv("ALLUSERSPROFILE", "C:\\ProgramData") - choco_install = os.path.join(program_data, "chocolatey") - return os.path.join(choco_install, "config", "chocolatey.config") - - -def read_xml(file_path): - if not os.path.exists(file_path): - root = ET.Element(f"{{{CHOCO_NS}}}chocolatey") - ET.SubElement(root, f"{{{CHOCO_NS}}}config") - ET.SubElement(root, f"{{{CHOCO_NS}}}features") - return ET.ElementTree(root) - try: - return ET.parse(file_path) - except Exception as e: - backup_path = f"{file_path}.corrupted.{uuid.uuid4().hex[:8]}.bak" - try: - shutil.copy2(file_path, backup_path) - log(f"Warning: could not parse {file_path}: {e}. Backed up to {backup_path}. Starting with default.") - except Exception: - log(f"Warning: could not parse {file_path}: {e}. Starting with default.") - root = ET.Element(f"{{{CHOCO_NS}}}chocolatey") - ET.SubElement(root, f"{{{CHOCO_NS}}}config") - ET.SubElement(root, f"{{{CHOCO_NS}}}features") - return ET.ElementTree(root) - - -def write_xml(file_path, tree): - os.makedirs(os.path.dirname(file_path), exist_ok=True) - tmp = file_path + ".tmp" - tree.write(tmp, encoding="utf-8", xml_declaration=True) - os.replace(tmp, file_path) - - -def merge_settings(tree, source): - changed = False - root = tree.getroot() - - if "config" in source and isinstance(source["config"], dict): - config_el = root.find(f"{{{CHOCO_NS}}}config") - for key, value in source["config"].items(): - if value is None: - continue - - str_val = str(value) if not isinstance(value, bool) else ("true" if value else "false") - - if config_el is None: - config_el = ET.SubElement(root, f"{{{CHOCO_NS}}}config") - changed = True - - existing = None - for add_el in config_el.findall(f"{{{CHOCO_NS}}}add"): - if add_el.get("key") == key: - existing = add_el - break - - if existing is not None: - if existing.get("value") != str_val: - existing.set("value", str_val) - changed = True - else: - ET.SubElement( - config_el, - f"{{{CHOCO_NS}}}add", - {"key": key, "value": str_val}, - ) - changed = True - - if "features" in source and isinstance(source["features"], dict): - features_el = root.find(f"{{{CHOCO_NS}}}features") - for name, enabled in source["features"].items(): - if enabled is None: - continue - - str_enabled = "true" if enabled else "false" - - if features_el is None: - features_el = ET.SubElement(root, f"{{{CHOCO_NS}}}features") - changed = True - - existing = None - for feature_el in features_el.findall(f"{{{CHOCO_NS}}}feature"): - if feature_el.get("name") == name: - existing = feature_el - break - - if existing is not None: - if existing.get("enabled") != str_enabled: - existing.set("enabled", str_enabled) - changed = True - else: - ET.SubElement( - features_el, - f"{{{CHOCO_NS}}}feature", - {"name": name, "enabled": str_enabled}, - ) - changed = True - - return changed - - -def check_installed(args, request_id): - installed = shutil.which("choco.exe") is not None or shutil.which("choco") is not None - return { - "requestId": request_id, - "success": True, - "changed": False, - "data": installed, - } - - -def apply_config(args, context, request_id): - dry_run = context.get("dryRun", False) - - try: - config_path = get_config_path() - tree = read_xml(config_path) - - settings = args.get("settings", {}) - changed = merge_settings(tree, settings) - - if not changed: - return { - "requestId": request_id, - "success": True, - "changed": False, - } - - if dry_run: - log(f"Would update Chocolatey config at {config_path}") - return { - "requestId": request_id, - "success": True, - "changed": True, - } - - write_xml(config_path, tree) - log(f"Updated Chocolatey config: {config_path}") - - return { - "requestId": request_id, - "success": True, - "changed": True, - } - - except Exception as e: - log(f"Failed to apply config: {e}") - return { - "requestId": request_id, - "success": False, - "changed": False, - "error": str(e), - } - - -def main(): - input_data = sys.stdin.read() - if not input_data: - return - - try: - request = json.loads(input_data) - except Exception as e: - response = { - "requestId": "unknown", - "success": False, - "changed": False, - "error": f"Failed to parse request: {e}", - } - sys.stdout.write(json.dumps(response) + "\n") - sys.stdout.flush() - return - - request_id = request.get("requestId", "unknown") - command = request.get("command") - args = request.get("args", {}) - context = request.get("context", {}) - - response = { - "requestId": request_id, - "success": False, - "changed": False, - } - - try: - if command == "check_installed": - response = check_installed(args, request_id) - elif command == "apply": - response = apply_config(args, context, request_id) - else: - response["error"] = f"Unknown command: {command}" - except Exception as fatal_err: - response["error"] = f"Internal Script Error: {str(fatal_err)}" - - sys.stdout.write(json.dumps(response) + "\n") - sys.stdout.flush() - - -if __name__ == "__main__": - main() +import json +import os +import shutil +import sys +import uuid +import xml.etree.ElementTree as ET + +CHOCO_NS = "http://chocolatey.org/schema/chocolatey-configuration" +ET.register_namespace("", CHOCO_NS) + + +def log(msg): + sys.stderr.write(f"[chocolatey-plugin] {msg}\n") + sys.stderr.flush() + + +def get_config_path(): + choco_install = os.getenv("ChocolateyInstall") + if not choco_install: + program_data = os.getenv("ALLUSERSPROFILE", "C:\\ProgramData") + choco_install = os.path.join(program_data, "chocolatey") + return os.path.join(choco_install, "config", "chocolatey.config") + + +def read_xml(file_path): + if not os.path.exists(file_path): + root = ET.Element(f"{{{CHOCO_NS}}}chocolatey") + ET.SubElement(root, f"{{{CHOCO_NS}}}config") + ET.SubElement(root, f"{{{CHOCO_NS}}}features") + return ET.ElementTree(root) + try: + return ET.parse(file_path) + except Exception as e: + backup_path = f"{file_path}.corrupted.{uuid.uuid4().hex[:8]}.bak" + try: + shutil.copy2(file_path, backup_path) + log( + f"Warning: could not parse {file_path}: {e}. Backed up to {backup_path}. Starting with default." + ) + except Exception: + log(f"Warning: could not parse {file_path}: {e}. Starting with default.") + root = ET.Element(f"{{{CHOCO_NS}}}chocolatey") + ET.SubElement(root, f"{{{CHOCO_NS}}}config") + ET.SubElement(root, f"{{{CHOCO_NS}}}features") + return ET.ElementTree(root) + + +def write_xml(file_path, tree): + os.makedirs(os.path.dirname(file_path), exist_ok=True) + tmp = file_path + ".tmp" + tree.write(tmp, encoding="utf-8", xml_declaration=True) + os.replace(tmp, file_path) + + +def merge_settings(tree, source): + changed = False + root = tree.getroot() + + if "config" in source and isinstance(source["config"], dict): + config_el = root.find(f"{{{CHOCO_NS}}}config") + for key, value in source["config"].items(): + if value is None: + continue + + str_val = ( + str(value) + if not isinstance(value, bool) + else ("true" if value else "false") + ) + + if config_el is None: + config_el = ET.SubElement(root, f"{{{CHOCO_NS}}}config") + changed = True + + existing = None + for add_el in config_el.findall(f"{{{CHOCO_NS}}}add"): + if add_el.get("key") == key: + existing = add_el + break + + if existing is not None: + if existing.get("value") != str_val: + existing.set("value", str_val) + changed = True + else: + ET.SubElement( + config_el, + f"{{{CHOCO_NS}}}add", + {"key": key, "value": str_val}, + ) + changed = True + + if "features" in source and isinstance(source["features"], dict): + features_el = root.find(f"{{{CHOCO_NS}}}features") + for name, enabled in source["features"].items(): + if enabled is None: + continue + + str_enabled = "true" if enabled else "false" + + if features_el is None: + features_el = ET.SubElement(root, f"{{{CHOCO_NS}}}features") + changed = True + + existing = None + for feature_el in features_el.findall(f"{{{CHOCO_NS}}}feature"): + if feature_el.get("name") == name: + existing = feature_el + break + + if existing is not None: + if existing.get("enabled") != str_enabled: + existing.set("enabled", str_enabled) + changed = True + else: + ET.SubElement( + features_el, + f"{{{CHOCO_NS}}}feature", + {"name": name, "enabled": str_enabled}, + ) + changed = True + + return changed + + +def check_installed(args, request_id): + installed = ( + shutil.which("choco.exe") is not None or shutil.which("choco") is not None + ) + return { + "requestId": request_id, + "success": True, + "changed": False, + "data": installed, + } + + +def apply_config(args, context, request_id): + dry_run = context.get("dryRun", False) + + try: + config_path = get_config_path() + tree = read_xml(config_path) + + settings = args.get("settings", {}) + changed = merge_settings(tree, settings) + + if not changed: + return { + "requestId": request_id, + "success": True, + "changed": False, + } + + if dry_run: + log(f"Would update Chocolatey config at {config_path}") + return { + "requestId": request_id, + "success": True, + "changed": True, + } + + write_xml(config_path, tree) + log(f"Updated Chocolatey config: {config_path}") + + return { + "requestId": request_id, + "success": True, + "changed": True, + } + + except Exception as e: + log(f"Failed to apply config: {e}") + return { + "requestId": request_id, + "success": False, + "changed": False, + "error": str(e), + } + + +def main(): + input_data = sys.stdin.read() + if not input_data: + return + + try: + request = json.loads(input_data) + except Exception as e: + response = { + "requestId": "unknown", + "success": False, + "changed": False, + "error": f"Failed to parse request: {e}", + } + sys.stdout.write(json.dumps(response) + "\n") + sys.stdout.flush() + return + + request_id = request.get("requestId", "unknown") + command = request.get("command") + args = request.get("args", {}) + context = request.get("context", {}) + + response = { + "requestId": request_id, + "success": False, + "changed": False, + } + + try: + if command == "check_installed": + response = check_installed(args, request_id) + elif command == "apply": + response = apply_config(args, context, request_id) + else: + response["error"] = f"Unknown command: {command}" + except Exception as fatal_err: + response["error"] = f"Internal Script Error: {str(fatal_err)}" + + sys.stdout.write(json.dumps(response) + "\n") + sys.stdout.flush() + + +if __name__ == "__main__": + main() diff --git a/plugins/chocolatey/test/test_chocolatey.py b/plugins/chocolatey/test/test_chocolatey.py index cae6500a..2f12f7ec 100644 --- a/plugins/chocolatey/test/test_chocolatey.py +++ b/plugins/chocolatey/test/test_chocolatey.py @@ -1,231 +1,243 @@ -import json -import os -import sys -import xml.etree.ElementTree as ET - -src_path = os.path.join(os.path.dirname(__file__), "..", "src") -sys.path.append(src_path) -import plugin - -sys.path.remove(src_path) - - -def make_request(command, args=None, dry_run=False): - return { - "requestId": "test-001", - "command": command, - "args": args or {}, - "context": {"dryRun": dry_run}, - } - - -def test_check_installed_returns_bool(monkeypatch): - import shutil - - monkeypatch.setattr(shutil, "which", lambda x: "/usr/bin/choco") - req = make_request("check_installed") - result = plugin.check_installed(req["args"], req["requestId"]) - assert result["success"] is True - assert result["data"] is True - - -def test_apply_dry_run_no_write(tmp_path, monkeypatch): - config_file = tmp_path / "chocolatey.config" - # Create empty base XML - root = ET.Element(f"{{{plugin.CHOCO_NS}}}chocolatey") - config_el = ET.SubElement(root, f"{{{plugin.CHOCO_NS}}}config") - ET.SubElement( - config_el, - f"{{{plugin.CHOCO_NS}}}add", - {"key": "cacheLocation", "value": "old_cache"}, - ) - tree = ET.ElementTree(root) - tree.write(config_file, encoding="utf-8") - - monkeypatch.setattr(plugin, "get_config_path", lambda: str(config_file)) - - args = {"settings": {"config": {"cacheLocation": "new_cache"}}} - req = make_request("apply", args, dry_run=True) - result = plugin.apply_config(req["args"], req["context"], req["requestId"]) - - assert result["success"] is True - assert result["changed"] is True - - # Assert file not modified - parsed = ET.parse(config_file).getroot() - cache_el = parsed.find(f".//{{{plugin.CHOCO_NS}}}add[@key='cacheLocation']") - assert cache_el.get("value") == "old_cache" - - -def test_apply_merges_config(tmp_path, monkeypatch): - config_file = tmp_path / "chocolatey.config" - # Create empty base XML - root = ET.Element(f"{{{plugin.CHOCO_NS}}}chocolatey") - config_el = ET.SubElement(root, f"{{{plugin.CHOCO_NS}}}config") - ET.SubElement( - config_el, - f"{{{plugin.CHOCO_NS}}}add", - {"key": "cacheLocation", "value": "old_cache"}, - ) - tree = ET.ElementTree(root) - tree.write(config_file, encoding="utf-8") - - monkeypatch.setattr(plugin, "get_config_path", lambda: str(config_file)) - - args = { - "settings": { - "config": {"cacheLocation": "new_cache", "newKey": "new_val"}, - "features": {"checksumFiles": True}, - } - } - req = make_request("apply", args, dry_run=False) - result = plugin.apply_config(req["args"], req["context"], req["requestId"]) - - assert result["success"] is True - assert result["changed"] is True - - # Assert file modified - parsed = ET.parse(config_file).getroot() - add_els = parsed.findall(f".//{{{plugin.CHOCO_NS}}}add") - cache_val = next(el.get("value") for el in add_els if el.get("key") == "cacheLocation") - newkey_val = next(el.get("value") for el in add_els if el.get("key") == "newKey") - assert cache_val == "new_cache" - assert newkey_val == "new_val" - - feat_els = parsed.findall(f".//{{{plugin.CHOCO_NS}}}feature") - feat_val = next(el.get("enabled") for el in feat_els if el.get("name") == "checksumFiles") - assert feat_val == "true" - - -def test_apply_idempotent(tmp_path, monkeypatch): - config_file = tmp_path / "chocolatey.config" - # Create empty base XML - root = ET.Element(f"{{{plugin.CHOCO_NS}}}chocolatey") - config_el = ET.SubElement(root, f"{{{plugin.CHOCO_NS}}}config") - ET.SubElement( - config_el, - f"{{{plugin.CHOCO_NS}}}add", - {"key": "cacheLocation", "value": "old_cache"}, - ) - tree = ET.ElementTree(root) - tree.write(config_file, encoding="utf-8") - - monkeypatch.setattr(plugin, "get_config_path", lambda: str(config_file)) - - args = {"settings": {"config": {"cacheLocation": "old_cache"}}} - req = make_request("apply", args, dry_run=False) - result = plugin.apply_config(req["args"], req["context"], req["requestId"]) - - assert result["success"] is True - assert result["changed"] is False - - -def test_apply_creates_file(tmp_path, monkeypatch): - config_file = tmp_path / "chocolatey.config" - # Ensure it doesn't exist - if config_file.exists(): - config_file.unlink() - - monkeypatch.setattr(plugin, "get_config_path", lambda: str(config_file)) - - args = {"settings": {"config": {"cacheLocation": "D:\\choco-cache"}}} - req = make_request("apply", args, dry_run=False) - result = plugin.apply_config(req["args"], req["context"], req["requestId"]) - - assert result["success"] is True - assert result["changed"] is True - assert config_file.exists() - - parsed = ET.parse(config_file).getroot() - add_els = parsed.findall(f".//{{{plugin.CHOCO_NS}}}add") - cache_val = next(el.get("value") for el in add_els if el.get("key") == "cacheLocation") - assert cache_val == "D:\\choco-cache" - - -def test_unknown_command_via_main(monkeypatch, capsys): - request = json.dumps( - { - "requestId": "test-999", - "command": "unknown_cmd", - "args": {}, - "context": {}, - } - ) - monkeypatch.setattr("sys.stdin", type("StdinMock", (), {"read": lambda self: request})()) - plugin.main() - out = capsys.readouterr().out - response = json.loads(out) - - assert response["success"] is False - assert "Unknown command" in response["error"] - - -def test_corrupted_xml_recovery(tmp_path, monkeypatch): - config_file = tmp_path / "chocolatey.config" - config_file.write_text("<<<<< INVALID XML >>>>", encoding="utf-8") - - monkeypatch.setattr(plugin, "get_config_path", lambda: str(config_file)) - - args = {"settings": {"config": {"cacheLocation": "recovered_cache"}}} - req = make_request("apply", args, dry_run=False) - result = plugin.apply_config(req["args"], req["context"], req["requestId"]) - - assert result["success"] is True - assert result["changed"] is True - - # Assert new valid XML - parsed = ET.parse(config_file).getroot() - add_els = parsed.findall(f".//{{{plugin.CHOCO_NS}}}add") - cache_val = next(el.get("value") for el in add_els if el.get("key") == "cacheLocation") - assert cache_val == "recovered_cache" - - # Assert backup exists - backups = list(tmp_path.glob("*.bak")) - assert len(backups) == 1 - assert "corrupted" in backups[0].name - - -def test_feature_enabled_false(tmp_path, monkeypatch): - config_file = tmp_path / "chocolatey.config" - # Create empty base XML - root = ET.Element(f"{{{plugin.CHOCO_NS}}}chocolatey") - tree = ET.ElementTree(root) - tree.write(config_file, encoding="utf-8") - - monkeypatch.setattr(plugin, "get_config_path", lambda: str(config_file)) - - args = {"settings": {"features": {"checksumFiles": False}}} - req = make_request("apply", args, dry_run=False) - result = plugin.apply_config(req["args"], req["context"], req["requestId"]) - - assert result["success"] is True - assert result["changed"] is True - - parsed = ET.parse(config_file).getroot() - feat_els = parsed.findall(f".//{{{plugin.CHOCO_NS}}}feature") - feat_val = next(el.get("enabled") for el in feat_els if el.get("name") == "checksumFiles") - assert feat_val == "false" - - -def test_empty_args_handled(tmp_path, monkeypatch): - config_file = tmp_path / "chocolatey.config" - # Create empty base XML - root = ET.Element(f"{{{plugin.CHOCO_NS}}}chocolatey") - tree = ET.ElementTree(root) - tree.write(config_file, encoding="utf-8") - - monkeypatch.setattr(plugin, "get_config_path", lambda: str(config_file)) - - # args missing 'settings' - req = make_request("apply", {}, dry_run=False) - result = plugin.apply_config(req["args"], req["context"], req["requestId"]) - - assert result["success"] is True - assert result["changed"] is False - - # args with empty 'settings' - req = make_request("apply", {"settings": {}}, dry_run=False) - result = plugin.apply_config(req["args"], req["context"], req["requestId"]) - - assert result["success"] is True - assert result["changed"] is False +import json +import os +import sys +import xml.etree.ElementTree as ET + +src_path = os.path.join(os.path.dirname(__file__), "..", "src") +sys.path.append(src_path) +import plugin + +sys.path.remove(src_path) + + +def make_request(command, args=None, dry_run=False): + return { + "requestId": "test-001", + "command": command, + "args": args or {}, + "context": {"dryRun": dry_run}, + } + + +def test_check_installed_returns_bool(monkeypatch): + import shutil + + monkeypatch.setattr(shutil, "which", lambda x: "/usr/bin/choco") + req = make_request("check_installed") + result = plugin.check_installed(req["args"], req["requestId"]) + assert result["success"] is True + assert result["data"] is True + + +def test_apply_dry_run_no_write(tmp_path, monkeypatch): + config_file = tmp_path / "chocolatey.config" + # Create empty base XML + root = ET.Element(f"{{{plugin.CHOCO_NS}}}chocolatey") + config_el = ET.SubElement(root, f"{{{plugin.CHOCO_NS}}}config") + ET.SubElement( + config_el, + f"{{{plugin.CHOCO_NS}}}add", + {"key": "cacheLocation", "value": "old_cache"}, + ) + tree = ET.ElementTree(root) + tree.write(config_file, encoding="utf-8") + + monkeypatch.setattr(plugin, "get_config_path", lambda: str(config_file)) + + args = {"settings": {"config": {"cacheLocation": "new_cache"}}} + req = make_request("apply", args, dry_run=True) + result = plugin.apply_config(req["args"], req["context"], req["requestId"]) + + assert result["success"] is True + assert result["changed"] is True + + # Assert file not modified + parsed = ET.parse(config_file).getroot() + cache_el = parsed.find(f".//{{{plugin.CHOCO_NS}}}add[@key='cacheLocation']") + assert cache_el.get("value") == "old_cache" + + +def test_apply_merges_config(tmp_path, monkeypatch): + config_file = tmp_path / "chocolatey.config" + # Create empty base XML + root = ET.Element(f"{{{plugin.CHOCO_NS}}}chocolatey") + config_el = ET.SubElement(root, f"{{{plugin.CHOCO_NS}}}config") + ET.SubElement( + config_el, + f"{{{plugin.CHOCO_NS}}}add", + {"key": "cacheLocation", "value": "old_cache"}, + ) + tree = ET.ElementTree(root) + tree.write(config_file, encoding="utf-8") + + monkeypatch.setattr(plugin, "get_config_path", lambda: str(config_file)) + + args = { + "settings": { + "config": {"cacheLocation": "new_cache", "newKey": "new_val"}, + "features": {"checksumFiles": True}, + } + } + req = make_request("apply", args, dry_run=False) + result = plugin.apply_config(req["args"], req["context"], req["requestId"]) + + assert result["success"] is True + assert result["changed"] is True + + # Assert file modified + parsed = ET.parse(config_file).getroot() + add_els = parsed.findall(f".//{{{plugin.CHOCO_NS}}}add") + cache_val = next( + el.get("value") for el in add_els if el.get("key") == "cacheLocation" + ) + newkey_val = next(el.get("value") for el in add_els if el.get("key") == "newKey") + assert cache_val == "new_cache" + assert newkey_val == "new_val" + + feat_els = parsed.findall(f".//{{{plugin.CHOCO_NS}}}feature") + feat_val = next( + el.get("enabled") for el in feat_els if el.get("name") == "checksumFiles" + ) + assert feat_val == "true" + + +def test_apply_idempotent(tmp_path, monkeypatch): + config_file = tmp_path / "chocolatey.config" + # Create empty base XML + root = ET.Element(f"{{{plugin.CHOCO_NS}}}chocolatey") + config_el = ET.SubElement(root, f"{{{plugin.CHOCO_NS}}}config") + ET.SubElement( + config_el, + f"{{{plugin.CHOCO_NS}}}add", + {"key": "cacheLocation", "value": "old_cache"}, + ) + tree = ET.ElementTree(root) + tree.write(config_file, encoding="utf-8") + + monkeypatch.setattr(plugin, "get_config_path", lambda: str(config_file)) + + args = {"settings": {"config": {"cacheLocation": "old_cache"}}} + req = make_request("apply", args, dry_run=False) + result = plugin.apply_config(req["args"], req["context"], req["requestId"]) + + assert result["success"] is True + assert result["changed"] is False + + +def test_apply_creates_file(tmp_path, monkeypatch): + config_file = tmp_path / "chocolatey.config" + # Ensure it doesn't exist + if config_file.exists(): + config_file.unlink() + + monkeypatch.setattr(plugin, "get_config_path", lambda: str(config_file)) + + args = {"settings": {"config": {"cacheLocation": "D:\\choco-cache"}}} + req = make_request("apply", args, dry_run=False) + result = plugin.apply_config(req["args"], req["context"], req["requestId"]) + + assert result["success"] is True + assert result["changed"] is True + assert config_file.exists() + + parsed = ET.parse(config_file).getroot() + add_els = parsed.findall(f".//{{{plugin.CHOCO_NS}}}add") + cache_val = next( + el.get("value") for el in add_els if el.get("key") == "cacheLocation" + ) + assert cache_val == "D:\\choco-cache" + + +def test_unknown_command_via_main(monkeypatch, capsys): + request = json.dumps( + { + "requestId": "test-999", + "command": "unknown_cmd", + "args": {}, + "context": {}, + } + ) + monkeypatch.setattr( + "sys.stdin", type("StdinMock", (), {"read": lambda self: request})() + ) + plugin.main() + out = capsys.readouterr().out + response = json.loads(out) + + assert response["success"] is False + assert "Unknown command" in response["error"] + + +def test_corrupted_xml_recovery(tmp_path, monkeypatch): + config_file = tmp_path / "chocolatey.config" + config_file.write_text("<<<<< INVALID XML >>>>", encoding="utf-8") + + monkeypatch.setattr(plugin, "get_config_path", lambda: str(config_file)) + + args = {"settings": {"config": {"cacheLocation": "recovered_cache"}}} + req = make_request("apply", args, dry_run=False) + result = plugin.apply_config(req["args"], req["context"], req["requestId"]) + + assert result["success"] is True + assert result["changed"] is True + + # Assert new valid XML + parsed = ET.parse(config_file).getroot() + add_els = parsed.findall(f".//{{{plugin.CHOCO_NS}}}add") + cache_val = next( + el.get("value") for el in add_els if el.get("key") == "cacheLocation" + ) + assert cache_val == "recovered_cache" + + # Assert backup exists + backups = list(tmp_path.glob("*.bak")) + assert len(backups) == 1 + assert "corrupted" in backups[0].name + + +def test_feature_enabled_false(tmp_path, monkeypatch): + config_file = tmp_path / "chocolatey.config" + # Create empty base XML + root = ET.Element(f"{{{plugin.CHOCO_NS}}}chocolatey") + tree = ET.ElementTree(root) + tree.write(config_file, encoding="utf-8") + + monkeypatch.setattr(plugin, "get_config_path", lambda: str(config_file)) + + args = {"settings": {"features": {"checksumFiles": False}}} + req = make_request("apply", args, dry_run=False) + result = plugin.apply_config(req["args"], req["context"], req["requestId"]) + + assert result["success"] is True + assert result["changed"] is True + + parsed = ET.parse(config_file).getroot() + feat_els = parsed.findall(f".//{{{plugin.CHOCO_NS}}}feature") + feat_val = next( + el.get("enabled") for el in feat_els if el.get("name") == "checksumFiles" + ) + assert feat_val == "false" + + +def test_empty_args_handled(tmp_path, monkeypatch): + config_file = tmp_path / "chocolatey.config" + # Create empty base XML + root = ET.Element(f"{{{plugin.CHOCO_NS}}}chocolatey") + tree = ET.ElementTree(root) + tree.write(config_file, encoding="utf-8") + + monkeypatch.setattr(plugin, "get_config_path", lambda: str(config_file)) + + # args missing 'settings' + req = make_request("apply", {}, dry_run=False) + result = plugin.apply_config(req["args"], req["context"], req["requestId"]) + + assert result["success"] is True + assert result["changed"] is False + + # args with empty 'settings' + req = make_request("apply", {"settings": {}}, dry_run=False) + result = plugin.apply_config(req["args"], req["context"], req["requestId"]) + + assert result["success"] is True + assert result["changed"] is False diff --git a/plugins/curl/src/plugin.py b/plugins/curl/src/plugin.py index 3bd235c0..bb60b110 100644 --- a/plugins/curl/src/plugin.py +++ b/plugins/curl/src/plugin.py @@ -1,232 +1,234 @@ -import datetime -import json -import os -import shutil -import sys -import tempfile -import uuid - - -def log(msg): - sys.stderr.write(f"[curl-plugin] {msg}\n") - sys.stderr.flush() - - -def get_config_path(): - if os.name == "nt": - user_profile = os.getenv("USERPROFILE") - if not user_profile: - user_profile = os.path.expanduser("~") - return os.path.join(user_profile, "_curlrc") - else: - return os.path.expanduser("~/.curlrc") - - -def _backup_corrupt_config(file_path: str, reason: str): - timestamp = datetime.datetime.now(datetime.timezone.utc).strftime("%Y%m%d%H%M%S") - suffix = uuid.uuid4().hex[:8] - backup_path = f"{file_path}.corrupted.{timestamp}.{suffix}" - log(f"Config read failed ({reason}). Backing up to {backup_path} and starting fresh.") - try: - shutil.move(file_path, backup_path) - except Exception as backup_e: - log(f"Failed to backup corrupted config: {backup_e}") - - -def parse_curlrc(lines: list) -> dict: - config = {} - for line in lines: - line = line.strip() - if not line or line.startswith("#"): - continue - if "=" in line: - key, val = line.split("=", 1) - config[key.strip()] = val.strip() - else: - config[line] = None - return config - - -def read_curlrc(file_path: str) -> dict: - if not os.path.exists(file_path): - return {} - - try: - with open(file_path, "r", encoding="utf-8") as f: - lines = f.readlines() - return parse_curlrc(lines) - except UnicodeDecodeError as e: - _backup_corrupt_config(file_path, f"UnicodeDecodeError: {e}") - return {} - except OSError as e: - _backup_corrupt_config(file_path, f"OSError: {e}") - return {} - - -def build_curlrc_content(merged_config: dict) -> str: - lines = [] - for key, value in merged_config.items(): - if value is None or str(value).lower() in ("true", "null"): - # It's a flag - lines.append(key) - elif str(value).lower() == "false": - # If the user sets a flag to false, we remove it. - continue - else: - lines.append(f"{key}={value}") - return "\n".join(lines) + "\n" - - -def write_curlrc(file_path: str, merged_config: dict) -> None: - dir_path = os.path.dirname(file_path) - if dir_path: - os.makedirs(dir_path, exist_ok=True) - fd, temp_path = tempfile.mkstemp(prefix="curl-", dir=dir_path or ".") - try: - with os.fdopen(fd, "w", encoding="utf-8") as f: - f.write(build_curlrc_content(merged_config)) - os.replace(temp_path, file_path) - except Exception: - try: - os.unlink(temp_path) - except OSError: - pass - raise - - -def _to_str(v): - if v is None: - return None - return str(v).lower() if isinstance(v, bool) else str(v) - - -def merge_settings(target: dict, source: dict) -> bool: - changed = False - for key, value in source.items(): - nv = _to_str(value) - if key not in target: - target[key] = value - changed = True - elif target[key] != nv: - if not (target[key] is None and nv == "true"): - target[key] = value - changed = True - return changed - - -def check_installed(args: dict, request_id: str) -> dict: - installed = shutil.which("curl.exe") is not None or shutil.which("curl") is not None - - return { - "requestId": request_id, - "success": True, - "changed": False, - "data": installed, - } - - -def apply_config(args: dict, context: dict, request_id: str) -> dict: - dry_run = context.get("dryRun", False) - settings = args.get("settings", {}) - - try: - config_path = get_config_path() - current_config = read_curlrc(config_path) - - changed = merge_settings(current_config, settings) - - if not changed: - return { - "requestId": request_id, - "success": True, - "changed": False, - "data": None, - } - - if dry_run: - log(f"Would update {config_path} with new settings") - return { - "requestId": request_id, - "success": True, - "changed": changed, - "data": None, - } - - write_curlrc(config_path, current_config) - log(f"Updated curl config: {config_path}") - - return { - "requestId": request_id, - "success": True, - "changed": True, - "data": None, - } - - except Exception as e: - log(f"Failed to apply config: {e}") - return { - "requestId": request_id, - "success": False, - "changed": False, - "error": str(e), - "data": None, - } - - -def main(): - input_data = sys.stdin.read() - if not input_data: - response = { - "requestId": "unknown", - "success": False, - "changed": False, - "error": "No input provided on stdin", - "data": None, - } - sys.stdout.write(json.dumps(response) + "\n") - sys.stdout.flush() - return - - try: - request = json.loads(input_data) - except Exception as e: - log(f"Failed to parse request: {e}") - response = { - "requestId": "unknown", - "success": False, - "changed": False, - "error": f"Failed to parse JSON request: {str(e)}", - "data": None, - } - sys.stdout.write(json.dumps(response) + "\n") - sys.stdout.flush() - return - - request_id = request.get("requestId", "unknown") - command = request.get("command") - args = request.get("args", {}) - context = request.get("context", {}) - - response = { - "requestId": request_id, - "success": False, - "changed": False, - "data": None, - } - - try: - if command == "check_installed": - response = check_installed(args, request_id) - elif command == "apply": - response = apply_config(args, context, request_id) - else: - response["error"] = f"Unknown command: {command}" - except Exception as fatal_err: - response["error"] = f"Internal Script Error: {str(fatal_err)}" - - sys.stdout.write(json.dumps(response) + "\n") - sys.stdout.flush() - - -if __name__ == "__main__": - main() +import datetime +import json +import os +import shutil +import sys +import tempfile +import uuid + + +def log(msg): + sys.stderr.write(f"[curl-plugin] {msg}\n") + sys.stderr.flush() + + +def get_config_path(): + if os.name == "nt": + user_profile = os.getenv("USERPROFILE") + if not user_profile: + user_profile = os.path.expanduser("~") + return os.path.join(user_profile, "_curlrc") + else: + return os.path.expanduser("~/.curlrc") + + +def _backup_corrupt_config(file_path: str, reason: str): + timestamp = datetime.datetime.now(datetime.timezone.utc).strftime("%Y%m%d%H%M%S") + suffix = uuid.uuid4().hex[:8] + backup_path = f"{file_path}.corrupted.{timestamp}.{suffix}" + log( + f"Config read failed ({reason}). Backing up to {backup_path} and starting fresh." + ) + try: + shutil.move(file_path, backup_path) + except Exception as backup_e: + log(f"Failed to backup corrupted config: {backup_e}") + + +def parse_curlrc(lines: list) -> dict: + config = {} + for line in lines: + line = line.strip() + if not line or line.startswith("#"): + continue + if "=" in line: + key, val = line.split("=", 1) + config[key.strip()] = val.strip() + else: + config[line] = None + return config + + +def read_curlrc(file_path: str) -> dict: + if not os.path.exists(file_path): + return {} + + try: + with open(file_path, "r", encoding="utf-8") as f: + lines = f.readlines() + return parse_curlrc(lines) + except UnicodeDecodeError as e: + _backup_corrupt_config(file_path, f"UnicodeDecodeError: {e}") + return {} + except OSError as e: + _backup_corrupt_config(file_path, f"OSError: {e}") + return {} + + +def build_curlrc_content(merged_config: dict) -> str: + lines = [] + for key, value in merged_config.items(): + if value is None or str(value).lower() in ("true", "null"): + # It's a flag + lines.append(key) + elif str(value).lower() == "false": + # If the user sets a flag to false, we remove it. + continue + else: + lines.append(f"{key}={value}") + return "\n".join(lines) + "\n" + + +def write_curlrc(file_path: str, merged_config: dict) -> None: + dir_path = os.path.dirname(file_path) + if dir_path: + os.makedirs(dir_path, exist_ok=True) + fd, temp_path = tempfile.mkstemp(prefix="curl-", dir=dir_path or ".") + try: + with os.fdopen(fd, "w", encoding="utf-8") as f: + f.write(build_curlrc_content(merged_config)) + os.replace(temp_path, file_path) + except Exception: + try: + os.unlink(temp_path) + except OSError: + pass + raise + + +def _to_str(v): + if v is None: + return None + return str(v).lower() if isinstance(v, bool) else str(v) + + +def merge_settings(target: dict, source: dict) -> bool: + changed = False + for key, value in source.items(): + nv = _to_str(value) + if key not in target: + target[key] = value + changed = True + elif target[key] != nv: + if not (target[key] is None and nv == "true"): + target[key] = value + changed = True + return changed + + +def check_installed(args: dict, request_id: str) -> dict: + installed = shutil.which("curl.exe") is not None or shutil.which("curl") is not None + + return { + "requestId": request_id, + "success": True, + "changed": False, + "data": installed, + } + + +def apply_config(args: dict, context: dict, request_id: str) -> dict: + dry_run = context.get("dryRun", False) + settings = args.get("settings", {}) + + try: + config_path = get_config_path() + current_config = read_curlrc(config_path) + + changed = merge_settings(current_config, settings) + + if not changed: + return { + "requestId": request_id, + "success": True, + "changed": False, + "data": None, + } + + if dry_run: + log(f"Would update {config_path} with new settings") + return { + "requestId": request_id, + "success": True, + "changed": changed, + "data": None, + } + + write_curlrc(config_path, current_config) + log(f"Updated curl config: {config_path}") + + return { + "requestId": request_id, + "success": True, + "changed": True, + "data": None, + } + + except Exception as e: + log(f"Failed to apply config: {e}") + return { + "requestId": request_id, + "success": False, + "changed": False, + "error": str(e), + "data": None, + } + + +def main(): + input_data = sys.stdin.read() + if not input_data: + response = { + "requestId": "unknown", + "success": False, + "changed": False, + "error": "No input provided on stdin", + "data": None, + } + sys.stdout.write(json.dumps(response) + "\n") + sys.stdout.flush() + return + + try: + request = json.loads(input_data) + except Exception as e: + log(f"Failed to parse request: {e}") + response = { + "requestId": "unknown", + "success": False, + "changed": False, + "error": f"Failed to parse JSON request: {str(e)}", + "data": None, + } + sys.stdout.write(json.dumps(response) + "\n") + sys.stdout.flush() + return + + request_id = request.get("requestId", "unknown") + command = request.get("command") + args = request.get("args", {}) + context = request.get("context", {}) + + response = { + "requestId": request_id, + "success": False, + "changed": False, + "data": None, + } + + try: + if command == "check_installed": + response = check_installed(args, request_id) + elif command == "apply": + response = apply_config(args, context, request_id) + else: + response["error"] = f"Unknown command: {command}" + except Exception as fatal_err: + response["error"] = f"Internal Script Error: {str(fatal_err)}" + + sys.stdout.write(json.dumps(response) + "\n") + sys.stdout.flush() + + +if __name__ == "__main__": + main() diff --git a/plugins/deno/src/plugin.py b/plugins/deno/src/plugin.py index feff31b8..64f87f44 100644 --- a/plugins/deno/src/plugin.py +++ b/plugins/deno/src/plugin.py @@ -100,7 +100,9 @@ def write_deno_config(file_path, config): def check_installed(): return ( - shutil.which("deno.cmd") is not None or shutil.which("deno.exe") is not None or shutil.which("deno") is not None + shutil.which("deno.cmd") is not None + or shutil.which("deno.exe") is not None + or shutil.which("deno") is not None ) @@ -178,7 +180,10 @@ def main(): response = {"requestId": request_id, "error": f"Unknown command: {command}"} except Exception as fatal_err: - response = {"requestId": request_id, "error": f"Internal Script Error: {str(fatal_err)}"} + response = { + "requestId": request_id, + "error": f"Internal Script Error: {str(fatal_err)}", + } sys.stdout.write(json.dumps(response) + "\n") sys.stdout.flush() diff --git a/plugins/deno/test/test_deno.py b/plugins/deno/test/test_deno.py index 85c9e5ca..350d230e 100644 --- a/plugins/deno/test/test_deno.py +++ b/plugins/deno/test/test_deno.py @@ -36,9 +36,17 @@ def test_apply_empty_config(self, mock_path): @patch("plugin.write_deno_config") def test_apply_config_no_changes_needed(self, mock_write, mock_read, mock_path): mock_path.return_value = "/fake/deno.json" - mock_read.return_value = {"lint": {"rules": {"tags": ["recommended"]}}, "fmt": {"useTabs": True}} + mock_read.return_value = { + "lint": {"rules": {"tags": ["recommended"]}}, + "fmt": {"useTabs": True}, + } - args = {"settings": {"lint": {"rules": {"tags": ["recommended"]}}, "fmt": {"useTabs": True}}} + args = { + "settings": { + "lint": {"rules": {"tags": ["recommended"]}}, + "fmt": {"useTabs": True}, + } + } result = plugin.apply_config(args, "req-789") @@ -53,7 +61,11 @@ def test_apply_config_changes_needed(self, mock_write, mock_read, mock_path): mock_read.return_value = {"lint": {"rules": {"tags": ["recommended"]}}} args = { - "settings": {"lint": {"rules": {"tags": ["recommended"]}}, "fmt": {"useTabs": True}, "typeCheckOnRun": True} + "settings": { + "lint": {"rules": {"tags": ["recommended"]}}, + "fmt": {"useTabs": True}, + "typeCheckOnRun": True, + } } result = plugin.apply_config(args, "req-abc") diff --git a/plugins/discord/test/test_discord.py b/plugins/discord/test/test_discord.py index 353aa4e7..8583dbbf 100644 --- a/plugins/discord/test/test_discord.py +++ b/plugins/discord/test/test_discord.py @@ -4,7 +4,9 @@ import sys import tempfile -PLUGIN = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "src", "plugin.py")) +PLUGIN = os.path.abspath( + os.path.join(os.path.dirname(__file__), "..", "src", "plugin.py") +) def run_plugin(payload: dict) -> dict: diff --git a/plugins/ditto/src/plugin.py b/plugins/ditto/src/plugin.py index 161034ed..4d82768e 100644 --- a/plugins/ditto/src/plugin.py +++ b/plugins/ditto/src/plugin.py @@ -128,7 +128,10 @@ def main(): response = {"requestId": request_id, "error": f"Unknown command: {command}"} except Exception as fatal_err: - response = {"requestId": request_id, "error": f"Internal Script Error: {str(fatal_err)}"} + response = { + "requestId": request_id, + "error": f"Internal Script Error: {str(fatal_err)}", + } sys.stdout.write(json.dumps(response) + "\n") sys.stdout.flush() diff --git a/plugins/ditto/test/test_ditto.py b/plugins/ditto/test/test_ditto.py index 1a4e4d81..b4f429aa 100644 --- a/plugins/ditto/test/test_ditto.py +++ b/plugins/ditto/test/test_ditto.py @@ -4,7 +4,9 @@ import sys import tempfile -PLUGIN = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "src", "plugin.py")) +PLUGIN = os.path.abspath( + os.path.join(os.path.dirname(__file__), "..", "src", "plugin.py") +) def run_plugin(payload: dict, env: dict = None) -> dict: @@ -123,7 +125,9 @@ def test_dry_run(): def test_unknown_command(): - res = run_plugin({"requestId": "6", "command": "explode", "args": {}, "context": {}}) + res = run_plugin( + {"requestId": "6", "command": "explode", "args": {}, "context": {}} + ) assert "error" in res assert "success" not in res print("✓ unknown_command") diff --git a/plugins/docker/src/plugin.py b/plugins/docker/src/plugin.py index b6ffba03..f13bca91 100644 --- a/plugins/docker/src/plugin.py +++ b/plugins/docker/src/plugin.py @@ -29,7 +29,9 @@ def read_json(file_path: str) -> dict: data = json.load(f) return data if isinstance(data, dict) else {} except json.JSONDecodeError: - timestamp = datetime.datetime.now(datetime.timezone.utc).strftime("%Y%m%d%H%M%S") + timestamp = datetime.datetime.now(datetime.timezone.utc).strftime( + "%Y%m%d%H%M%S" + ) suffix = uuid.uuid4().hex[:8] backup_path = f"{file_path}.corrupted.{timestamp}.{suffix}" log(f"Config corrupted. Backing up to {backup_path} and starting fresh.") @@ -74,7 +76,9 @@ def merge_settings(target: dict, source: dict) -> bool: def check_installed(args: dict, request_id: str) -> dict: # Check for docker or docker.exe in PATH - installed = shutil.which("docker.exe") is not None or shutil.which("docker") is not None + installed = ( + shutil.which("docker.exe") is not None or shutil.which("docker") is not None + ) return { "requestId": request_id, "success": True, diff --git a/plugins/docker/test/test_docker.py b/plugins/docker/test/test_docker.py index bbcbc76c..abf5630c 100644 --- a/plugins/docker/test/test_docker.py +++ b/plugins/docker/test/test_docker.py @@ -1,139 +1,143 @@ -import json -import os -import sys -import tempfile -import unittest -from unittest.mock import patch - -# Add src directory to path to import plugin -_src_path = os.path.abspath(os.path.join(os.path.dirname(__file__), "../src")) -sys.path.append(_src_path) -import plugin - -sys.path.remove(_src_path) - - -class TestDockerPlugin(unittest.TestCase): - def setUp(self): - self.temp_dir = tempfile.TemporaryDirectory() - self.config_path = os.path.join(self.temp_dir.name, "settings.json") - - def tearDown(self): - self.temp_dir.cleanup() - - @patch("plugin.shutil.which") - def test_check_installed(self, mock_which): - mock_which.return_value = "C:\\Program Files\\Docker\\Docker\\resources\\bin\\docker.exe" - - response = plugin.check_installed({}, "req-1") - - self.assertTrue(response["success"]) - self.assertTrue(response["data"]) - mock_which.assert_called() - - def test_merge_settings(self): - target = { - "wslEngineEnabled": False, - "proxies": {"httpProxy": "http://oldproxy:80"}, - "registryMirrors": ["https://old.mirror"], - } - - source = { - "wslEngineEnabled": True, - "experimental": True, - "proxies": {"httpsProxy": "http://newproxy:443"}, - "registryMirrors": ["https://new.mirror"], - } - - changed = plugin.merge_settings(target, source) - - self.assertTrue(changed) - self.assertTrue(target["wslEngineEnabled"]) - self.assertTrue(target["experimental"]) - - # Nested dicts should merge - self.assertEqual(target["proxies"]["httpProxy"], "http://oldproxy:80") - self.assertEqual(target["proxies"]["httpsProxy"], "http://newproxy:443") - - # Arrays should overwrite - self.assertEqual(target["registryMirrors"], ["https://new.mirror"]) - - @patch("plugin.get_config_path") - def test_apply_config_dry_run(self, mock_get_path): - mock_get_path.return_value = self.config_path - - # Write initial config - with open(self.config_path, "w", encoding="utf-8") as f: - json.dump({"wslEngineEnabled": False}, f) - - args = {"settings": {"wslEngineEnabled": True}} - - # Dry run - response = plugin.apply_config(args, {"dryRun": True}, "req-2") - self.assertTrue(response["success"]) - self.assertTrue(response["changed"]) - - # Verify file was NOT changed - with open(self.config_path, "r", encoding="utf-8") as f: - content = json.load(f) - self.assertFalse(content["wslEngineEnabled"]) - - @patch("plugin.get_config_path") - def test_apply_config_real_run(self, mock_get_path): - mock_get_path.return_value = self.config_path - - args = {"settings": {"kubernetes": {"enabled": True}}} - - # Real run on missing file (should create it) - response = plugin.apply_config(args, {"dryRun": False}, "req-3") - self.assertTrue(response["success"]) - self.assertTrue(response["changed"]) - - # Verify file WAS created and changed - with open(self.config_path, "r", encoding="utf-8") as f: - content = json.load(f) - self.assertTrue(content["kubernetes"]["enabled"]) - - @patch("plugin.get_config_path") - def test_apply_config_no_changes(self, mock_get_path): - mock_get_path.return_value = self.config_path - - # Write initial config - with open(self.config_path, "w", encoding="utf-8") as f: - json.dump({"kubernetes": {"enabled": True}}, f) - - args = {"settings": {"kubernetes": {"enabled": True}}} - - # Real run but no actual differences - response = plugin.apply_config(args, {"dryRun": False}, "req-4") - self.assertTrue(response["success"]) - self.assertFalse(response["changed"]) - - @patch("plugin.get_config_path") - def test_read_corrupted_config(self, mock_get_path): - mock_get_path.return_value = self.config_path - - # Write corrupted config - with open(self.config_path, "w", encoding="utf-8") as f: - f.write("{ invalid json") - - args = {"settings": {"wslEngineEnabled": True}} - - # Should back up corrupted and apply new - response = plugin.apply_config(args, {"dryRun": False}, "req-5") - self.assertTrue(response["success"]) - self.assertTrue(response["changed"]) - - # Verify file WAS reset and written - with open(self.config_path, "r", encoding="utf-8") as f: - content = json.load(f) - self.assertTrue(content["wslEngineEnabled"]) - - # Verify backup was created - dir_name = os.path.dirname(self.config_path) - backups = [f for f in os.listdir(dir_name) if f.startswith("settings.json.corrupted.")] - self.assertEqual(len(backups), 1) - - -if __name__ == "__main__": - unittest.main() +import json +import os +import sys +import tempfile +import unittest +from unittest.mock import patch + +# Add src directory to path to import plugin +_src_path = os.path.abspath(os.path.join(os.path.dirname(__file__), "../src")) +sys.path.append(_src_path) +import plugin + +sys.path.remove(_src_path) + + +class TestDockerPlugin(unittest.TestCase): + def setUp(self): + self.temp_dir = tempfile.TemporaryDirectory() + self.config_path = os.path.join(self.temp_dir.name, "settings.json") + + def tearDown(self): + self.temp_dir.cleanup() + + @patch("plugin.shutil.which") + def test_check_installed(self, mock_which): + mock_which.return_value = ( + "C:\\Program Files\\Docker\\Docker\\resources\\bin\\docker.exe" + ) + + response = plugin.check_installed({}, "req-1") + + self.assertTrue(response["success"]) + self.assertTrue(response["data"]) + mock_which.assert_called() + + def test_merge_settings(self): + target = { + "wslEngineEnabled": False, + "proxies": {"httpProxy": "http://oldproxy:80"}, + "registryMirrors": ["https://old.mirror"], + } + + source = { + "wslEngineEnabled": True, + "experimental": True, + "proxies": {"httpsProxy": "http://newproxy:443"}, + "registryMirrors": ["https://new.mirror"], + } + + changed = plugin.merge_settings(target, source) + + self.assertTrue(changed) + self.assertTrue(target["wslEngineEnabled"]) + self.assertTrue(target["experimental"]) + + # Nested dicts should merge + self.assertEqual(target["proxies"]["httpProxy"], "http://oldproxy:80") + self.assertEqual(target["proxies"]["httpsProxy"], "http://newproxy:443") + + # Arrays should overwrite + self.assertEqual(target["registryMirrors"], ["https://new.mirror"]) + + @patch("plugin.get_config_path") + def test_apply_config_dry_run(self, mock_get_path): + mock_get_path.return_value = self.config_path + + # Write initial config + with open(self.config_path, "w", encoding="utf-8") as f: + json.dump({"wslEngineEnabled": False}, f) + + args = {"settings": {"wslEngineEnabled": True}} + + # Dry run + response = plugin.apply_config(args, {"dryRun": True}, "req-2") + self.assertTrue(response["success"]) + self.assertTrue(response["changed"]) + + # Verify file was NOT changed + with open(self.config_path, "r", encoding="utf-8") as f: + content = json.load(f) + self.assertFalse(content["wslEngineEnabled"]) + + @patch("plugin.get_config_path") + def test_apply_config_real_run(self, mock_get_path): + mock_get_path.return_value = self.config_path + + args = {"settings": {"kubernetes": {"enabled": True}}} + + # Real run on missing file (should create it) + response = plugin.apply_config(args, {"dryRun": False}, "req-3") + self.assertTrue(response["success"]) + self.assertTrue(response["changed"]) + + # Verify file WAS created and changed + with open(self.config_path, "r", encoding="utf-8") as f: + content = json.load(f) + self.assertTrue(content["kubernetes"]["enabled"]) + + @patch("plugin.get_config_path") + def test_apply_config_no_changes(self, mock_get_path): + mock_get_path.return_value = self.config_path + + # Write initial config + with open(self.config_path, "w", encoding="utf-8") as f: + json.dump({"kubernetes": {"enabled": True}}, f) + + args = {"settings": {"kubernetes": {"enabled": True}}} + + # Real run but no actual differences + response = plugin.apply_config(args, {"dryRun": False}, "req-4") + self.assertTrue(response["success"]) + self.assertFalse(response["changed"]) + + @patch("plugin.get_config_path") + def test_read_corrupted_config(self, mock_get_path): + mock_get_path.return_value = self.config_path + + # Write corrupted config + with open(self.config_path, "w", encoding="utf-8") as f: + f.write("{ invalid json") + + args = {"settings": {"wslEngineEnabled": True}} + + # Should back up corrupted and apply new + response = plugin.apply_config(args, {"dryRun": False}, "req-5") + self.assertTrue(response["success"]) + self.assertTrue(response["changed"]) + + # Verify file WAS reset and written + with open(self.config_path, "r", encoding="utf-8") as f: + content = json.load(f) + self.assertTrue(content["wslEngineEnabled"]) + + # Verify backup was created + dir_name = os.path.dirname(self.config_path) + backups = [ + f for f in os.listdir(dir_name) if f.startswith("settings.json.corrupted.") + ] + self.assertEqual(len(backups), 1) + + +if __name__ == "__main__": + unittest.main() diff --git a/plugins/espanso/src/plugin.py b/plugins/espanso/src/plugin.py index e7610858..30a79bdd 100644 --- a/plugins/espanso/src/plugin.py +++ b/plugins/espanso/src/plugin.py @@ -1,386 +1,392 @@ -""" -Espanso plugin for WinHome. - -Manages Espanso text expander configuration on Windows. -Config location: %APPDATA%\\espanso\\match\\base.yml - -Commands: - - check_installed: Verify Espanso is installed - - apply: Deep-merge matches/global_vars into base.yml -""" - -import json -import os -import sys -from copy import deepcopy -from pathlib import Path - -# --------------------------------------------------------------------------- -# Logging — simple stderr helper, matching other WinHome plugins -# --------------------------------------------------------------------------- - - -def log(msg: str) -> None: - print(f"[espanso] {msg}", file=sys.stderr, flush=True) - - -# --------------------------------------------------------------------------- -# YAML helpers (stdlib-only, no PyYAML dependency) -# --------------------------------------------------------------------------- - - -def _yaml_value(v) -> str: - """Serialise a scalar Python value to a YAML-safe string.""" - if isinstance(v, bool): - return "true" if v else "false" - if isinstance(v, (int, float)): - return str(v) - s = str(v) - if any( - c in s - for c in ( - ":", - "#", - "[", - "]", - "{", - "}", - ",", - "&", - "*", - "?", - "|", - "-", - "<", - ">", - "=", - "!", - "%", - "@", - "`", - '"', - "'", - ) - ): - escaped = s.replace('"', '\\"') - return f'"{escaped}"' - if s in ("true", "false", "null", "yes", "no", "on", "off", ""): - return f'"{s}"' - return s - - -def _dump_yaml(data: dict, indent: int = 0) -> str: - """ - Minimal recursive YAML serialiser. - - Handles the subset used by Espanso base.yml: - top-level keys, lists of dicts, and scalar values. - """ - lines = [] - pad = " " * indent - for key, value in data.items(): - if isinstance(value, list): - lines.append(f"{pad}{key}:") - for item in value: - if isinstance(item, dict): - first = True - for k, v in item.items(): - if isinstance(v, dict): - if first: - lines.append(f"{pad} - {k}:") - else: - lines.append(f"{pad} {k}:") - for ik, iv in v.items(): - lines.append(f"{pad} {ik}: {_yaml_value(iv)}") - else: - prefix = f"{pad} - " if first else f"{pad} " - lines.append(f"{prefix}{k}: {_yaml_value(v)}") - first = False - else: - lines.append(f"{pad} - {_yaml_value(item)}") - elif isinstance(value, dict): - lines.append(f"{pad}{key}:") - lines.append(_dump_yaml(value, indent + 1)) - else: - lines.append(f"{pad}{key}: {_yaml_value(value)}") - return "\n".join(lines) - - -def _parse_yaml(text: str) -> dict: - """ - Minimal YAML parser for Espanso base.yml. - - Handles top-level keys, lists of dicts, and scalar values. - Falls back gracefully to an empty dict on parse errors. - """ - try: - import yaml # noqa: PLC0415 - - return yaml.safe_load(text) or {} - except ImportError: - pass - - # Stdlib fallback — parses the specific structure Espanso uses - result: dict = {} - current_list: list | None = None - current_item: dict | None = None - - for raw_line in text.splitlines(): - line = raw_line.rstrip() - if not line or line.lstrip().startswith("#"): - continue - - stripped = line.lstrip() - depth = len(line) - len(stripped) - - # Top-level key (depth 0): "key:" or "key: value" - if depth == 0 and not stripped.startswith("-"): - if ":" in stripped: - k, _, v = stripped.partition(":") - k = k.strip() - v = v.strip() - if v: - result[k] = _cast(v) - current_list = None - current_item = None - else: - current_list = [] - result[k] = current_list - current_item = None - continue - - # List item opener: " - key: value" - if stripped.startswith("- ") and current_list is not None: - kv = stripped[2:].strip() - current_item = {} - current_list.append(current_item) - if ":" in kv: - k, _, v = kv.partition(":") - k = k.strip() - v = v.strip() - if v: - current_item[k] = _cast(v) - else: - current_item[k] = {} - continue - - # Continuation key under a list item: " key: value" - if current_item is not None and ":" in stripped and not stripped.startswith("-"): - k, _, v = stripped.partition(":") - k = k.strip() - v = v.strip() - if not v: - current_item[k] = {} - else: - last_val = list(current_item.values())[-1] if current_item else None - if isinstance(last_val, dict) and depth >= 6: - last_val[k] = _cast(v) - else: - current_item[k] = _cast(v) - continue - - return result - - -def _cast(v: str): - """Cast a YAML scalar string to the appropriate Python type.""" - v = v.strip().strip('"').strip("'") - if v.lower() == "true": - return True - if v.lower() == "false": - return False - if v.lower() == "null": - return None - try: - return int(v) - except ValueError: - pass - try: - return float(v) - except ValueError: - pass - return v - - -# --------------------------------------------------------------------------- -# File helpers -# --------------------------------------------------------------------------- - - -def get_base_yml_path() -> Path: - """Return the canonical path to Espanso's base.yml on Windows.""" - appdata = os.environ.get("APPDATA", "") - if not appdata: - raise EnvironmentError("APPDATA environment variable is not set.") - return Path(appdata) / "espanso" / "match" / "base.yml" - - -def is_espanso_installed() -> bool: - """Espanso is considered installed when its config directory exists.""" - appdata = os.environ.get("APPDATA", "") - if not appdata: - return False - return (Path(appdata) / "espanso").exists() - - -def read_config(path: Path) -> dict: - """Read a YAML config file; return empty dict if missing or unreadable.""" - if not path.exists(): - log(f"config not found, starting empty: {path}") - return {} - text = path.read_text(encoding="utf-8") - return _parse_yaml(text) - - -def write_config(path: Path, data: dict) -> None: - """Write *data* as YAML to *path*, creating parent directories if needed.""" - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(_dump_yaml(data) + "\n", encoding="utf-8") - log(f"wrote config to {path}") - - -# --------------------------------------------------------------------------- -# Merge logic -# --------------------------------------------------------------------------- - - -def deep_merge_lists(existing: list, incoming: list, key: str = "trigger") -> list: - """ - Merge two lists of dicts by *key*. - - Incoming items replace existing items with the same key value. - Existing items not present in incoming are preserved. - New items in incoming are appended. - """ - merged = deepcopy(existing) - index = {item.get(key): i for i, item in enumerate(merged) if isinstance(item, dict)} - for item in incoming: - if not isinstance(item, dict): - continue - k = item.get(key) - if k is not None and k in index: - merged[index[k]] = deepcopy(item) - else: - merged.append(deepcopy(item)) - return merged - - -def merge_config(existing: dict, incoming: dict) -> tuple[dict, bool]: - """ - Deep-merge *incoming* into *existing*. - - Returns (merged_config, changed). - """ - merged = deepcopy(existing) - changed = False - - if "matches" in incoming: - old = merged.get("matches", []) - new = deep_merge_lists(old, incoming["matches"], key="trigger") - if new != old: - merged["matches"] = new - changed = True - - if "global_vars" in incoming: - old = merged.get("global_vars", []) - new = deep_merge_lists(old, incoming["global_vars"], key="name") - if new != old: - merged["global_vars"] = new - changed = True - - return merged, changed - - -# --------------------------------------------------------------------------- -# Command handlers -# --------------------------------------------------------------------------- - - -def handle_check_installed(request_id: str, _args: dict) -> dict: - installed = is_espanso_installed() - log(f"check_installed → installed={installed}") - return { - "requestId": request_id, - "success": True, - "changed": False, - "data": {"installed": installed}, - } - - -def handle_apply(request_id: str, args: dict, dry_run: bool = False) -> dict: - base_path = get_base_yml_path() - existing = read_config(base_path) - merged, changed = merge_config(existing, args) - - if dry_run: - log(f"[dry-run] apply → changed={changed} (file NOT written)") - else: - if changed: - write_config(base_path, merged) - else: - log("apply → no changes, file left untouched") - - return { - "requestId": request_id, - "success": True, - "changed": changed, - } - - -# --------------------------------------------------------------------------- -# Single-shot JSON-over-stdio protocol -# --------------------------------------------------------------------------- - - -def main() -> None: - try: - msg = json.loads(sys.stdin.read()) - except json.JSONDecodeError as exc: - sys.stdout.write( - json.dumps( - { - "requestId": None, - "success": False, - "changed": False, - "error": f"Invalid JSON: {exc}", - } - ) - + "\n" - ) - sys.stdout.flush() - return - - request_id = msg.get("requestId", "") - command = msg.get("command", "") - args = msg.get("args", {}) - dry_run = bool(msg.get("context", {}).get("dryRun", False)) - - log(f"command={command} requestId={request_id} dryRun={dry_run}") - - try: - if command == "check_installed": - response = handle_check_installed(request_id, args) - elif command == "apply": - response = handle_apply(request_id, args, dry_run=dry_run) - else: - response = { - "requestId": request_id, - "success": False, - "changed": False, - "error": f"unknown command: {command!r}", - } - except Exception as exc: # noqa: BLE001 - log(f"unhandled error: {exc}") - response = { - "requestId": request_id, - "success": False, - "changed": False, - "error": str(exc), - } - - sys.stdout.write(json.dumps(response) + "\n") - sys.stdout.flush() - - -if __name__ == "__main__": - main() +""" +Espanso plugin for WinHome. + +Manages Espanso text expander configuration on Windows. +Config location: %APPDATA%\\espanso\\match\\base.yml + +Commands: + - check_installed: Verify Espanso is installed + - apply: Deep-merge matches/global_vars into base.yml +""" + +import json +import os +import sys +from copy import deepcopy +from pathlib import Path + +# --------------------------------------------------------------------------- +# Logging — simple stderr helper, matching other WinHome plugins +# --------------------------------------------------------------------------- + + +def log(msg: str) -> None: + print(f"[espanso] {msg}", file=sys.stderr, flush=True) + + +# --------------------------------------------------------------------------- +# YAML helpers (stdlib-only, no PyYAML dependency) +# --------------------------------------------------------------------------- + + +def _yaml_value(v) -> str: + """Serialise a scalar Python value to a YAML-safe string.""" + if isinstance(v, bool): + return "true" if v else "false" + if isinstance(v, (int, float)): + return str(v) + s = str(v) + if any( + c in s + for c in ( + ":", + "#", + "[", + "]", + "{", + "}", + ",", + "&", + "*", + "?", + "|", + "-", + "<", + ">", + "=", + "!", + "%", + "@", + "`", + '"', + "'", + ) + ): + escaped = s.replace('"', '\\"') + return f'"{escaped}"' + if s in ("true", "false", "null", "yes", "no", "on", "off", ""): + return f'"{s}"' + return s + + +def _dump_yaml(data: dict, indent: int = 0) -> str: + """ + Minimal recursive YAML serialiser. + + Handles the subset used by Espanso base.yml: + top-level keys, lists of dicts, and scalar values. + """ + lines = [] + pad = " " * indent + for key, value in data.items(): + if isinstance(value, list): + lines.append(f"{pad}{key}:") + for item in value: + if isinstance(item, dict): + first = True + for k, v in item.items(): + if isinstance(v, dict): + if first: + lines.append(f"{pad} - {k}:") + else: + lines.append(f"{pad} {k}:") + for ik, iv in v.items(): + lines.append(f"{pad} {ik}: {_yaml_value(iv)}") + else: + prefix = f"{pad} - " if first else f"{pad} " + lines.append(f"{prefix}{k}: {_yaml_value(v)}") + first = False + else: + lines.append(f"{pad} - {_yaml_value(item)}") + elif isinstance(value, dict): + lines.append(f"{pad}{key}:") + lines.append(_dump_yaml(value, indent + 1)) + else: + lines.append(f"{pad}{key}: {_yaml_value(value)}") + return "\n".join(lines) + + +def _parse_yaml(text: str) -> dict: + """ + Minimal YAML parser for Espanso base.yml. + + Handles top-level keys, lists of dicts, and scalar values. + Falls back gracefully to an empty dict on parse errors. + """ + try: + import yaml # noqa: PLC0415 + + return yaml.safe_load(text) or {} + except ImportError: + pass + + # Stdlib fallback — parses the specific structure Espanso uses + result: dict = {} + current_list: list | None = None + current_item: dict | None = None + + for raw_line in text.splitlines(): + line = raw_line.rstrip() + if not line or line.lstrip().startswith("#"): + continue + + stripped = line.lstrip() + depth = len(line) - len(stripped) + + # Top-level key (depth 0): "key:" or "key: value" + if depth == 0 and not stripped.startswith("-"): + if ":" in stripped: + k, _, v = stripped.partition(":") + k = k.strip() + v = v.strip() + if v: + result[k] = _cast(v) + current_list = None + current_item = None + else: + current_list = [] + result[k] = current_list + current_item = None + continue + + # List item opener: " - key: value" + if stripped.startswith("- ") and current_list is not None: + kv = stripped[2:].strip() + current_item = {} + current_list.append(current_item) + if ":" in kv: + k, _, v = kv.partition(":") + k = k.strip() + v = v.strip() + if v: + current_item[k] = _cast(v) + else: + current_item[k] = {} + continue + + # Continuation key under a list item: " key: value" + if ( + current_item is not None + and ":" in stripped + and not stripped.startswith("-") + ): + k, _, v = stripped.partition(":") + k = k.strip() + v = v.strip() + if not v: + current_item[k] = {} + else: + last_val = list(current_item.values())[-1] if current_item else None + if isinstance(last_val, dict) and depth >= 6: + last_val[k] = _cast(v) + else: + current_item[k] = _cast(v) + continue + + return result + + +def _cast(v: str): + """Cast a YAML scalar string to the appropriate Python type.""" + v = v.strip().strip('"').strip("'") + if v.lower() == "true": + return True + if v.lower() == "false": + return False + if v.lower() == "null": + return None + try: + return int(v) + except ValueError: + pass + try: + return float(v) + except ValueError: + pass + return v + + +# --------------------------------------------------------------------------- +# File helpers +# --------------------------------------------------------------------------- + + +def get_base_yml_path() -> Path: + """Return the canonical path to Espanso's base.yml on Windows.""" + appdata = os.environ.get("APPDATA", "") + if not appdata: + raise EnvironmentError("APPDATA environment variable is not set.") + return Path(appdata) / "espanso" / "match" / "base.yml" + + +def is_espanso_installed() -> bool: + """Espanso is considered installed when its config directory exists.""" + appdata = os.environ.get("APPDATA", "") + if not appdata: + return False + return (Path(appdata) / "espanso").exists() + + +def read_config(path: Path) -> dict: + """Read a YAML config file; return empty dict if missing or unreadable.""" + if not path.exists(): + log(f"config not found, starting empty: {path}") + return {} + text = path.read_text(encoding="utf-8") + return _parse_yaml(text) + + +def write_config(path: Path, data: dict) -> None: + """Write *data* as YAML to *path*, creating parent directories if needed.""" + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(_dump_yaml(data) + "\n", encoding="utf-8") + log(f"wrote config to {path}") + + +# --------------------------------------------------------------------------- +# Merge logic +# --------------------------------------------------------------------------- + + +def deep_merge_lists(existing: list, incoming: list, key: str = "trigger") -> list: + """ + Merge two lists of dicts by *key*. + + Incoming items replace existing items with the same key value. + Existing items not present in incoming are preserved. + New items in incoming are appended. + """ + merged = deepcopy(existing) + index = { + item.get(key): i for i, item in enumerate(merged) if isinstance(item, dict) + } + for item in incoming: + if not isinstance(item, dict): + continue + k = item.get(key) + if k is not None and k in index: + merged[index[k]] = deepcopy(item) + else: + merged.append(deepcopy(item)) + return merged + + +def merge_config(existing: dict, incoming: dict) -> tuple[dict, bool]: + """ + Deep-merge *incoming* into *existing*. + + Returns (merged_config, changed). + """ + merged = deepcopy(existing) + changed = False + + if "matches" in incoming: + old = merged.get("matches", []) + new = deep_merge_lists(old, incoming["matches"], key="trigger") + if new != old: + merged["matches"] = new + changed = True + + if "global_vars" in incoming: + old = merged.get("global_vars", []) + new = deep_merge_lists(old, incoming["global_vars"], key="name") + if new != old: + merged["global_vars"] = new + changed = True + + return merged, changed + + +# --------------------------------------------------------------------------- +# Command handlers +# --------------------------------------------------------------------------- + + +def handle_check_installed(request_id: str, _args: dict) -> dict: + installed = is_espanso_installed() + log(f"check_installed → installed={installed}") + return { + "requestId": request_id, + "success": True, + "changed": False, + "data": {"installed": installed}, + } + + +def handle_apply(request_id: str, args: dict, dry_run: bool = False) -> dict: + base_path = get_base_yml_path() + existing = read_config(base_path) + merged, changed = merge_config(existing, args) + + if dry_run: + log(f"[dry-run] apply → changed={changed} (file NOT written)") + else: + if changed: + write_config(base_path, merged) + else: + log("apply → no changes, file left untouched") + + return { + "requestId": request_id, + "success": True, + "changed": changed, + } + + +# --------------------------------------------------------------------------- +# Single-shot JSON-over-stdio protocol +# --------------------------------------------------------------------------- + + +def main() -> None: + try: + msg = json.loads(sys.stdin.read()) + except json.JSONDecodeError as exc: + sys.stdout.write( + json.dumps( + { + "requestId": None, + "success": False, + "changed": False, + "error": f"Invalid JSON: {exc}", + } + ) + + "\n" + ) + sys.stdout.flush() + return + + request_id = msg.get("requestId", "") + command = msg.get("command", "") + args = msg.get("args", {}) + dry_run = bool(msg.get("context", {}).get("dryRun", False)) + + log(f"command={command} requestId={request_id} dryRun={dry_run}") + + try: + if command == "check_installed": + response = handle_check_installed(request_id, args) + elif command == "apply": + response = handle_apply(request_id, args, dry_run=dry_run) + else: + response = { + "requestId": request_id, + "success": False, + "changed": False, + "error": f"unknown command: {command!r}", + } + except Exception as exc: # noqa: BLE001 + log(f"unhandled error: {exc}") + response = { + "requestId": request_id, + "success": False, + "changed": False, + "error": str(exc), + } + + sys.stdout.write(json.dumps(response) + "\n") + sys.stdout.flush() + + +if __name__ == "__main__": + main() diff --git a/plugins/espanso/test/test_espanso.py b/plugins/espanso/test/test_espanso.py index 9ddc250d..ed18be69 100644 --- a/plugins/espanso/test/test_espanso.py +++ b/plugins/espanso/test/test_espanso.py @@ -146,7 +146,9 @@ def test_merge_lists_preserves_untouched(): {"trigger": ":email", "replace": "me@example.com"}, {"trigger": ":hello", "replace": "Hello!"}, ] - result = plugin.deep_merge_lists(existing, [{"trigger": ":email", "replace": "new@example.com"}]) + result = plugin.deep_merge_lists( + existing, [{"trigger": ":email", "replace": "new@example.com"}] + ) assert len(result) == 2 assert result[1]["trigger"] == ":hello" @@ -182,7 +184,9 @@ def test_merge_config_no_change(existing_config): def test_merge_config_does_not_mutate(existing_config): original = deepcopy(existing_config) - plugin.merge_config(existing_config, {"matches": [{"trigger": ":new", "replace": "x"}]}) + plugin.merge_config( + existing_config, {"matches": [{"trigger": ":new", "replace": "x"}]} + ) assert existing_config == original @@ -238,7 +242,9 @@ def test_handle_apply_writes_file(tmp_path, monkeypatch): def test_handle_apply_dry_run_no_write(tmp_path, monkeypatch): base_yml = tmp_path / "espanso" / "match" / "base.yml" monkeypatch.setattr(plugin, "get_base_yml_path", lambda: base_yml) - result = plugin.handle_apply("req-4", {"matches": [{"trigger": ":t", "replace": "x"}]}, dry_run=True) + result = plugin.handle_apply( + "req-4", {"matches": [{"trigger": ":t", "replace": "x"}]}, dry_run=True + ) assert result == {"requestId": "req-4", "success": True, "changed": True} assert not base_yml.exists() @@ -271,7 +277,9 @@ def test_handle_apply_merges_not_overwrites(tmp_path, monkeypatch, existing_conf def test_handle_apply_creates_missing_dirs(tmp_path, monkeypatch): base_yml = tmp_path / "deep" / "nested" / "base.yml" monkeypatch.setattr(plugin, "get_base_yml_path", lambda: base_yml) - result = plugin.handle_apply("req-7", {"matches": [{"trigger": ":t", "replace": "test"}]}) + result = plugin.handle_apply( + "req-7", {"matches": [{"trigger": ":t", "replace": "test"}]} + ) assert result["success"] is True assert base_yml.exists() @@ -334,7 +342,9 @@ def test_protocol_dry_run_top_level_ignored(tmp_path, monkeypatch): "context": {}, } ) - assert base_yml.exists(), "top-level dry_run must be ignored; file should have been written" + assert ( + base_yml.exists() + ), "top-level dry_run must be ignored; file should have been written" def test_protocol_unknown_command(): @@ -344,7 +354,9 @@ def test_protocol_unknown_command(): def test_protocol_request_id_propagated(installed_appdata): - resp = run_main({"requestId": "my-unique-id", "command": "check_installed", "args": {}}) + resp = run_main( + {"requestId": "my-unique-id", "command": "check_installed", "args": {}} + ) assert resp["requestId"] == "my-unique-id" diff --git a/plugins/everything/src/plugin.py b/plugins/everything/src/plugin.py index 29f0aa02..30d5c15c 100644 --- a/plugins/everything/src/plugin.py +++ b/plugins/everything/src/plugin.py @@ -10,7 +10,12 @@ def handle_check_installed(request_id): - return {"requestId": request_id, "success": True, "changed": False, "data": EVERYTHING_DIR.exists()} + return { + "requestId": request_id, + "success": True, + "changed": False, + "data": EVERYTHING_DIR.exists(), + } def load_config(): @@ -73,7 +78,13 @@ def main(): if not raw: print( json.dumps( - {"requestId": None, "success": False, "changed": False, "data": None, "error": "empty input"} + { + "requestId": None, + "success": False, + "changed": False, + "data": None, + "error": "empty input", + } ) ) return @@ -92,12 +103,19 @@ def main(): result = handle_apply(args, context, request_id) else: - result = {"requestId": request_id, "success": False, "error": f"unknown command: {command}"} + result = { + "requestId": request_id, + "success": False, + "error": f"unknown command: {command}", + } print(json.dumps(result), flush=True) except Exception as error: - print(json.dumps({"requestId": None, "success": False, "error": str(error)}), flush=True) + print( + json.dumps({"requestId": None, "success": False, "error": str(error)}), + flush=True, + ) if __name__ == "__main__": diff --git a/plugins/everything/test/test_everything.py b/plugins/everything/test/test_everything.py index 81e5a5ec..785381c4 100644 --- a/plugins/everything/test/test_everything.py +++ b/plugins/everything/test/test_everything.py @@ -3,12 +3,18 @@ import subprocess import sys -PLUGIN = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "src", "plugin.py")) +PLUGIN = os.path.abspath( + os.path.join(os.path.dirname(__file__), "..", "src", "plugin.py") +) def run_plugin(payload: dict): process = subprocess.Popen( - [sys.executable, PLUGIN], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True + [sys.executable, PLUGIN], + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, ) stdout, stderr = process.communicate(json.dumps(payload)) diff --git a/plugins/flow-launcher/src/plugin.py b/plugins/flow-launcher/src/plugin.py index 358ffe97..3cab3fcf 100644 --- a/plugins/flow-launcher/src/plugin.py +++ b/plugins/flow-launcher/src/plugin.py @@ -93,14 +93,18 @@ def check_installed(args): def main(): input_data = sys.stdin.read() if not input_data: - sys.stdout.write(json.dumps({"requestId": "unknown", "error": "No input received"}) + "\n") + sys.stdout.write( + json.dumps({"requestId": "unknown", "error": "No input received"}) + "\n" + ) return try: request = json.loads(input_data) except Exception as e: log(f"Failed to parse request: {e}") - sys.stdout.write(json.dumps({"requestId": "unknown", "error": "Invalid JSON"}) + "\n") + sys.stdout.write( + json.dumps({"requestId": "unknown", "error": "Invalid JSON"}) + "\n" + ) return command = request.get("command") diff --git a/plugins/flow-launcher/test/test_plugin.py b/plugins/flow-launcher/test/test_plugin.py index 0e34d898..43b1641a 100644 --- a/plugins/flow-launcher/test/test_plugin.py +++ b/plugins/flow-launcher/test/test_plugin.py @@ -42,7 +42,11 @@ def test_check_installed_false(): def test_apply_dry_run_changed(): - request = {"command": "apply", "requestId": "125", "args": {"settings": {"Hotkey": "Alt+Space"}, "dryRun": True}} + request = { + "command": "apply", + "requestId": "125", + "args": {"settings": {"Hotkey": "Alt+Space"}, "dryRun": True}, + } existing_settings = '{"Hotkey": "Ctrl+Space"}' with ( patch("sys.stdin", StringIO(json.dumps(request))), @@ -58,7 +62,11 @@ def test_apply_dry_run_changed(): def test_apply_no_changes(): - request = {"command": "apply", "requestId": "126", "args": {"settings": {"Hotkey": "Alt+Space"}, "dryRun": False}} + request = { + "command": "apply", + "requestId": "126", + "args": {"settings": {"Hotkey": "Alt+Space"}, "dryRun": False}, + } existing_settings = '{"Hotkey": "Alt+Space"}' with ( patch("sys.stdin", StringIO(json.dumps(request))), @@ -77,7 +85,10 @@ def test_apply_writes_changes(): request = { "command": "apply", "requestId": "127", - "args": {"settings": {"Hotkey": "Alt+Space", "Plugins": {"Search": True}}, "dryRun": False}, + "args": { + "settings": {"Hotkey": "Alt+Space", "Plugins": {"Search": True}}, + "dryRun": False, + }, } existing_settings = '{"Hotkey": "Ctrl+Space", "Plugins": {"Search": false}}' @@ -102,7 +113,9 @@ def test_apply_writes_changes(): # Verify write was called written_data = "".join( - call.args[0] for call in mock_write().write.call_args_list if isinstance(call.args[0], str) + call.args[0] + for call in mock_write().write.call_args_list + if isinstance(call.args[0], str) ) written_json = json.loads(written_data) assert written_json["Hotkey"] == "Alt+Space" diff --git a/plugins/fzf/src/plugin.py b/plugins/fzf/src/plugin.py index a4087203..33fbc435 100644 --- a/plugins/fzf/src/plugin.py +++ b/plugins/fzf/src/plugin.py @@ -1,316 +1,331 @@ -import json -import os -import shlex -import shutil -import sys -import tempfile -import uuid -from pathlib import Path -from typing import Any, Dict, Tuple - -SUPPORTED_OPTION_SETTINGS = { - "height", - "border", - "preview", - "color", - "bind", - "layout", -} - - -def log(msg: str) -> None: - sys.stderr.write(f"[fzf-plugin] {msg}\n") - sys.stderr.flush() - - -def response( - request_id: str, - success: bool, - changed: bool, - data: Any, - error: str | None = None, -) -> Dict[str, Any]: - payload: Dict[str, Any] = { - "requestId": request_id, - "success": success, - "changed": changed, - "data": data, - } - if error is not None: - payload["error"] = error - return payload - - -def get_fzfrc_path() -> str: - if os.name == "nt": - user_profile = os.getenv("USERPROFILE") - if not user_profile: - raise RuntimeError("USERPROFILE environment variable not set") - return os.path.join(user_profile, "_fzfrc") - return str(Path.home() / ".fzfrc") - - -def check_installed(args: Dict[str, Any], request_id: str) -> Dict[str, Any]: - executable = "fzf.exe" if os.name == "nt" else "fzf" - installed = shutil.which(executable) is not None - return response(request_id, True, False, installed) - - -def stringify_value(value: Any) -> str: - if isinstance(value, bool): - return "true" if value else "false" - return str(value) - - -def parse_export_line(line: str) -> Tuple[str, str]: - if not line.startswith("export "): - raise ValueError("expected export KEY=value") - - assignment = line[len("export ") :].strip() - if "=" not in assignment: - raise ValueError("expected export KEY=value") - - key, raw_value = assignment.split("=", 1) - if not key or not key.replace("_", "A").isalnum() or key[0].isdigit(): - raise ValueError(f"invalid export key: {key}") - return key, parse_shell_value(raw_value.strip()) - - -def parse_shell_value(raw_value: str) -> str: - if not raw_value: - return "" - - if raw_value[0] == "'": - end = raw_value.find("'", 1) - if end == -1: - raise ValueError("unterminated single-quoted value") - if raw_value[end + 1 :].strip(): - raise ValueError("unexpected text after quoted value") - return raw_value[1:end] - - if raw_value[0] != '"': - try: - parts = shlex.split(raw_value, posix=True) - except ValueError as exc: - raise ValueError(f"invalid shell syntax: {exc}") from exc - if len(parts) != 1: - raise ValueError("expected a single shell value") - return parts[0] - - value = [] - escaped = False - for index, char in enumerate(raw_value[1:], start=1): - if escaped: - if char in {"\\", '"', "$", "`"}: - value.append(char) - else: - value.append("\\") - value.append(char) - escaped = False - continue - if char == "\\": - escaped = True - continue - if char == '"': - if raw_value[index + 1 :].strip(): - raise ValueError("unexpected text after quoted value") - return "".join(value) - value.append(char) - - raise ValueError("unterminated double-quoted value") - - -def read_fzfrc(file_path: str) -> Tuple[Dict[str, str], bool]: - config: Dict[str, str] = {} - if not os.path.exists(file_path): - return config, False - - try: - with open(file_path, "r", encoding="utf-8") as f: - for line_number, raw_line in enumerate(f, start=1): - line = raw_line.strip() - if not line or line.startswith("#"): - continue - if not line.startswith("export "): - raise ValueError(f"line {line_number}: expected export statement") - key, value = parse_export_line(line) - config[key] = value - except (OSError, UnicodeError, ValueError) as exc: - log(f"Warning: failed to parse existing config ({exc}). Backing up and starting fresh.") - return {}, True - - return config, False - - -def shell_quote(value: str) -> str: - if "\n" in value or "\r" in value: - raise ValueError("fzf config values cannot contain newlines") - escaped = value.replace("\\", "\\\\").replace('"', '\\"').replace("$", "\\$").replace("`", "\\`") - return f'"{escaped}"' - - -def write_fzfrc(file_path: str, config: Dict[str, str]) -> None: - parent = os.path.dirname(file_path) - if parent: - os.makedirs(parent, exist_ok=True) - - fd, temp_path = tempfile.mkstemp(prefix=".fzfrc.", suffix=".tmp", dir=parent or None, text=True) - try: - with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as f: - for key in sorted(config): - f.write(f"export {key}={shell_quote(config[key])}\n") - os.replace(temp_path, file_path) - except Exception: - try: - os.remove(temp_path) - except OSError: - pass - raise - - -def backup_corrupted_config(file_path: str) -> str: - backup_path = f"{file_path}.bak.{uuid.uuid4()}" - shutil.copy2(file_path, backup_path) - return backup_path - - -def build_default_opts(settings: Dict[str, Any], existing_value: str | None) -> str | None: - explicit = settings.get("FZF_DEFAULT_OPTS") - if explicit is not None: - opts = stringify_value(explicit).strip() - else: - opts = (existing_value or "").strip() - - generated = [] - for key in sorted(SUPPORTED_OPTION_SETTINGS): - if key not in settings: - continue - value = stringify_value(settings[key]) - generated.append(f"--{key} {value}") - - if generated: - opts = " ".join(part for part in [opts, *generated] if part).strip() - return opts or None - - -def merge_config(target: Dict[str, str], settings: Dict[str, Any]) -> bool: - changed = False - - default_opts = build_default_opts(settings, target.get("FZF_DEFAULT_OPTS")) - if default_opts is not None and target.get("FZF_DEFAULT_OPTS") != default_opts: - target["FZF_DEFAULT_OPTS"] = default_opts - changed = True - - for key, value in settings.items(): - if key in SUPPORTED_OPTION_SETTINGS or key == "FZF_DEFAULT_OPTS": - continue - if not key.startswith("FZF_"): - continue - string_value = stringify_value(value) - if target.get(key) != string_value: - target[key] = string_value - changed = True - - return changed - - -def apply_config(args: Dict[str, Any], context: Dict[str, Any], request_id: str) -> Dict[str, Any]: - dry_run = context.get("dryRun", False) is True - - try: - fzfrc_path = get_fzfrc_path() - settings = args.get("settings", {}) - if not isinstance(settings, dict): - raise ValueError("args.settings must be an object") - - current, corrupted = read_fzfrc(fzfrc_path) - changed = corrupted - changed = merge_config(current, settings) or changed - - data: Dict[str, Any] = { - "path": fzfrc_path, - "settings": current, - "corrupted": corrupted, - } - - if not changed: - return response(request_id, True, False, data) - - if dry_run: - log(f"Would update {fzfrc_path} with: {json.dumps(settings)}") - if corrupted: - log(f"Would back up corrupted config: {fzfrc_path}") - return response(request_id, True, True, data) - - if corrupted and os.path.exists(fzfrc_path): - backup_path = backup_corrupted_config(fzfrc_path) - data["backupPath"] = backup_path - log(f"Backed up corrupted fzf config: {backup_path}") - - write_fzfrc(fzfrc_path, current) - log(f"Updated fzf config: {fzfrc_path}") - return response(request_id, True, True, data) - - except Exception as exc: - log(f"Failed to apply config: {exc}") - return response(request_id, False, False, {}, str(exc)) - - -def dispatch(request: Dict[str, Any]) -> Dict[str, Any]: - request_id = request.get("requestId", "unknown") - command = request.get("command") - args = request.get("args", {}) - context = request.get("context", {}) - - if not isinstance(args, dict): - return response(request_id, False, False, {}, "args must be an object") - if not isinstance(context, dict): - return response(request_id, False, False, {}, "context must be an object") - - try: - if command == "check_installed": - return check_installed(args, request_id) - if command == "apply": - return apply_config(args, context, request_id) - return response(request_id, False, False, {}, f"Unknown command: {command}") - except Exception as exc: - return response(request_id, False, False, {}, f"Internal Script Error: {exc}") - - -def main() -> None: - input_data = sys.stdin.read() - if not input_data: - sys.stdout.write(json.dumps(response("unknown", False, False, {}, "Empty stdin")) + "\n") - sys.stdout.flush() - return - - try: - request = json.loads(input_data) - except Exception as exc: - log(f"Failed to parse request: {exc}") - sys.stdout.write( - json.dumps( - response( - "unknown", - False, - False, - {}, - f"Failed to parse request: {exc}", - ) - ) - + "\n" - ) - sys.stdout.flush() - return - - if not isinstance(request, dict): - payload = response("unknown", False, False, {}, "Request must be a JSON object") - else: - payload = dispatch(request) - - sys.stdout.write(json.dumps(payload) + "\n") - sys.stdout.flush() - - -if __name__ == "__main__": - main() +import json +import os +import shlex +import shutil +import sys +import tempfile +import uuid +from pathlib import Path +from typing import Any, Dict, Tuple + +SUPPORTED_OPTION_SETTINGS = { + "height", + "border", + "preview", + "color", + "bind", + "layout", +} + + +def log(msg: str) -> None: + sys.stderr.write(f"[fzf-plugin] {msg}\n") + sys.stderr.flush() + + +def response( + request_id: str, + success: bool, + changed: bool, + data: Any, + error: str | None = None, +) -> Dict[str, Any]: + payload: Dict[str, Any] = { + "requestId": request_id, + "success": success, + "changed": changed, + "data": data, + } + if error is not None: + payload["error"] = error + return payload + + +def get_fzfrc_path() -> str: + if os.name == "nt": + user_profile = os.getenv("USERPROFILE") + if not user_profile: + raise RuntimeError("USERPROFILE environment variable not set") + return os.path.join(user_profile, "_fzfrc") + return str(Path.home() / ".fzfrc") + + +def check_installed(args: Dict[str, Any], request_id: str) -> Dict[str, Any]: + executable = "fzf.exe" if os.name == "nt" else "fzf" + installed = shutil.which(executable) is not None + return response(request_id, True, False, installed) + + +def stringify_value(value: Any) -> str: + if isinstance(value, bool): + return "true" if value else "false" + return str(value) + + +def parse_export_line(line: str) -> Tuple[str, str]: + if not line.startswith("export "): + raise ValueError("expected export KEY=value") + + assignment = line[len("export ") :].strip() + if "=" not in assignment: + raise ValueError("expected export KEY=value") + + key, raw_value = assignment.split("=", 1) + if not key or not key.replace("_", "A").isalnum() or key[0].isdigit(): + raise ValueError(f"invalid export key: {key}") + return key, parse_shell_value(raw_value.strip()) + + +def parse_shell_value(raw_value: str) -> str: + if not raw_value: + return "" + + if raw_value[0] == "'": + end = raw_value.find("'", 1) + if end == -1: + raise ValueError("unterminated single-quoted value") + if raw_value[end + 1 :].strip(): + raise ValueError("unexpected text after quoted value") + return raw_value[1:end] + + if raw_value[0] != '"': + try: + parts = shlex.split(raw_value, posix=True) + except ValueError as exc: + raise ValueError(f"invalid shell syntax: {exc}") from exc + if len(parts) != 1: + raise ValueError("expected a single shell value") + return parts[0] + + value = [] + escaped = False + for index, char in enumerate(raw_value[1:], start=1): + if escaped: + if char in {"\\", '"', "$", "`"}: + value.append(char) + else: + value.append("\\") + value.append(char) + escaped = False + continue + if char == "\\": + escaped = True + continue + if char == '"': + if raw_value[index + 1 :].strip(): + raise ValueError("unexpected text after quoted value") + return "".join(value) + value.append(char) + + raise ValueError("unterminated double-quoted value") + + +def read_fzfrc(file_path: str) -> Tuple[Dict[str, str], bool]: + config: Dict[str, str] = {} + if not os.path.exists(file_path): + return config, False + + try: + with open(file_path, "r", encoding="utf-8") as f: + for line_number, raw_line in enumerate(f, start=1): + line = raw_line.strip() + if not line or line.startswith("#"): + continue + if not line.startswith("export "): + raise ValueError(f"line {line_number}: expected export statement") + key, value = parse_export_line(line) + config[key] = value + except (OSError, UnicodeError, ValueError) as exc: + log( + f"Warning: failed to parse existing config ({exc}). Backing up and starting fresh." + ) + return {}, True + + return config, False + + +def shell_quote(value: str) -> str: + if "\n" in value or "\r" in value: + raise ValueError("fzf config values cannot contain newlines") + escaped = ( + value.replace("\\", "\\\\") + .replace('"', '\\"') + .replace("$", "\\$") + .replace("`", "\\`") + ) + return f'"{escaped}"' + + +def write_fzfrc(file_path: str, config: Dict[str, str]) -> None: + parent = os.path.dirname(file_path) + if parent: + os.makedirs(parent, exist_ok=True) + + fd, temp_path = tempfile.mkstemp( + prefix=".fzfrc.", suffix=".tmp", dir=parent or None, text=True + ) + try: + with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as f: + for key in sorted(config): + f.write(f"export {key}={shell_quote(config[key])}\n") + os.replace(temp_path, file_path) + except Exception: + try: + os.remove(temp_path) + except OSError: + pass + raise + + +def backup_corrupted_config(file_path: str) -> str: + backup_path = f"{file_path}.bak.{uuid.uuid4()}" + shutil.copy2(file_path, backup_path) + return backup_path + + +def build_default_opts( + settings: Dict[str, Any], existing_value: str | None +) -> str | None: + explicit = settings.get("FZF_DEFAULT_OPTS") + if explicit is not None: + opts = stringify_value(explicit).strip() + else: + opts = (existing_value or "").strip() + + generated = [] + for key in sorted(SUPPORTED_OPTION_SETTINGS): + if key not in settings: + continue + value = stringify_value(settings[key]) + generated.append(f"--{key} {value}") + + if generated: + opts = " ".join(part for part in [opts, *generated] if part).strip() + return opts or None + + +def merge_config(target: Dict[str, str], settings: Dict[str, Any]) -> bool: + changed = False + + default_opts = build_default_opts(settings, target.get("FZF_DEFAULT_OPTS")) + if default_opts is not None and target.get("FZF_DEFAULT_OPTS") != default_opts: + target["FZF_DEFAULT_OPTS"] = default_opts + changed = True + + for key, value in settings.items(): + if key in SUPPORTED_OPTION_SETTINGS or key == "FZF_DEFAULT_OPTS": + continue + if not key.startswith("FZF_"): + continue + string_value = stringify_value(value) + if target.get(key) != string_value: + target[key] = string_value + changed = True + + return changed + + +def apply_config( + args: Dict[str, Any], context: Dict[str, Any], request_id: str +) -> Dict[str, Any]: + dry_run = context.get("dryRun", False) is True + + try: + fzfrc_path = get_fzfrc_path() + settings = args.get("settings", {}) + if not isinstance(settings, dict): + raise ValueError("args.settings must be an object") + + current, corrupted = read_fzfrc(fzfrc_path) + changed = corrupted + changed = merge_config(current, settings) or changed + + data: Dict[str, Any] = { + "path": fzfrc_path, + "settings": current, + "corrupted": corrupted, + } + + if not changed: + return response(request_id, True, False, data) + + if dry_run: + log(f"Would update {fzfrc_path} with: {json.dumps(settings)}") + if corrupted: + log(f"Would back up corrupted config: {fzfrc_path}") + return response(request_id, True, True, data) + + if corrupted and os.path.exists(fzfrc_path): + backup_path = backup_corrupted_config(fzfrc_path) + data["backupPath"] = backup_path + log(f"Backed up corrupted fzf config: {backup_path}") + + write_fzfrc(fzfrc_path, current) + log(f"Updated fzf config: {fzfrc_path}") + return response(request_id, True, True, data) + + except Exception as exc: + log(f"Failed to apply config: {exc}") + return response(request_id, False, False, {}, str(exc)) + + +def dispatch(request: Dict[str, Any]) -> Dict[str, Any]: + request_id = request.get("requestId", "unknown") + command = request.get("command") + args = request.get("args", {}) + context = request.get("context", {}) + + if not isinstance(args, dict): + return response(request_id, False, False, {}, "args must be an object") + if not isinstance(context, dict): + return response(request_id, False, False, {}, "context must be an object") + + try: + if command == "check_installed": + return check_installed(args, request_id) + if command == "apply": + return apply_config(args, context, request_id) + return response(request_id, False, False, {}, f"Unknown command: {command}") + except Exception as exc: + return response(request_id, False, False, {}, f"Internal Script Error: {exc}") + + +def main() -> None: + input_data = sys.stdin.read() + if not input_data: + sys.stdout.write( + json.dumps(response("unknown", False, False, {}, "Empty stdin")) + "\n" + ) + sys.stdout.flush() + return + + try: + request = json.loads(input_data) + except Exception as exc: + log(f"Failed to parse request: {exc}") + sys.stdout.write( + json.dumps( + response( + "unknown", + False, + False, + {}, + f"Failed to parse request: {exc}", + ) + ) + + "\n" + ) + sys.stdout.flush() + return + + if not isinstance(request, dict): + payload = response("unknown", False, False, {}, "Request must be a JSON object") + else: + payload = dispatch(request) + + sys.stdout.write(json.dumps(payload) + "\n") + sys.stdout.flush() + + +if __name__ == "__main__": + main() diff --git a/plugins/fzf/test/test_plugin.py b/plugins/fzf/test/test_plugin.py index e6b77c8c..86d3271a 100644 --- a/plugins/fzf/test/test_plugin.py +++ b/plugins/fzf/test/test_plugin.py @@ -1,278 +1,285 @@ -import json -import os -import subprocess -import sys -from pathlib import Path - -PLUGIN = Path(__file__).resolve().parents[1] / "src" / "plugin.py" - - -def run_plugin(payload=None, env=None, raw_input=None): - input_data = raw_input if raw_input is not None else json.dumps(payload) - result = subprocess.run( - [sys.executable, str(PLUGIN)], - input=input_data, - capture_output=True, - text=True, - env=env, - ) - output = result.stdout.strip() - assert output - return json.loads(output), result - - -def plugin_env(tmp_path, path=None): - env = os.environ.copy() - env["USERPROFILE"] = str(tmp_path) - env["HOME"] = str(tmp_path) - if path is not None: - env["PATH"] = str(path) - return env - - -def fzfrc_path(tmp_path): - return tmp_path / ("_fzfrc" if os.name == "nt" else ".fzfrc") - - -def assert_schema(response, success=True, changed=False): - assert response["success"] is success - assert response["changed"] is changed - assert "requestId" in response - assert "data" in response - if success: - assert "error" not in response - else: - assert "error" in response - - -def test_apply_fzf_settings(tmp_path): - env = plugin_env(tmp_path) - payload = { - "requestId": "apply-1", - "command": "apply", - "args": { - "settings": { - "FZF_DEFAULT_OPTS": "--height 40% --border", - "FZF_CTRL_T_COMMAND": "fd --type f", - } - }, - "context": {"dryRun": False}, - } - - response, result = run_plugin(payload, env=env) - - assert_schema(response, success=True, changed=True) - config_file = fzfrc_path(tmp_path) - content = config_file.read_text(encoding="utf-8") - assert 'export FZF_DEFAULT_OPTS="--height 40% --border"' in content - assert 'export FZF_CTRL_T_COMMAND="fd --type f"' in content - assert "Updated fzf config" in result.stderr - - -def test_idempotent(tmp_path): - env = plugin_env(tmp_path) - payload = { - "requestId": "idem-1", - "command": "apply", - "args": {"settings": {"FZF_DEFAULT_OPTS": "--height 40% --border"}}, - } - - first, _ = run_plugin(payload, env=env) - second, _ = run_plugin(payload, env=env) - - assert_schema(first, success=True, changed=True) - assert_schema(second, success=True, changed=False) - - -def test_dry_run(tmp_path): - env = plugin_env(tmp_path) - config_file = fzfrc_path(tmp_path) - payload = { - "requestId": "dry-1", - "command": "apply", - "args": {"settings": {"FZF_DEFAULT_OPTS": "--height 40%"}}, - "context": {"dryRun": True}, - } - - response, result = run_plugin(payload, env=env) - - assert_schema(response, success=True, changed=True) - assert not config_file.exists() - assert "Would update" in result.stderr - - -def test_check_installed(tmp_path): - bin_dir = tmp_path / "bin" - bin_dir.mkdir() - executable = bin_dir / ("fzf.exe" if os.name == "nt" else "fzf") - executable.write_text("", encoding="utf-8") - executable.chmod(0o755) - - payload = {"requestId": "check-1", "command": "check_installed"} - installed, _ = run_plugin(payload, env=plugin_env(tmp_path, bin_dir)) - missing, _ = run_plugin(payload, env=plugin_env(tmp_path, tmp_path / "missing")) - - assert_schema(installed, success=True, changed=False) - assert installed["data"] is True - assert_schema(missing, success=True, changed=False) - assert missing["data"] is False - - -def test_empty_stdin(tmp_path): - response, _ = run_plugin(env=plugin_env(tmp_path), raw_input="") - - assert_schema(response, success=False, changed=False) - assert response["requestId"] == "unknown" - assert "Empty stdin" in response["error"] - - -def test_malformed_json(tmp_path): - response, result = run_plugin(env=plugin_env(tmp_path), raw_input="{ not json") - - assert result.returncode == 0 - assert_schema(response, success=False, changed=False) - assert response["requestId"] == "unknown" - assert "Failed to parse request" in response["error"] - assert "Failed to parse request" in result.stderr - - -def test_invalid_settings_shape(tmp_path): - payload = { - "requestId": "bad-settings-1", - "command": "apply", - "args": {"settings": "not-an-object"}, - } - - response, _ = run_plugin(payload, env=plugin_env(tmp_path)) - - assert_schema(response, success=False, changed=False) - assert response["error"] == "args.settings must be an object" - - -def test_parse_existing_config(tmp_path): - env = plugin_env(tmp_path) - config_file = fzfrc_path(tmp_path) - config_file.write_text( - '# comment\n\nexport FZF_CTRL_T_COMMAND="fd --type f"\nexport OTHER_TOOL="keep me"\n', - encoding="utf-8", - ) - payload = { - "requestId": "parse-1", - "command": "apply", - "args": {"settings": {"height": "40%", "border": True}}, - } - - response, _ = run_plugin(payload, env=env) - - assert_schema(response, success=True, changed=True) - content = config_file.read_text(encoding="utf-8") - assert 'export FZF_CTRL_T_COMMAND="fd --type f"' in content - assert 'export OTHER_TOOL="keep me"' in content - assert 'export FZF_DEFAULT_OPTS="--border true --height 40%"' in content - - -def test_corrupted_config_backup(tmp_path): - env = plugin_env(tmp_path) - config_file = fzfrc_path(tmp_path) - config_file.write_text('export FZF_DEFAULT_OPTS="unterminated\n', encoding="utf-8") - payload = { - "requestId": "corrupt-1", - "command": "apply", - "args": {"settings": {"FZF_DEFAULT_OPTS": "--height 60%"}}, - } - - response, result = run_plugin(payload, env=env) - - assert_schema(response, success=True, changed=True) - backups = list(tmp_path.glob(f"{config_file.name}.bak.*")) - assert len(backups) == 1 - assert backups[0].read_text(encoding="utf-8") == 'export FZF_DEFAULT_OPTS="unterminated\n' - assert 'export FZF_DEFAULT_OPTS="--height 60%"' in config_file.read_text(encoding="utf-8") - assert "Backed up corrupted fzf config" in result.stderr - assert response["data"]["corrupted"] is True - assert "backupPath" in response["data"] - - -def test_utf8_decode_failure_backup(tmp_path): - env = plugin_env(tmp_path) - config_file = fzfrc_path(tmp_path) - config_file.write_bytes(b"\xff\xfe\xfa") - payload = { - "requestId": "utf8-1", - "command": "apply", - "args": {"settings": {"FZF_DEFAULT_OPTS": "--height 70%"}}, - } - - response, _ = run_plugin(payload, env=env) - - assert_schema(response, success=True, changed=True) - backups = list(tmp_path.glob(f"{config_file.name}.bak.*")) - assert len(backups) == 1 - assert backups[0].read_bytes() == b"\xff\xfe\xfa" - assert 'export FZF_DEFAULT_OPTS="--height 70%"' in config_file.read_text(encoding="utf-8") - - -def test_shell_escaping_round_trip(tmp_path): - env = plugin_env(tmp_path) - value = '--preview "bat $FILE `pwd` C:\\tmp"' - payload = { - "requestId": "escape-1", - "command": "apply", - "args": {"settings": {"FZF_DEFAULT_OPTS": value}}, - } - - first, _ = run_plugin(payload, env=env) - second, _ = run_plugin(payload, env=env) - - assert_schema(first, success=True, changed=True) - assert_schema(second, success=True, changed=False) - content = fzfrc_path(tmp_path).read_text(encoding="utf-8") - assert '\\"bat \\$FILE \\`pwd\\` C:\\\\tmp\\"' in content - - -def test_filesystem_write_error_returns_json(tmp_path): - bad_home = tmp_path / "profile-as-file" - bad_home.write_text("not a directory", encoding="utf-8") - payload = { - "requestId": "write-error-1", - "command": "apply", - "args": {"settings": {"FZF_DEFAULT_OPTS": "--height 40%"}}, - } - - response, result = run_plugin(payload, env=plugin_env(bad_home)) - - assert result.returncode == 0 - assert_schema(response, success=False, changed=False) - assert response["data"] == {} - assert response["error"] - assert "Failed to apply config" in result.stderr - - -def test_dry_run_corrupted_config_does_not_backup_or_write(tmp_path): - env = plugin_env(tmp_path) - config_file = fzfrc_path(tmp_path) - original = 'export FZF_DEFAULT_OPTS="unterminated\n' - config_file.write_text(original, encoding="utf-8") - payload = { - "requestId": "dry-corrupt-1", - "command": "apply", - "args": {"settings": {"FZF_DEFAULT_OPTS": "--height 80%"}}, - "context": {"dryRun": True}, - } - - response, result = run_plugin(payload, env=env) - - assert_schema(response, success=True, changed=True) - assert response["data"]["corrupted"] is True - assert config_file.read_text(encoding="utf-8") == original - assert list(tmp_path.glob(f"{config_file.name}.bak.*")) == [] - assert "Would back up corrupted config" in result.stderr - - -def test_unknown_command(tmp_path): - payload = {"requestId": "unknown-1", "command": "unknown_cmd"} - - response, _ = run_plugin(payload, env=plugin_env(tmp_path)) - - assert_schema(response, success=False, changed=False) - assert response["error"] == "Unknown command: unknown_cmd" +import json +import os +import subprocess +import sys +from pathlib import Path + +PLUGIN = Path(__file__).resolve().parents[1] / "src" / "plugin.py" + + +def run_plugin(payload=None, env=None, raw_input=None): + input_data = raw_input if raw_input is not None else json.dumps(payload) + result = subprocess.run( + [sys.executable, str(PLUGIN)], + input=input_data, + capture_output=True, + text=True, + env=env, + ) + output = result.stdout.strip() + assert output + return json.loads(output), result + + +def plugin_env(tmp_path, path=None): + env = os.environ.copy() + env["USERPROFILE"] = str(tmp_path) + env["HOME"] = str(tmp_path) + if path is not None: + env["PATH"] = str(path) + return env + + +def fzfrc_path(tmp_path): + return tmp_path / ("_fzfrc" if os.name == "nt" else ".fzfrc") + + +def assert_schema(response, success=True, changed=False): + assert response["success"] is success + assert response["changed"] is changed + assert "requestId" in response + assert "data" in response + if success: + assert "error" not in response + else: + assert "error" in response + + +def test_apply_fzf_settings(tmp_path): + env = plugin_env(tmp_path) + payload = { + "requestId": "apply-1", + "command": "apply", + "args": { + "settings": { + "FZF_DEFAULT_OPTS": "--height 40% --border", + "FZF_CTRL_T_COMMAND": "fd --type f", + } + }, + "context": {"dryRun": False}, + } + + response, result = run_plugin(payload, env=env) + + assert_schema(response, success=True, changed=True) + config_file = fzfrc_path(tmp_path) + content = config_file.read_text(encoding="utf-8") + assert 'export FZF_DEFAULT_OPTS="--height 40% --border"' in content + assert 'export FZF_CTRL_T_COMMAND="fd --type f"' in content + assert "Updated fzf config" in result.stderr + + +def test_idempotent(tmp_path): + env = plugin_env(tmp_path) + payload = { + "requestId": "idem-1", + "command": "apply", + "args": {"settings": {"FZF_DEFAULT_OPTS": "--height 40% --border"}}, + } + + first, _ = run_plugin(payload, env=env) + second, _ = run_plugin(payload, env=env) + + assert_schema(first, success=True, changed=True) + assert_schema(second, success=True, changed=False) + + +def test_dry_run(tmp_path): + env = plugin_env(tmp_path) + config_file = fzfrc_path(tmp_path) + payload = { + "requestId": "dry-1", + "command": "apply", + "args": {"settings": {"FZF_DEFAULT_OPTS": "--height 40%"}}, + "context": {"dryRun": True}, + } + + response, result = run_plugin(payload, env=env) + + assert_schema(response, success=True, changed=True) + assert not config_file.exists() + assert "Would update" in result.stderr + + +def test_check_installed(tmp_path): + bin_dir = tmp_path / "bin" + bin_dir.mkdir() + executable = bin_dir / ("fzf.exe" if os.name == "nt" else "fzf") + executable.write_text("", encoding="utf-8") + executable.chmod(0o755) + + payload = {"requestId": "check-1", "command": "check_installed"} + installed, _ = run_plugin(payload, env=plugin_env(tmp_path, bin_dir)) + missing, _ = run_plugin(payload, env=plugin_env(tmp_path, tmp_path / "missing")) + + assert_schema(installed, success=True, changed=False) + assert installed["data"] is True + assert_schema(missing, success=True, changed=False) + assert missing["data"] is False + + +def test_empty_stdin(tmp_path): + response, _ = run_plugin(env=plugin_env(tmp_path), raw_input="") + + assert_schema(response, success=False, changed=False) + assert response["requestId"] == "unknown" + assert "Empty stdin" in response["error"] + + +def test_malformed_json(tmp_path): + response, result = run_plugin(env=plugin_env(tmp_path), raw_input="{ not json") + + assert result.returncode == 0 + assert_schema(response, success=False, changed=False) + assert response["requestId"] == "unknown" + assert "Failed to parse request" in response["error"] + assert "Failed to parse request" in result.stderr + + +def test_invalid_settings_shape(tmp_path): + payload = { + "requestId": "bad-settings-1", + "command": "apply", + "args": {"settings": "not-an-object"}, + } + + response, _ = run_plugin(payload, env=plugin_env(tmp_path)) + + assert_schema(response, success=False, changed=False) + assert response["error"] == "args.settings must be an object" + + +def test_parse_existing_config(tmp_path): + env = plugin_env(tmp_path) + config_file = fzfrc_path(tmp_path) + config_file.write_text( + '# comment\n\nexport FZF_CTRL_T_COMMAND="fd --type f"\nexport OTHER_TOOL="keep me"\n', + encoding="utf-8", + ) + payload = { + "requestId": "parse-1", + "command": "apply", + "args": {"settings": {"height": "40%", "border": True}}, + } + + response, _ = run_plugin(payload, env=env) + + assert_schema(response, success=True, changed=True) + content = config_file.read_text(encoding="utf-8") + assert 'export FZF_CTRL_T_COMMAND="fd --type f"' in content + assert 'export OTHER_TOOL="keep me"' in content + assert 'export FZF_DEFAULT_OPTS="--border true --height 40%"' in content + + +def test_corrupted_config_backup(tmp_path): + env = plugin_env(tmp_path) + config_file = fzfrc_path(tmp_path) + config_file.write_text('export FZF_DEFAULT_OPTS="unterminated\n', encoding="utf-8") + payload = { + "requestId": "corrupt-1", + "command": "apply", + "args": {"settings": {"FZF_DEFAULT_OPTS": "--height 60%"}}, + } + + response, result = run_plugin(payload, env=env) + + assert_schema(response, success=True, changed=True) + backups = list(tmp_path.glob(f"{config_file.name}.bak.*")) + assert len(backups) == 1 + assert ( + backups[0].read_text(encoding="utf-8") + == 'export FZF_DEFAULT_OPTS="unterminated\n' + ) + assert 'export FZF_DEFAULT_OPTS="--height 60%"' in config_file.read_text( + encoding="utf-8" + ) + assert "Backed up corrupted fzf config" in result.stderr + assert response["data"]["corrupted"] is True + assert "backupPath" in response["data"] + + +def test_utf8_decode_failure_backup(tmp_path): + env = plugin_env(tmp_path) + config_file = fzfrc_path(tmp_path) + config_file.write_bytes(b"\xff\xfe\xfa") + payload = { + "requestId": "utf8-1", + "command": "apply", + "args": {"settings": {"FZF_DEFAULT_OPTS": "--height 70%"}}, + } + + response, _ = run_plugin(payload, env=env) + + assert_schema(response, success=True, changed=True) + backups = list(tmp_path.glob(f"{config_file.name}.bak.*")) + assert len(backups) == 1 + assert backups[0].read_bytes() == b"\xff\xfe\xfa" + assert 'export FZF_DEFAULT_OPTS="--height 70%"' in config_file.read_text( + encoding="utf-8" + ) + + +def test_shell_escaping_round_trip(tmp_path): + env = plugin_env(tmp_path) + value = '--preview "bat $FILE `pwd` C:\\tmp"' + payload = { + "requestId": "escape-1", + "command": "apply", + "args": {"settings": {"FZF_DEFAULT_OPTS": value}}, + } + + first, _ = run_plugin(payload, env=env) + second, _ = run_plugin(payload, env=env) + + assert_schema(first, success=True, changed=True) + assert_schema(second, success=True, changed=False) + content = fzfrc_path(tmp_path).read_text(encoding="utf-8") + assert '\\"bat \\$FILE \\`pwd\\` C:\\\\tmp\\"' in content + + +def test_filesystem_write_error_returns_json(tmp_path): + bad_home = tmp_path / "profile-as-file" + bad_home.write_text("not a directory", encoding="utf-8") + payload = { + "requestId": "write-error-1", + "command": "apply", + "args": {"settings": {"FZF_DEFAULT_OPTS": "--height 40%"}}, + } + + response, result = run_plugin(payload, env=plugin_env(bad_home)) + + assert result.returncode == 0 + assert_schema(response, success=False, changed=False) + assert response["data"] == {} + assert response["error"] + assert "Failed to apply config" in result.stderr + + +def test_dry_run_corrupted_config_does_not_backup_or_write(tmp_path): + env = plugin_env(tmp_path) + config_file = fzfrc_path(tmp_path) + original = 'export FZF_DEFAULT_OPTS="unterminated\n' + config_file.write_text(original, encoding="utf-8") + payload = { + "requestId": "dry-corrupt-1", + "command": "apply", + "args": {"settings": {"FZF_DEFAULT_OPTS": "--height 80%"}}, + "context": {"dryRun": True}, + } + + response, result = run_plugin(payload, env=env) + + assert_schema(response, success=True, changed=True) + assert response["data"]["corrupted"] is True + assert config_file.read_text(encoding="utf-8") == original + assert list(tmp_path.glob(f"{config_file.name}.bak.*")) == [] + assert "Would back up corrupted config" in result.stderr + + +def test_unknown_command(tmp_path): + payload = {"requestId": "unknown-1", "command": "unknown_cmd"} + + response, _ = run_plugin(payload, env=plugin_env(tmp_path)) + + assert_schema(response, success=False, changed=False) + assert response["error"] == "Unknown command: unknown_cmd" diff --git a/plugins/gh-dash/src/plugin.py b/plugins/gh-dash/src/plugin.py index f19955da..604a4021 100644 --- a/plugins/gh-dash/src/plugin.py +++ b/plugins/gh-dash/src/plugin.py @@ -1,354 +1,368 @@ -# /// script -# dependencies = [ -# "pyyaml", -# ] -# /// - -import json -import os -import re -import shutil -import subprocess -import sys - -try: - import yaml as _yaml - - _HAS_PYYAML = True -except ImportError: - _yaml = None - _HAS_PYYAML = False - - -# Minimal stdlib YAML fallback used when PyYAML is unavailable. -# Covers the gh-dash config subset: scalar key-values, nested dicts, -# and block sequences of dicts (prSections / issuesSections). - -_KEY_RE = re.compile(r"^([^:\s][^:]*):\s*(.*)") - - -def _parse_scalar(val: str): - val = val.strip() - if not val or val in ("null", "~"): - return None - if val.lower() == "true": - return True - if val.lower() == "false": - return False - if (val.startswith('"') and val.endswith('"')) or (val.startswith("'") and val.endswith("'")): - return val[1:-1] - try: - return int(val) - except ValueError: - pass - try: - return float(val) - except ValueError: - pass - return val - - -def _parse_mapping(lines: list, i: int, base_indent: int) -> tuple: - result = {} - while i < len(lines): - raw = lines[i] - stripped = raw.strip() - if not stripped or stripped.startswith("#"): - i += 1 - continue - indent = len(raw) - len(raw.lstrip()) - if indent < base_indent: - break - lstripped = raw.lstrip() - if lstripped.startswith("- "): - break - m = _KEY_RE.match(lstripped) - if not m: - i += 1 - continue - key, val_str = m.group(1).strip(), m.group(2).strip() - if val_str: - result[key] = _parse_scalar(val_str) - i += 1 - else: - j = i + 1 - while j < len(lines) and not lines[j].strip(): - j += 1 - if j < len(lines) and (len(lines[j]) - len(lines[j].lstrip())) > indent: - child_indent = len(lines[j]) - len(lines[j].lstrip()) - if lines[j].lstrip().startswith("- "): - result[key], i = _parse_sequence(lines, j, child_indent) - else: - result[key], i = _parse_mapping(lines, j, child_indent) - else: - result[key] = None - i = j - return result, i - - -def _parse_sequence(lines: list, i: int, base_indent: int) -> tuple: - items = [] - while i < len(lines): - raw = lines[i] - if not raw.strip() or raw.strip().startswith("#"): - i += 1 - continue - indent = len(raw) - len(raw.lstrip()) - if indent < base_indent: - break - lstripped = raw.lstrip() - if not lstripped.startswith("- "): - break - item_content = lstripped[2:].strip() - m = _KEY_RE.match(item_content) - if m: - key0, val0 = m.group(1).strip(), m.group(2).strip() - item_dict = {key0: _parse_scalar(val0) if val0 else None} - j, cont_indent = i + 1, base_indent + 2 - while j < len(lines): - craw = lines[j] - if not craw.strip() or craw.strip().startswith("#"): - j += 1 - continue - if (len(craw) - len(craw.lstrip())) < cont_indent or craw.lstrip().startswith("- "): - break - cm = _KEY_RE.match(craw.lstrip()) - if cm: - item_dict[cm.group(1).strip()] = _parse_scalar(cm.group(2).strip()) if cm.group(2).strip() else None - j += 1 - i = j - items.append(item_dict) - elif item_content: - items.append(_parse_scalar(item_content)) - i += 1 - else: - items.append(None) - i += 1 - return items, i - - -def _scalar_str(val) -> str: - if val is None: - return "null" - if isinstance(val, bool): - return "true" if val else "false" - if isinstance(val, (int, float)): - return str(val) - s = str(val) - if not s or re.search(r"[:#\[\]{}|>&*!,@`]", s) or s[0] in (" ", '"', "'"): - return f'"{s}"' - return s - - -def _dump_node(val, indent: int) -> str: - prefix = " " * indent - if isinstance(val, dict): - if not val: - return " {}\n" - parts = ["\n"] - for k, v in val.items(): - if isinstance(v, (dict, list)): - parts.append(f"{prefix}{k}:{_dump_node(v, indent + 1)}") - else: - parts.append(f"{prefix}{k}: {_scalar_str(v)}\n") - return "".join(parts) - if isinstance(val, list): - if not val: - return " []\n" - parts = ["\n"] - for item in val: - if isinstance(item, dict): - first = True - for k, v in item.items(): - marker = "- " if first else " " - first = False - parts.append(f"{prefix}{marker}{k}: {_scalar_str(v)}\n") - else: - parts.append(f"{prefix}- {_scalar_str(item)}\n") - return "".join(parts) - return f" {_scalar_str(val)}\n" - - -def _loads_fallback(text: str) -> dict: - if not text.strip(): - return {} - result, _ = _parse_mapping(text.splitlines(), 0, 0) - return result - - -def _dumps_fallback(data: dict) -> str: - parts = [] - for k, v in data.items(): - if isinstance(v, (dict, list)): - parts.append(f"{k}:{_dump_node(v, 1)}") - else: - parts.append(f"{k}: {_scalar_str(v)}\n") - return "".join(parts) - - -# --- Config I/O --- - - -def get_config_path() -> str: - env_path = os.environ.get("GH_DASH_CONFIG") - if env_path: - return env_path - user_profile = os.environ.get("USERPROFILE", "") - return os.path.join(user_profile, ".config", "gh-dash", "config.yml") - - -def log(message: str) -> None: - sys.stderr.write(f"[gh-dash-plugin] {message}\n") - sys.stderr.flush() - - -def read_yaml(file_path: str) -> dict: - if not os.path.exists(file_path): - return {} - with open(file_path, "r", encoding="utf-8") as fh: - text = fh.read() - if _HAS_PYYAML: - data = _yaml.safe_load(text) - return data if isinstance(data, dict) else {} - return _loads_fallback(text) - - -def write_yaml(file_path: str, data: dict) -> None: - parent = os.path.dirname(file_path) - if parent: - os.makedirs(parent, exist_ok=True) - text = _yaml.dump(data, default_flow_style=False, sort_keys=False) if _HAS_PYYAML else _dumps_fallback(data) - tmp = file_path + ".tmp" - try: - with open(tmp, "w", encoding="utf-8") as fh: - fh.write(text) - os.replace(tmp, file_path) - except Exception: - if os.path.exists(tmp): - os.remove(tmp) - raise - - -def merge_settings(target: dict, source: dict) -> bool: - """Merge source into target. Lists are replaced entirely; dicts are merged recursively.""" - changed = False - for key, value in source.items(): - if isinstance(value, list): - if target.get(key) != value: - target[key] = value - changed = True - elif isinstance(value, dict): - if not isinstance(target.get(key), dict): - target[key] = {} - changed = True - if merge_settings(target[key], value): - changed = True - else: - if target.get(key) != value: - target[key] = value - changed = True - return changed - - -def get_settings_from_args(args: dict) -> dict: - """Support both wrapped { settings: {...} } and flat { key: value } arg formats.""" - settings = args.get("settings") - if isinstance(settings, dict): - return settings - return args - - -def check_installed(request_id: str) -> dict: - if shutil.which("gh-dash") is not None or shutil.which("gh-dash.exe") is not None: - return { - "requestId": request_id, - "success": True, - "changed": False, - "data": True, - } - - try: - result = subprocess.run( - ["gh", "ext", "list"], - capture_output=True, - text=True, - timeout=5, - ) - if "dlvhdr/gh-dash" in result.stdout: - return { - "requestId": request_id, - "success": True, - "changed": False, - "data": True, - } - except Exception: - pass - - return { - "requestId": request_id, - "success": True, - "changed": False, - "data": False, - } - - -def apply_config(request_id: str, args: dict, context: dict) -> dict: - dry_run = bool(context.get("dryRun", False)) - settings = get_settings_from_args(args) - - config_path = get_config_path() - current_config = read_yaml(config_path) - changed = merge_settings(current_config, settings) - - if dry_run: - log(f"dry_run: {'would update' if changed else 'no changes for'} {config_path}") - return {"requestId": request_id, "success": True, "changed": changed} - - if changed: - write_yaml(config_path, current_config) - - return {"requestId": request_id, "success": True, "changed": changed} - - -def handle(request: dict) -> dict: - request_id = request.get("requestId", "unknown") - command = request.get("command") - args = request.get("args", {}) - context = request.get("context", {}) - - if command == "check_installed": - return check_installed(request_id) - if command == "apply": - if not isinstance(args, dict): - raise ValueError("args must be an object") - if not isinstance(context, dict): - raise ValueError("context must be an object") - return apply_config(request_id, args, context) - - raise ValueError(f"Unknown command: {command}") - - -def main() -> None: - raw = sys.stdin.read() - if not raw: - return - - try: - request = json.loads(raw) - result = handle(request) - except Exception as error: - result = { - "requestId": request.get("requestId", "unknown") - if "request" in locals() and isinstance(request, dict) - else "unknown", - "success": False, - "changed": False, - "error": str(error), - } - - sys.stdout.write(json.dumps(result) + "\n") - sys.stdout.flush() - - -if __name__ == "__main__": - main() +# /// script +# dependencies = [ +# "pyyaml", +# ] +# /// + +import json +import os +import re +import shutil +import subprocess +import sys + +try: + import yaml as _yaml + + _HAS_PYYAML = True +except ImportError: + _yaml = None + _HAS_PYYAML = False + + +# Minimal stdlib YAML fallback used when PyYAML is unavailable. +# Covers the gh-dash config subset: scalar key-values, nested dicts, +# and block sequences of dicts (prSections / issuesSections). + +_KEY_RE = re.compile(r"^([^:\s][^:]*):\s*(.*)") + + +def _parse_scalar(val: str): + val = val.strip() + if not val or val in ("null", "~"): + return None + if val.lower() == "true": + return True + if val.lower() == "false": + return False + if (val.startswith('"') and val.endswith('"')) or ( + val.startswith("'") and val.endswith("'") + ): + return val[1:-1] + try: + return int(val) + except ValueError: + pass + try: + return float(val) + except ValueError: + pass + return val + + +def _parse_mapping(lines: list, i: int, base_indent: int) -> tuple: + result = {} + while i < len(lines): + raw = lines[i] + stripped = raw.strip() + if not stripped or stripped.startswith("#"): + i += 1 + continue + indent = len(raw) - len(raw.lstrip()) + if indent < base_indent: + break + lstripped = raw.lstrip() + if lstripped.startswith("- "): + break + m = _KEY_RE.match(lstripped) + if not m: + i += 1 + continue + key, val_str = m.group(1).strip(), m.group(2).strip() + if val_str: + result[key] = _parse_scalar(val_str) + i += 1 + else: + j = i + 1 + while j < len(lines) and not lines[j].strip(): + j += 1 + if j < len(lines) and (len(lines[j]) - len(lines[j].lstrip())) > indent: + child_indent = len(lines[j]) - len(lines[j].lstrip()) + if lines[j].lstrip().startswith("- "): + result[key], i = _parse_sequence(lines, j, child_indent) + else: + result[key], i = _parse_mapping(lines, j, child_indent) + else: + result[key] = None + i = j + return result, i + + +def _parse_sequence(lines: list, i: int, base_indent: int) -> tuple: + items = [] + while i < len(lines): + raw = lines[i] + if not raw.strip() or raw.strip().startswith("#"): + i += 1 + continue + indent = len(raw) - len(raw.lstrip()) + if indent < base_indent: + break + lstripped = raw.lstrip() + if not lstripped.startswith("- "): + break + item_content = lstripped[2:].strip() + m = _KEY_RE.match(item_content) + if m: + key0, val0 = m.group(1).strip(), m.group(2).strip() + item_dict = {key0: _parse_scalar(val0) if val0 else None} + j, cont_indent = i + 1, base_indent + 2 + while j < len(lines): + craw = lines[j] + if not craw.strip() or craw.strip().startswith("#"): + j += 1 + continue + if ( + len(craw) - len(craw.lstrip()) + ) < cont_indent or craw.lstrip().startswith("- "): + break + cm = _KEY_RE.match(craw.lstrip()) + if cm: + item_dict[cm.group(1).strip()] = ( + _parse_scalar(cm.group(2).strip()) + if cm.group(2).strip() + else None + ) + j += 1 + i = j + items.append(item_dict) + elif item_content: + items.append(_parse_scalar(item_content)) + i += 1 + else: + items.append(None) + i += 1 + return items, i + + +def _scalar_str(val) -> str: + if val is None: + return "null" + if isinstance(val, bool): + return "true" if val else "false" + if isinstance(val, (int, float)): + return str(val) + s = str(val) + if not s or re.search(r"[:#\[\]{}|>&*!,@`]", s) or s[0] in (" ", '"', "'"): + return f'"{s}"' + return s + + +def _dump_node(val, indent: int) -> str: + prefix = " " * indent + if isinstance(val, dict): + if not val: + return " {}\n" + parts = ["\n"] + for k, v in val.items(): + if isinstance(v, (dict, list)): + parts.append(f"{prefix}{k}:{_dump_node(v, indent + 1)}") + else: + parts.append(f"{prefix}{k}: {_scalar_str(v)}\n") + return "".join(parts) + if isinstance(val, list): + if not val: + return " []\n" + parts = ["\n"] + for item in val: + if isinstance(item, dict): + first = True + for k, v in item.items(): + marker = "- " if first else " " + first = False + parts.append(f"{prefix}{marker}{k}: {_scalar_str(v)}\n") + else: + parts.append(f"{prefix}- {_scalar_str(item)}\n") + return "".join(parts) + return f" {_scalar_str(val)}\n" + + +def _loads_fallback(text: str) -> dict: + if not text.strip(): + return {} + result, _ = _parse_mapping(text.splitlines(), 0, 0) + return result + + +def _dumps_fallback(data: dict) -> str: + parts = [] + for k, v in data.items(): + if isinstance(v, (dict, list)): + parts.append(f"{k}:{_dump_node(v, 1)}") + else: + parts.append(f"{k}: {_scalar_str(v)}\n") + return "".join(parts) + + +# --- Config I/O --- + + +def get_config_path() -> str: + env_path = os.environ.get("GH_DASH_CONFIG") + if env_path: + return env_path + user_profile = os.environ.get("USERPROFILE", "") + return os.path.join(user_profile, ".config", "gh-dash", "config.yml") + + +def log(message: str) -> None: + sys.stderr.write(f"[gh-dash-plugin] {message}\n") + sys.stderr.flush() + + +def read_yaml(file_path: str) -> dict: + if not os.path.exists(file_path): + return {} + with open(file_path, "r", encoding="utf-8") as fh: + text = fh.read() + if _HAS_PYYAML: + data = _yaml.safe_load(text) + return data if isinstance(data, dict) else {} + return _loads_fallback(text) + + +def write_yaml(file_path: str, data: dict) -> None: + parent = os.path.dirname(file_path) + if parent: + os.makedirs(parent, exist_ok=True) + text = ( + _yaml.dump(data, default_flow_style=False, sort_keys=False) + if _HAS_PYYAML + else _dumps_fallback(data) + ) + tmp = file_path + ".tmp" + try: + with open(tmp, "w", encoding="utf-8") as fh: + fh.write(text) + os.replace(tmp, file_path) + except Exception: + if os.path.exists(tmp): + os.remove(tmp) + raise + + +def merge_settings(target: dict, source: dict) -> bool: + """Merge source into target. Lists are replaced entirely; dicts are merged recursively.""" + changed = False + for key, value in source.items(): + if isinstance(value, list): + if target.get(key) != value: + target[key] = value + changed = True + elif isinstance(value, dict): + if not isinstance(target.get(key), dict): + target[key] = {} + changed = True + if merge_settings(target[key], value): + changed = True + else: + if target.get(key) != value: + target[key] = value + changed = True + return changed + + +def get_settings_from_args(args: dict) -> dict: + """Support both wrapped { settings: {...} } and flat { key: value } arg formats.""" + settings = args.get("settings") + if isinstance(settings, dict): + return settings + return args + + +def check_installed(request_id: str) -> dict: + if shutil.which("gh-dash") is not None or shutil.which("gh-dash.exe") is not None: + return { + "requestId": request_id, + "success": True, + "changed": False, + "data": True, + } + + try: + result = subprocess.run( + ["gh", "ext", "list"], + capture_output=True, + text=True, + timeout=5, + ) + if "dlvhdr/gh-dash" in result.stdout: + return { + "requestId": request_id, + "success": True, + "changed": False, + "data": True, + } + except Exception: + pass + + return { + "requestId": request_id, + "success": True, + "changed": False, + "data": False, + } + + +def apply_config(request_id: str, args: dict, context: dict) -> dict: + dry_run = bool(context.get("dryRun", False)) + settings = get_settings_from_args(args) + + config_path = get_config_path() + current_config = read_yaml(config_path) + changed = merge_settings(current_config, settings) + + if dry_run: + log(f"dry_run: {'would update' if changed else 'no changes for'} {config_path}") + return {"requestId": request_id, "success": True, "changed": changed} + + if changed: + write_yaml(config_path, current_config) + + return {"requestId": request_id, "success": True, "changed": changed} + + +def handle(request: dict) -> dict: + request_id = request.get("requestId", "unknown") + command = request.get("command") + args = request.get("args", {}) + context = request.get("context", {}) + + if command == "check_installed": + return check_installed(request_id) + if command == "apply": + if not isinstance(args, dict): + raise ValueError("args must be an object") + if not isinstance(context, dict): + raise ValueError("context must be an object") + return apply_config(request_id, args, context) + + raise ValueError(f"Unknown command: {command}") + + +def main() -> None: + raw = sys.stdin.read() + if not raw: + return + + try: + request = json.loads(raw) + result = handle(request) + except Exception as error: + result = { + "requestId": ( + request.get("requestId", "unknown") + if "request" in locals() and isinstance(request, dict) + else "unknown" + ), + "success": False, + "changed": False, + "error": str(error), + } + + sys.stdout.write(json.dumps(result) + "\n") + sys.stdout.flush() + + +if __name__ == "__main__": + main() diff --git a/plugins/gh-dash/test/test_gh_dash.py b/plugins/gh-dash/test/test_gh_dash.py index ac66fb07..041c1b99 100644 --- a/plugins/gh-dash/test/test_gh_dash.py +++ b/plugins/gh-dash/test/test_gh_dash.py @@ -1,388 +1,394 @@ -#!/usr/bin/env python3 -# /// script -# dependencies = [ -# "pyyaml", -# ] -# /// - -import json -import os -import sys -import tempfile -import unittest -from io import StringIO -from unittest.mock import MagicMock, patch - -import yaml - -sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "src"))) -import plugin - - -class TestGhDashPlugin(unittest.TestCase): - def run_main(self, payload: dict) -> dict: - stdin = StringIO(json.dumps(payload) + "\n") - stdout = StringIO() - with patch("sys.stdin", stdin), patch("sys.stdout", stdout): - plugin.main() - return json.loads(stdout.getvalue().strip()) - - # --- check_installed --- - - def test_check_installed_true_via_which(self): - with patch( - "plugin.shutil.which", - side_effect=lambda name: "/usr/local/bin/gh-dash" if name == "gh-dash" else None, - ): - response = self.run_main( - { - "requestId": "req-1", - "command": "check_installed", - "args": {}, - "context": {}, - } - ) - - self.assertTrue(response["success"]) - self.assertFalse(response["changed"]) - self.assertTrue(response["data"]) - - def test_check_installed_true_via_gh_ext(self): - mock_result = MagicMock() - mock_result.stdout = "dlvhdr/gh-dash\nsome-other/extension\n" - - with ( - patch("plugin.shutil.which", return_value=None), - patch("plugin.subprocess.run", return_value=mock_result), - ): - response = self.run_main( - { - "requestId": "req-2", - "command": "check_installed", - "args": {}, - "context": {}, - } - ) - - self.assertTrue(response["success"]) - self.assertTrue(response["data"]) - - def test_check_installed_false(self): - mock_result = MagicMock() - mock_result.stdout = "some-other/extension\n" - - with ( - patch("plugin.shutil.which", return_value=None), - patch("plugin.subprocess.run", return_value=mock_result), - ): - response = self.run_main( - { - "requestId": "req-3", - "command": "check_installed", - "args": {}, - "context": {}, - } - ) - - self.assertTrue(response["success"]) - self.assertFalse(response["data"]) - - # --- apply --- - - def test_apply_writes_sections(self): - with tempfile.TemporaryDirectory() as tmp_dir: - config_path = os.path.join(tmp_dir, "config.yml") - - with patch("plugin.get_config_path", return_value=config_path): - response = self.run_main( - { - "requestId": "req-4", - "command": "apply", - "args": { - "defaultLimit": 30, - "prSections": [ - { - "title": "My PRs", - "filters": "is:open author:@me", - } - ], - }, - "context": {"dryRun": False}, - } - ) - - self.assertTrue(response["success"]) - self.assertTrue(response["changed"]) - - with open(config_path, "r", encoding="utf-8") as fh: - content = yaml.safe_load(fh) - - self.assertEqual(content["defaultLimit"], 30) - self.assertEqual(len(content["prSections"]), 1) - self.assertEqual(content["prSections"][0]["title"], "My PRs") - - def test_apply_replaces_list_sections_entirely(self): - with tempfile.TemporaryDirectory() as tmp_dir: - config_path = os.path.join(tmp_dir, "config.yml") - initial = { - "prSections": [ - {"title": "Section A", "filters": "is:open"}, - {"title": "Section B", "filters": "is:merged"}, - ] - } - with open(config_path, "w", encoding="utf-8") as fh: - yaml.dump(initial, fh, default_flow_style=False, sort_keys=False) - - with patch("plugin.get_config_path", return_value=config_path): - response = self.run_main( - { - "requestId": "req-5", - "command": "apply", - "args": { - "prSections": [ - { - "title": "Only Section", - "filters": "is:open author:@me", - } - ] - }, - "context": {"dryRun": False}, - } - ) - - self.assertTrue(response["success"]) - self.assertTrue(response["changed"]) - - with open(config_path, "r", encoding="utf-8") as fh: - content = yaml.safe_load(fh) - - self.assertEqual(len(content["prSections"]), 1) - self.assertEqual(content["prSections"][0]["title"], "Only Section") - - def test_apply_merges_scalar_settings(self): - with tempfile.TemporaryDirectory() as tmp_dir: - config_path = os.path.join(tmp_dir, "config.yml") - with open(config_path, "w", encoding="utf-8") as fh: - yaml.dump( - {"defaultLimit": 20}, - fh, - default_flow_style=False, - sort_keys=False, - ) - - with patch("plugin.get_config_path", return_value=config_path): - response = self.run_main( - { - "requestId": "req-6", - "command": "apply", - "args": {"refreshInterval": 120}, - "context": {"dryRun": False}, - } - ) - - self.assertTrue(response["success"]) - self.assertTrue(response["changed"]) - - with open(config_path, "r", encoding="utf-8") as fh: - content = yaml.safe_load(fh) - - self.assertEqual(content["defaultLimit"], 20) - self.assertEqual(content["refreshInterval"], 120) - - def test_apply_no_changes_returns_changed_false(self): - with tempfile.TemporaryDirectory() as tmp_dir: - config_path = os.path.join(tmp_dir, "config.yml") - initial = { - "defaultLimit": 30, - "prSections": [{"title": "My PRs", "filters": "is:open author:@me"}], - } - with open(config_path, "w", encoding="utf-8") as fh: - yaml.dump(initial, fh, default_flow_style=False, sort_keys=False) - - with patch("plugin.get_config_path", return_value=config_path): - response = self.run_main( - { - "requestId": "req-7", - "command": "apply", - "args": { - "defaultLimit": 30, - "prSections": [ - { - "title": "My PRs", - "filters": "is:open author:@me", - } - ], - }, - "context": {"dryRun": False}, - } - ) - - self.assertTrue(response["success"]) - self.assertFalse(response["changed"]) - - def test_apply_dry_run_does_not_write(self): - with tempfile.TemporaryDirectory() as tmp_dir: - config_path = os.path.join(tmp_dir, "subdir", "config.yml") - - with patch("plugin.get_config_path", return_value=config_path): - response = self.run_main( - { - "requestId": "req-8", - "command": "apply", - "args": {"defaultLimit": 50}, - "context": {"dryRun": True}, - } - ) - - self.assertTrue(response["success"]) - self.assertTrue(response["changed"]) - self.assertFalse(os.path.exists(config_path)) - - def test_apply_creates_missing_directory(self): - with tempfile.TemporaryDirectory() as tmp_dir: - config_path = os.path.join(tmp_dir, "gh-dash", "config.yml") - self.assertFalse(os.path.isdir(os.path.dirname(config_path))) - - with patch("plugin.get_config_path", return_value=config_path): - response = self.run_main( - { - "requestId": "req-9", - "command": "apply", - "args": {"defaultLimit": 30}, - "context": {"dryRun": False}, - } - ) - - self.assertTrue(response["success"]) - self.assertTrue(response["changed"]) - self.assertTrue(os.path.isdir(os.path.dirname(config_path))) - self.assertTrue(os.path.exists(config_path)) - - def test_apply_preserves_unmentioned_settings(self): - with tempfile.TemporaryDirectory() as tmp_dir: - config_path = os.path.join(tmp_dir, "config.yml") - with open(config_path, "w", encoding="utf-8") as fh: - yaml.dump( - {"theme": "dark", "defaultLimit": 20}, - fh, - default_flow_style=False, - sort_keys=False, - ) - - with patch("plugin.get_config_path", return_value=config_path): - self.run_main( - { - "requestId": "req-10", - "command": "apply", - "args": {"defaultLimit": 30}, - "context": {"dryRun": False}, - } - ) - - with open(config_path, "r", encoding="utf-8") as fh: - content = yaml.safe_load(fh) - - self.assertEqual(content["theme"], "dark") - self.assertEqual(content["defaultLimit"], 30) - - def test_apply_gh_dash_config_env_var(self): - with tempfile.TemporaryDirectory() as tmp_dir: - config_path = os.path.join(tmp_dir, "custom-config.yml") - - with patch.dict(os.environ, {"GH_DASH_CONFIG": config_path}): - response = self.run_main( - { - "requestId": "req-11", - "command": "apply", - "args": {"defaultLimit": 25}, - "context": {"dryRun": False}, - } - ) - - self.assertTrue(response["success"]) - self.assertTrue(response["changed"]) - self.assertTrue(os.path.exists(config_path)) - - with open(config_path, "r", encoding="utf-8") as fh: - content = yaml.safe_load(fh) - self.assertEqual(content["defaultLimit"], 25) - - def test_apply_uses_settings_wrapper(self): - with tempfile.TemporaryDirectory() as tmp_dir: - config_path = os.path.join(tmp_dir, "config.yml") - - with patch("plugin.get_config_path", return_value=config_path): - response = self.run_main( - { - "requestId": "req-12", - "command": "apply", - "args": { - "settings": { - "defaultLimit": 40, - "prSections": [{"title": "Wrapped", "filters": "is:open"}], - } - }, - "context": {"dryRun": False}, - } - ) - - self.assertTrue(response["success"]) - self.assertTrue(response["changed"]) - - with open(config_path, "r", encoding="utf-8") as fh: - content = yaml.safe_load(fh) - - self.assertEqual(content["defaultLimit"], 40) - self.assertEqual(content["prSections"][0]["title"], "Wrapped") - - def test_apply_works_without_pyyaml(self): - with tempfile.TemporaryDirectory() as tmp_dir: - config_path = os.path.join(tmp_dir, "config.yml") - with ( - patch("plugin.get_config_path", return_value=config_path), - patch("plugin._HAS_PYYAML", False), - patch("plugin._yaml", None), - ): - response = self.run_main( - { - "requestId": "req-13", - "command": "apply", - "args": { - "defaultLimit": 30, - "prSections": [ - { - "title": "My PRs", - "filters": "is:open author:@me", - } - ], - }, - "context": {"dryRun": False}, - } - ) - - self.assertEqual(response["requestId"], "req-13") - self.assertTrue(response["success"]) - self.assertTrue(response["changed"]) - - with open(config_path, "r", encoding="utf-8") as fh: - content = yaml.safe_load(fh) - self.assertEqual(content["defaultLimit"], 30) - self.assertEqual(content["prSections"][0]["title"], "My PRs") - - def test_unknown_command_returns_error(self): - response = self.run_main( - { - "requestId": "req-14", - "command": "explode", - "args": {}, - "context": {}, - } - ) - self.assertEqual(response["requestId"], "req-14") - self.assertFalse(response["success"]) - self.assertFalse(response["changed"]) - self.assertIn("Unknown command", response["error"]) - - -if __name__ == "__main__": - unittest.main() +#!/usr/bin/env python3 +# /// script +# dependencies = [ +# "pyyaml", +# ] +# /// + +import json +import os +import sys +import tempfile +import unittest +from io import StringIO +from unittest.mock import MagicMock, patch + +import yaml + +sys.path.insert( + 0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "src")) +) +import plugin + + +class TestGhDashPlugin(unittest.TestCase): + def run_main(self, payload: dict) -> dict: + stdin = StringIO(json.dumps(payload) + "\n") + stdout = StringIO() + with patch("sys.stdin", stdin), patch("sys.stdout", stdout): + plugin.main() + return json.loads(stdout.getvalue().strip()) + + # --- check_installed --- + + def test_check_installed_true_via_which(self): + with patch( + "plugin.shutil.which", + side_effect=lambda name: ( + "/usr/local/bin/gh-dash" if name == "gh-dash" else None + ), + ): + response = self.run_main( + { + "requestId": "req-1", + "command": "check_installed", + "args": {}, + "context": {}, + } + ) + + self.assertTrue(response["success"]) + self.assertFalse(response["changed"]) + self.assertTrue(response["data"]) + + def test_check_installed_true_via_gh_ext(self): + mock_result = MagicMock() + mock_result.stdout = "dlvhdr/gh-dash\nsome-other/extension\n" + + with ( + patch("plugin.shutil.which", return_value=None), + patch("plugin.subprocess.run", return_value=mock_result), + ): + response = self.run_main( + { + "requestId": "req-2", + "command": "check_installed", + "args": {}, + "context": {}, + } + ) + + self.assertTrue(response["success"]) + self.assertTrue(response["data"]) + + def test_check_installed_false(self): + mock_result = MagicMock() + mock_result.stdout = "some-other/extension\n" + + with ( + patch("plugin.shutil.which", return_value=None), + patch("plugin.subprocess.run", return_value=mock_result), + ): + response = self.run_main( + { + "requestId": "req-3", + "command": "check_installed", + "args": {}, + "context": {}, + } + ) + + self.assertTrue(response["success"]) + self.assertFalse(response["data"]) + + # --- apply --- + + def test_apply_writes_sections(self): + with tempfile.TemporaryDirectory() as tmp_dir: + config_path = os.path.join(tmp_dir, "config.yml") + + with patch("plugin.get_config_path", return_value=config_path): + response = self.run_main( + { + "requestId": "req-4", + "command": "apply", + "args": { + "defaultLimit": 30, + "prSections": [ + { + "title": "My PRs", + "filters": "is:open author:@me", + } + ], + }, + "context": {"dryRun": False}, + } + ) + + self.assertTrue(response["success"]) + self.assertTrue(response["changed"]) + + with open(config_path, "r", encoding="utf-8") as fh: + content = yaml.safe_load(fh) + + self.assertEqual(content["defaultLimit"], 30) + self.assertEqual(len(content["prSections"]), 1) + self.assertEqual(content["prSections"][0]["title"], "My PRs") + + def test_apply_replaces_list_sections_entirely(self): + with tempfile.TemporaryDirectory() as tmp_dir: + config_path = os.path.join(tmp_dir, "config.yml") + initial = { + "prSections": [ + {"title": "Section A", "filters": "is:open"}, + {"title": "Section B", "filters": "is:merged"}, + ] + } + with open(config_path, "w", encoding="utf-8") as fh: + yaml.dump(initial, fh, default_flow_style=False, sort_keys=False) + + with patch("plugin.get_config_path", return_value=config_path): + response = self.run_main( + { + "requestId": "req-5", + "command": "apply", + "args": { + "prSections": [ + { + "title": "Only Section", + "filters": "is:open author:@me", + } + ] + }, + "context": {"dryRun": False}, + } + ) + + self.assertTrue(response["success"]) + self.assertTrue(response["changed"]) + + with open(config_path, "r", encoding="utf-8") as fh: + content = yaml.safe_load(fh) + + self.assertEqual(len(content["prSections"]), 1) + self.assertEqual(content["prSections"][0]["title"], "Only Section") + + def test_apply_merges_scalar_settings(self): + with tempfile.TemporaryDirectory() as tmp_dir: + config_path = os.path.join(tmp_dir, "config.yml") + with open(config_path, "w", encoding="utf-8") as fh: + yaml.dump( + {"defaultLimit": 20}, + fh, + default_flow_style=False, + sort_keys=False, + ) + + with patch("plugin.get_config_path", return_value=config_path): + response = self.run_main( + { + "requestId": "req-6", + "command": "apply", + "args": {"refreshInterval": 120}, + "context": {"dryRun": False}, + } + ) + + self.assertTrue(response["success"]) + self.assertTrue(response["changed"]) + + with open(config_path, "r", encoding="utf-8") as fh: + content = yaml.safe_load(fh) + + self.assertEqual(content["defaultLimit"], 20) + self.assertEqual(content["refreshInterval"], 120) + + def test_apply_no_changes_returns_changed_false(self): + with tempfile.TemporaryDirectory() as tmp_dir: + config_path = os.path.join(tmp_dir, "config.yml") + initial = { + "defaultLimit": 30, + "prSections": [{"title": "My PRs", "filters": "is:open author:@me"}], + } + with open(config_path, "w", encoding="utf-8") as fh: + yaml.dump(initial, fh, default_flow_style=False, sort_keys=False) + + with patch("plugin.get_config_path", return_value=config_path): + response = self.run_main( + { + "requestId": "req-7", + "command": "apply", + "args": { + "defaultLimit": 30, + "prSections": [ + { + "title": "My PRs", + "filters": "is:open author:@me", + } + ], + }, + "context": {"dryRun": False}, + } + ) + + self.assertTrue(response["success"]) + self.assertFalse(response["changed"]) + + def test_apply_dry_run_does_not_write(self): + with tempfile.TemporaryDirectory() as tmp_dir: + config_path = os.path.join(tmp_dir, "subdir", "config.yml") + + with patch("plugin.get_config_path", return_value=config_path): + response = self.run_main( + { + "requestId": "req-8", + "command": "apply", + "args": {"defaultLimit": 50}, + "context": {"dryRun": True}, + } + ) + + self.assertTrue(response["success"]) + self.assertTrue(response["changed"]) + self.assertFalse(os.path.exists(config_path)) + + def test_apply_creates_missing_directory(self): + with tempfile.TemporaryDirectory() as tmp_dir: + config_path = os.path.join(tmp_dir, "gh-dash", "config.yml") + self.assertFalse(os.path.isdir(os.path.dirname(config_path))) + + with patch("plugin.get_config_path", return_value=config_path): + response = self.run_main( + { + "requestId": "req-9", + "command": "apply", + "args": {"defaultLimit": 30}, + "context": {"dryRun": False}, + } + ) + + self.assertTrue(response["success"]) + self.assertTrue(response["changed"]) + self.assertTrue(os.path.isdir(os.path.dirname(config_path))) + self.assertTrue(os.path.exists(config_path)) + + def test_apply_preserves_unmentioned_settings(self): + with tempfile.TemporaryDirectory() as tmp_dir: + config_path = os.path.join(tmp_dir, "config.yml") + with open(config_path, "w", encoding="utf-8") as fh: + yaml.dump( + {"theme": "dark", "defaultLimit": 20}, + fh, + default_flow_style=False, + sort_keys=False, + ) + + with patch("plugin.get_config_path", return_value=config_path): + self.run_main( + { + "requestId": "req-10", + "command": "apply", + "args": {"defaultLimit": 30}, + "context": {"dryRun": False}, + } + ) + + with open(config_path, "r", encoding="utf-8") as fh: + content = yaml.safe_load(fh) + + self.assertEqual(content["theme"], "dark") + self.assertEqual(content["defaultLimit"], 30) + + def test_apply_gh_dash_config_env_var(self): + with tempfile.TemporaryDirectory() as tmp_dir: + config_path = os.path.join(tmp_dir, "custom-config.yml") + + with patch.dict(os.environ, {"GH_DASH_CONFIG": config_path}): + response = self.run_main( + { + "requestId": "req-11", + "command": "apply", + "args": {"defaultLimit": 25}, + "context": {"dryRun": False}, + } + ) + + self.assertTrue(response["success"]) + self.assertTrue(response["changed"]) + self.assertTrue(os.path.exists(config_path)) + + with open(config_path, "r", encoding="utf-8") as fh: + content = yaml.safe_load(fh) + self.assertEqual(content["defaultLimit"], 25) + + def test_apply_uses_settings_wrapper(self): + with tempfile.TemporaryDirectory() as tmp_dir: + config_path = os.path.join(tmp_dir, "config.yml") + + with patch("plugin.get_config_path", return_value=config_path): + response = self.run_main( + { + "requestId": "req-12", + "command": "apply", + "args": { + "settings": { + "defaultLimit": 40, + "prSections": [ + {"title": "Wrapped", "filters": "is:open"} + ], + } + }, + "context": {"dryRun": False}, + } + ) + + self.assertTrue(response["success"]) + self.assertTrue(response["changed"]) + + with open(config_path, "r", encoding="utf-8") as fh: + content = yaml.safe_load(fh) + + self.assertEqual(content["defaultLimit"], 40) + self.assertEqual(content["prSections"][0]["title"], "Wrapped") + + def test_apply_works_without_pyyaml(self): + with tempfile.TemporaryDirectory() as tmp_dir: + config_path = os.path.join(tmp_dir, "config.yml") + with ( + patch("plugin.get_config_path", return_value=config_path), + patch("plugin._HAS_PYYAML", False), + patch("plugin._yaml", None), + ): + response = self.run_main( + { + "requestId": "req-13", + "command": "apply", + "args": { + "defaultLimit": 30, + "prSections": [ + { + "title": "My PRs", + "filters": "is:open author:@me", + } + ], + }, + "context": {"dryRun": False}, + } + ) + + self.assertEqual(response["requestId"], "req-13") + self.assertTrue(response["success"]) + self.assertTrue(response["changed"]) + + with open(config_path, "r", encoding="utf-8") as fh: + content = yaml.safe_load(fh) + self.assertEqual(content["defaultLimit"], 30) + self.assertEqual(content["prSections"][0]["title"], "My PRs") + + def test_unknown_command_returns_error(self): + response = self.run_main( + { + "requestId": "req-14", + "command": "explode", + "args": {}, + "context": {}, + } + ) + self.assertEqual(response["requestId"], "req-14") + self.assertFalse(response["success"]) + self.assertFalse(response["changed"]) + self.assertIn("Unknown command", response["error"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/plugins/gh/src/plugin.py b/plugins/gh/src/plugin.py index e013f3d2..17904fe0 100644 --- a/plugins/gh/src/plugin.py +++ b/plugins/gh/src/plugin.py @@ -146,9 +146,11 @@ def main() -> None: result = handle(request) except Exception as error: result = { - "requestId": request.get("requestId", "unknown") - if "request" in locals() and isinstance(request, dict) - else "unknown", + "requestId": ( + request.get("requestId", "unknown") + if "request" in locals() and isinstance(request, dict) + else "unknown" + ), "success": False, "changed": False, "error": str(error), diff --git a/plugins/gh/test/test_gh.py b/plugins/gh/test/test_gh.py index b998ebc8..1e4ee885 100644 --- a/plugins/gh/test/test_gh.py +++ b/plugins/gh/test/test_gh.py @@ -15,7 +15,9 @@ import yaml -sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "src"))) +sys.path.insert( + 0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "src")) +) import plugin @@ -30,9 +32,13 @@ def run_main(self, payload: dict) -> dict: def test_check_installed_returns_true_when_gh_is_found(self): with patch( "plugin.shutil.which", - side_effect=lambda name: "C:/Program Files/gh/gh.exe" if name in {"gh", "gh.exe"} else None, + side_effect=lambda name: ( + "C:/Program Files/gh/gh.exe" if name in {"gh", "gh.exe"} else None + ), ): - response = self.run_main({"requestId": "req-1", "command": "check_installed", "args": {}}) + response = self.run_main( + {"requestId": "req-1", "command": "check_installed", "args": {}} + ) self.assertEqual(response["requestId"], "req-1") self.assertTrue(response["success"]) @@ -41,7 +47,9 @@ def test_check_installed_returns_true_when_gh_is_found(self): def test_check_installed_returns_false_when_gh_is_missing(self): with patch("plugin.shutil.which", return_value=None): - response = self.run_main({"requestId": "req-2", "command": "check_installed", "args": {}}) + response = self.run_main( + {"requestId": "req-2", "command": "check_installed", "args": {}} + ) self.assertEqual(response["requestId"], "req-2") self.assertTrue(response["success"]) diff --git a/plugins/github-desktop/src/plugin.py b/plugins/github-desktop/src/plugin.py index f5cb460a..ef1cde11 100644 --- a/plugins/github-desktop/src/plugin.py +++ b/plugins/github-desktop/src/plugin.py @@ -19,7 +19,9 @@ def deep_merge(base, update): def check_installed() -> bool: """Decoupled utility to determine installation state profiles cleanly.""" appdata = os.environ.get("APPDATA", "") - config_path = os.path.join(appdata, "GitHub Desktop", "config.json") if appdata else "" + config_path = ( + os.path.join(appdata, "GitHub Desktop", "config.json") if appdata else "" + ) return bool(config_path and os.path.exists(config_path)) @@ -95,7 +97,9 @@ def main(): if not os.path.exists(config_dir): os.makedirs(config_dir, exist_ok=True) try: - fd, temp_path = tempfile.mkstemp(dir=config_dir, prefix="config_", suffix=".json") + fd, temp_path = tempfile.mkstemp( + dir=config_dir, prefix="config_", suffix=".json" + ) with os.fdopen(fd, "w", encoding="utf-8") as f: json.dump(updated_config, f, indent=2) os.replace(temp_path, config_path) @@ -116,7 +120,9 @@ def main(): json.dumps( { "requestId": request_id, - "error": (f"Unknown execution command structural parameter: {command}"), + "error": ( + f"Unknown execution command structural parameter: {command}" + ), } ) ) diff --git a/plugins/github-desktop/test/test_github_desktop.py b/plugins/github-desktop/test/test_github_desktop.py index a1b52c95..760d70ad 100644 --- a/plugins/github-desktop/test/test_github_desktop.py +++ b/plugins/github-desktop/test/test_github_desktop.py @@ -54,7 +54,9 @@ def test_check_installed_protocol_parity(self, mock_exists, mock_stdin): @patch("tempfile.mkstemp") @patch("os.fdopen", new_callable=mock_open) @patch("os.replace") - def test_settings_deep_merge_atomic_write(self, mock_rep, mock_fd, mock_stemp, mock_f, mock_ex, mock_in): + def test_settings_deep_merge_atomic_write( + self, mock_rep, mock_fd, mock_stemp, mock_f, mock_ex, mock_in + ): """Verifies deep merges calculate variance parameters seamlessly.""" mock_in.read.return_value = json.dumps( { diff --git a/plugins/go/src/plugin.py b/plugins/go/src/plugin.py index f66496d2..d40950d5 100644 --- a/plugins/go/src/plugin.py +++ b/plugins/go/src/plugin.py @@ -42,7 +42,10 @@ def read_go_env(go_bin, key): text=True, ) if result.returncode != 0: - error = result.stderr.strip() or f"go env {key} failed with exit code {result.returncode}" + error = ( + result.stderr.strip() + or f"go env {key} failed with exit code {result.returncode}" + ) raise RuntimeError(error) return result.stdout.rstrip("\r\n") @@ -55,7 +58,10 @@ def write_go_env(go_bin, key, value): text=True, ) if result.returncode != 0: - error = result.stderr.strip() or f"go env -w {key}=... failed with exit code {result.returncode}" + error = ( + result.stderr.strip() + or f"go env -w {key}=... failed with exit code {result.returncode}" + ) raise RuntimeError(error) @@ -116,7 +122,9 @@ def apply_config(args, context, request_id): } if dry_run: - log(f"dry_run: would update Go environment settings: {json.dumps(changes, sort_keys=True)}") + log( + f"dry_run: would update Go environment settings: {json.dumps(changes, sort_keys=True)}" + ) return { "requestId": request_id, "success": True, @@ -184,7 +192,9 @@ def error_response(request_id, error): def main(): input_data = sys.stdin.read() if not input_data: - sys.stdout.write(json.dumps(error_response("unknown", "No input provided")) + "\n") + sys.stdout.write( + json.dumps(error_response("unknown", "No input provided")) + "\n" + ) sys.stdout.flush() return @@ -195,7 +205,11 @@ def main(): raise ValueError("request must be an object") response = handle(request) except Exception as error: - request_id = request.get("requestId", "unknown") if isinstance(request, dict) else "unknown" + request_id = ( + request.get("requestId", "unknown") + if isinstance(request, dict) + else "unknown" + ) response = error_response(request_id, error) sys.stdout.write(json.dumps(response) + "\n") diff --git a/plugins/go/test/test_go.py b/plugins/go/test/test_go.py index 3ba585b9..506d66b5 100644 --- a/plugins/go/test/test_go.py +++ b/plugins/go/test/test_go.py @@ -23,7 +23,11 @@ def completed(stdout="", stderr="", returncode=0): class TestGoPluginInstalled(unittest.TestCase): def test_check_installed_true(self): - with patch.object(plugin.shutil, "which", side_effect=lambda name: "/usr/bin/go" if name == "go" else None): + with patch.object( + plugin.shutil, + "which", + side_effect=lambda name: "/usr/bin/go" if name == "go" else None, + ): result = plugin.check_installed({}, "req-installed") self.assertTrue(result["success"]) @@ -41,7 +45,9 @@ def test_check_installed_false(self): class TestGoEnvParsing(unittest.TestCase): def test_read_go_env_strips_trailing_newlines_only(self): - with patch.object(plugin.subprocess, "run", return_value=completed(stdout="/tmp/go\n")) as run: + with patch.object( + plugin.subprocess, "run", return_value=completed(stdout="/tmp/go\n") + ) as run: value = plugin.read_go_env("/usr/bin/go", "GOPATH") self.assertEqual(value, "/tmp/go") @@ -53,7 +59,11 @@ def test_read_go_env_strips_trailing_newlines_only(self): ) def test_read_go_env_raises_clean_stderr(self): - with patch.object(plugin.subprocess, "run", return_value=completed(stderr="bad key\n", returncode=1)): + with patch.object( + plugin.subprocess, + "run", + return_value=completed(stderr="bad key\n", returncode=1), + ): with self.assertRaisesRegex(RuntimeError, "bad key"): plugin.read_go_env("/usr/bin/go", "GOPATH") @@ -113,7 +123,9 @@ def fake_run(cmd, capture_output, check, text): def test_noop_when_values_already_match(self): with patch.object(plugin, "go_executable", return_value="/usr/bin/go"): with patch.object( - plugin.subprocess, "run", return_value=completed(stdout="https://proxy.golang.org,direct\n") + plugin.subprocess, + "run", + return_value=completed(stdout="https://proxy.golang.org,direct\n"), ): result = plugin.apply_config( {"settings": {"GOPROXY": "https://proxy.golang.org,direct"}}, @@ -127,7 +139,9 @@ def test_noop_when_values_already_match(self): def test_missing_go_is_graceful(self): with patch.object(plugin, "go_executable", return_value=None): - result = plugin.apply_config({"settings": {"GOPATH": "/tmp/go"}}, {}, "req-no-go") + result = plugin.apply_config( + {"settings": {"GOPATH": "/tmp/go"}}, {}, "req-no-go" + ) self.assertFalse(result["success"]) self.assertFalse(result["changed"]) @@ -135,7 +149,9 @@ def test_missing_go_is_graceful(self): def test_rejects_unsupported_setting(self): with patch.object(plugin, "go_executable", return_value="/usr/bin/go"): - result = plugin.apply_config({"settings": {"NOT_GO_ENV": "x"}}, {}, "req-bad") + result = plugin.apply_config( + {"settings": {"NOT_GO_ENV": "x"}}, {}, "req-bad" + ) self.assertFalse(result["success"]) self.assertIn("Unsupported Go environment setting", result["error"]) @@ -161,7 +177,9 @@ def test_handle_uses_settings_from_args(self): ) self.assertTrue(result["success"]) - apply.assert_called_once_with({"settings": {"GOPATH": "/workspace/go"}}, {}, "req") + apply.assert_called_once_with( + {"settings": {"GOPATH": "/workspace/go"}}, {}, "req" + ) def test_main_returns_json_error_for_invalid_json(self): result = self.run_main("{not json") @@ -178,7 +196,9 @@ def test_main_returns_json_error_for_empty_input(self): self.assertEqual(result["error"], "No input provided") def test_unknown_command(self): - result = plugin.handle({"requestId": "req-unknown", "command": "wat", "args": {}, "context": {}}) + result = plugin.handle( + {"requestId": "req-unknown", "command": "wat", "args": {}, "context": {}} + ) self.assertFalse(result["success"]) self.assertEqual(result["error"], "Unknown command: wat") diff --git a/plugins/greenshot/src/plugin.py b/plugins/greenshot/src/plugin.py index f7ad1708..55bb7736 100644 --- a/plugins/greenshot/src/plugin.py +++ b/plugins/greenshot/src/plugin.py @@ -72,7 +72,9 @@ def main(): try: input_data = sys.stdin.read().strip() if not input_data: - sys.stdout.write(json.dumps({"requestId": request_id, "error": "Empty stdin"}) + "\n") + sys.stdout.write( + json.dumps({"requestId": request_id, "error": "Empty stdin"}) + "\n" + ) sys.stdout.flush() return diff --git a/plugins/helix-editor/src/plugin.py b/plugins/helix-editor/src/plugin.py index 9c5dfa98..4ac36f2e 100644 --- a/plugins/helix-editor/src/plugin.py +++ b/plugins/helix-editor/src/plugin.py @@ -44,7 +44,9 @@ def read_toml(file_path: str) -> dict: log(f"Warning: could not parse {file_path} using tomllib: {e}") return {} else: - log("Warning: tomllib not available (requires Python 3.11+). Starting with empty config.") + log( + "Warning: tomllib not available (requires Python 3.11+). Starting with empty config." + ) return {} @@ -65,7 +67,9 @@ def dump_value(v): def _dump_dict_recursive(d: dict, prefix: str, lines: list): # Primitives first for k, v in d.items(): - if not isinstance(v, dict) and not (isinstance(v, list) and len(v) > 0 and isinstance(v[0], dict)): + if not isinstance(v, dict) and not ( + isinstance(v, list) and len(v) > 0 and isinstance(v[0], dict) + ): lines.append(f"{k} = {dump_value(v)}") # Array of Tables @@ -119,7 +123,11 @@ def merge_settings(target: dict, source: dict) -> bool: for item in value: if "name" in item: existing_item = next( - (i for i in target[key] if isinstance(i, dict) and i.get("name") == item["name"]), + ( + i + for i in target[key] + if isinstance(i, dict) and i.get("name") == item["name"] + ), None, ) if existing_item: diff --git a/plugins/helix-editor/test/test_plugin.py b/plugins/helix-editor/test/test_plugin.py index 8af9c6bc..267f7b54 100644 --- a/plugins/helix-editor/test/test_plugin.py +++ b/plugins/helix-editor/test/test_plugin.py @@ -1,123 +1,133 @@ -import json -import os -import sys -import unittest -from io import StringIO -from unittest.mock import patch - -# Add src to sys.path so we can import plugin -sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../src"))) -import plugin - - -class TestHelixPlugin(unittest.TestCase): - def setUp(self): - self.mock_helix_dir = os.path.join(os.path.dirname(__file__), "mock_helix_dir") - if not os.path.exists(self.mock_helix_dir): - os.makedirs(self.mock_helix_dir) - - def tearDown(self): - import shutil - - if os.path.exists(self.mock_helix_dir): - shutil.rmtree(self.mock_helix_dir) - - @patch("plugin.get_helix_dir") - def test_apply_config_standard(self, mock_get_helix_dir): - mock_get_helix_dir.return_value = self.mock_helix_dir - - args = { - "config": {"theme": "dark", "editor": {"line-number": "relative"}}, - "languages": {"language": [{"name": "python", "auto-format": True}]}, - } - - context = {"dryRun": False} - result = plugin.apply_config(args, context, "req1") - - self.assertTrue(result["success"]) - self.assertTrue(result["changed"]) - - # Verify files were written - self.assertTrue(os.path.exists(os.path.join(self.mock_helix_dir, "config.toml"))) - self.assertTrue(os.path.exists(os.path.join(self.mock_helix_dir, "languages.toml"))) - - config_toml = open(os.path.join(self.mock_helix_dir, "config.toml")).read() - self.assertIn('theme = "dark"', config_toml) - self.assertIn("[editor]", config_toml) - self.assertIn('line-number = "relative"', config_toml) - - languages_toml = open(os.path.join(self.mock_helix_dir, "languages.toml")).read() - self.assertIn("[[language]]", languages_toml) - self.assertIn('name = "python"', languages_toml) - self.assertIn("auto-format = true", languages_toml) - - @patch("plugin.get_helix_dir") - def test_apply_config_dry_run(self, mock_get_helix_dir): - mock_get_helix_dir.return_value = self.mock_helix_dir - - args = {"config": {"theme": "light"}} - context = {"dryRun": True} - - result = plugin.apply_config(args, context, "req2") - self.assertTrue(result["success"]) - self.assertTrue(result["changed"]) - - # Verify file NOT written - self.assertFalse(os.path.exists(os.path.join(self.mock_helix_dir, "config.toml"))) - - @patch("plugin.get_helix_dir") - def test_apply_config_idempotent(self, mock_get_helix_dir): - mock_get_helix_dir.return_value = self.mock_helix_dir - - args = { - "config": {"theme": "dark"}, - "languages": {"language": [{"name": "python", "auto-format": True}]}, - } - context = {"dryRun": False} - - # First apply - result1 = plugin.apply_config(args, context, "req3") - self.assertTrue(result1["success"]) - self.assertTrue(result1["changed"]) - - # Second apply (should be idempotent) - result2 = plugin.apply_config(args, context, "req4") - self.assertTrue(result2["success"]) - self.assertFalse(result2["changed"]) - - @patch("plugin.get_helix_dir") - def test_unwrapped_args(self, mock_get_helix_dir): - mock_get_helix_dir.return_value = self.mock_helix_dir - - # Helix users might pass flat structure - args = { - "theme": "dracula", - "editor": {"cursor-shape": {"insert": "bar"}}, - "language": [{"name": "rust", "auto-format": True}], - } - context = {"dryRun": False} - - result = plugin.apply_config(args, context, "req5") - self.assertTrue(result["success"]) - - config_toml = open(os.path.join(self.mock_helix_dir, "config.toml")).read() - self.assertIn('theme = "dracula"', config_toml) - self.assertIn("[editor.cursor-shape]", config_toml) - self.assertIn('insert = "bar"', config_toml) - - languages_toml = open(os.path.join(self.mock_helix_dir, "languages.toml")).read() - self.assertIn("[[language]]", languages_toml) - self.assertIn('name = "rust"', languages_toml) - - @patch("sys.stdin", StringIO('{"command": "unknown", "requestId": "123"}')) - @patch("sys.stdout", new_callable=StringIO) - def test_unknown_command(self, mock_stdout): - plugin.main() - output = mock_stdout.getvalue() - resp = json.loads(output) - self.assertFalse(resp["success"]) - self.assertIn("Unknown command", resp["error"]) - - -if __name__ == "__main__": - unittest.main() +import json +import os +import sys +import unittest +from io import StringIO +from unittest.mock import patch + +# Add src to sys.path so we can import plugin +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../src"))) +import plugin + + +class TestHelixPlugin(unittest.TestCase): + def setUp(self): + self.mock_helix_dir = os.path.join(os.path.dirname(__file__), "mock_helix_dir") + if not os.path.exists(self.mock_helix_dir): + os.makedirs(self.mock_helix_dir) + + def tearDown(self): + import shutil + + if os.path.exists(self.mock_helix_dir): + shutil.rmtree(self.mock_helix_dir) + + @patch("plugin.get_helix_dir") + def test_apply_config_standard(self, mock_get_helix_dir): + mock_get_helix_dir.return_value = self.mock_helix_dir + + args = { + "config": {"theme": "dark", "editor": {"line-number": "relative"}}, + "languages": {"language": [{"name": "python", "auto-format": True}]}, + } + + context = {"dryRun": False} + result = plugin.apply_config(args, context, "req1") + + self.assertTrue(result["success"]) + self.assertTrue(result["changed"]) + + # Verify files were written + self.assertTrue( + os.path.exists(os.path.join(self.mock_helix_dir, "config.toml")) + ) + self.assertTrue( + os.path.exists(os.path.join(self.mock_helix_dir, "languages.toml")) + ) + + config_toml = open(os.path.join(self.mock_helix_dir, "config.toml")).read() + self.assertIn('theme = "dark"', config_toml) + self.assertIn("[editor]", config_toml) + self.assertIn('line-number = "relative"', config_toml) + + languages_toml = open( + os.path.join(self.mock_helix_dir, "languages.toml") + ).read() + self.assertIn("[[language]]", languages_toml) + self.assertIn('name = "python"', languages_toml) + self.assertIn("auto-format = true", languages_toml) + + @patch("plugin.get_helix_dir") + def test_apply_config_dry_run(self, mock_get_helix_dir): + mock_get_helix_dir.return_value = self.mock_helix_dir + + args = {"config": {"theme": "light"}} + context = {"dryRun": True} + + result = plugin.apply_config(args, context, "req2") + self.assertTrue(result["success"]) + self.assertTrue(result["changed"]) + + # Verify file NOT written + self.assertFalse( + os.path.exists(os.path.join(self.mock_helix_dir, "config.toml")) + ) + + @patch("plugin.get_helix_dir") + def test_apply_config_idempotent(self, mock_get_helix_dir): + mock_get_helix_dir.return_value = self.mock_helix_dir + + args = { + "config": {"theme": "dark"}, + "languages": {"language": [{"name": "python", "auto-format": True}]}, + } + context = {"dryRun": False} + + # First apply + result1 = plugin.apply_config(args, context, "req3") + self.assertTrue(result1["success"]) + self.assertTrue(result1["changed"]) + + # Second apply (should be idempotent) + result2 = plugin.apply_config(args, context, "req4") + self.assertTrue(result2["success"]) + self.assertFalse(result2["changed"]) + + @patch("plugin.get_helix_dir") + def test_unwrapped_args(self, mock_get_helix_dir): + mock_get_helix_dir.return_value = self.mock_helix_dir + + # Helix users might pass flat structure + args = { + "theme": "dracula", + "editor": {"cursor-shape": {"insert": "bar"}}, + "language": [{"name": "rust", "auto-format": True}], + } + context = {"dryRun": False} + + result = plugin.apply_config(args, context, "req5") + self.assertTrue(result["success"]) + + config_toml = open(os.path.join(self.mock_helix_dir, "config.toml")).read() + self.assertIn('theme = "dracula"', config_toml) + self.assertIn("[editor.cursor-shape]", config_toml) + self.assertIn('insert = "bar"', config_toml) + + languages_toml = open( + os.path.join(self.mock_helix_dir, "languages.toml") + ).read() + self.assertIn("[[language]]", languages_toml) + self.assertIn('name = "rust"', languages_toml) + + @patch("sys.stdin", StringIO('{"command": "unknown", "requestId": "123"}')) + @patch("sys.stdout", new_callable=StringIO) + def test_unknown_command(self, mock_stdout): + plugin.main() + output = mock_stdout.getvalue() + resp = json.loads(output) + self.assertFalse(resp["success"]) + self.assertIn("Unknown command", resp["error"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/plugins/irfanview/src/plugin.py b/plugins/irfanview/src/plugin.py index ed83166b..dec78bb8 100644 --- a/plugins/irfanview/src/plugin.py +++ b/plugins/irfanview/src/plugin.py @@ -84,8 +84,15 @@ def apply_settings(settings: dict, dry_run: bool) -> bool: changed = True for k, v in keys.items(): str_k = str(k) - str_v = "1" if isinstance(v, bool) and v else ("0" if isinstance(v, bool) else str(v)) - if not parser.has_option(section, str_k) or parser.get(section, str_k) != str_v: + str_v = ( + "1" + if isinstance(v, bool) and v + else ("0" if isinstance(v, bool) else str(v)) + ) + if ( + not parser.has_option(section, str_k) + or parser.get(section, str_k) != str_v + ): parser.set(section, str_k, str_v) changed = True @@ -97,7 +104,9 @@ def apply_settings(settings: dict, dry_run: bool) -> bool: return True os.makedirs(os.path.dirname(ini_path), exist_ok=True) - fd, temp_path = tempfile.mkstemp(dir=os.path.dirname(ini_path), prefix="i_view.ini.") + fd, temp_path = tempfile.mkstemp( + dir=os.path.dirname(ini_path), prefix="i_view.ini." + ) try: with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as f: parser.write(f, space_around_delimiters=False) @@ -115,7 +124,12 @@ def handle_request(request: dict) -> dict: if cmd == "check_installed": installed = check_installed() - return {"requestId": req_id, "success": True, "changed": False, "data": installed} + return { + "requestId": req_id, + "success": True, + "changed": False, + "data": installed, + } elif cmd == "apply": args = request.get("args", {}) settings = args.get("settings", {}) @@ -124,11 +138,26 @@ def handle_request(request: dict) -> dict: try: changed = apply_settings(settings, dry_run) - return {"requestId": req_id, "success": True, "changed": bool(changed), "data": None} + return { + "requestId": req_id, + "success": True, + "changed": bool(changed), + "data": None, + } except Exception as e: - return {"requestId": req_id, "success": False, "changed": False, "error": str(e)} - - return {"requestId": req_id, "success": False, "changed": False, "error": f"Unknown command: {cmd}"} + return { + "requestId": req_id, + "success": False, + "changed": False, + "error": str(e), + } + + return { + "requestId": req_id, + "success": False, + "changed": False, + "error": f"Unknown command: {cmd}", + } def main(): diff --git a/plugins/irfanview/test/test_irfanview.py b/plugins/irfanview/test/test_irfanview.py index 04b12453..67ac9dd2 100644 --- a/plugins/irfanview/test/test_irfanview.py +++ b/plugins/irfanview/test/test_irfanview.py @@ -36,7 +36,10 @@ def test_check_installed_via_path(self, mock_which): def test_apply_settings_new_file(self): with mock_env() as td: - settings = {"Others": {"ShowAllFiles": True, "ThumbnailSize": 200}, "Language": {"Language": "ENGLISH"}} + settings = { + "Others": {"ShowAllFiles": True, "ThumbnailSize": 200}, + "Language": {"Language": "ENGLISH"}, + } plugin.apply_settings(settings, dry_run=False) ini_path = os.path.join(td, "IrfanView", "i_view64.ini") @@ -92,7 +95,9 @@ def test_apply_settings_dry_run(self): def test_handle_request(self): with mock_env(): # Test check_installed - res = plugin.handle_request({"command": "check_installed", "requestId": "1"}) + res = plugin.handle_request( + {"command": "check_installed", "requestId": "1"} + ) self.assertEqual(res["requestId"], "1") self.assertIn("data", res) diff --git a/plugins/joplin/src/plugin.py b/plugins/joplin/src/plugin.py index 9385f5c8..5329caf8 100644 --- a/plugins/joplin/src/plugin.py +++ b/plugins/joplin/src/plugin.py @@ -109,7 +109,11 @@ def main(): result = handle_apply(settings, dry_run) - response = {"requestId": request_id, "changed": result["changed"], "changes": result["changes"]} + response = { + "requestId": request_id, + "changed": result["changed"], + "changes": result["changes"], + } else: response = {"requestId": request_id, "error": f"Unknown command: {command}"} diff --git a/plugins/keepassxc/src/plugin.py b/plugins/keepassxc/src/plugin.py index ef6b835b..fc186956 100644 --- a/plugins/keepassxc/src/plugin.py +++ b/plugins/keepassxc/src/plugin.py @@ -1,305 +1,319 @@ -import datetime -import json -import os -import re -import shutil -import sys -import uuid - - -def log(msg): - sys.stderr.write(f"[keepassxc-plugin] {msg}\n") - sys.stderr.flush() - - -def get_config_path(): - appdata = os.getenv("APPDATA") - if not appdata: - raise Exception("APPDATA environment variable not found") - return os.path.join(appdata, "KeePassXC", "keepassxc.ini") - - -def read_text(file_path: str) -> str: - if not os.path.exists(file_path): - return "" - try: - with open(file_path, "r", encoding="utf-8") as f: - return f.read() - except (OSError, UnicodeDecodeError) as e: - timestamp = datetime.datetime.now(datetime.timezone.utc).strftime("%Y%m%d%H%M%S") - suffix_str = uuid.uuid4().hex[:8] - backup_path = f"{file_path}.corrupted.{timestamp}.{suffix_str}" - log(f"Config corrupted. Backing up to {backup_path} and starting fresh. Error: {e}") - try: - shutil.move(file_path, backup_path) - except Exception as backup_e: - log(f"Failed to backup corrupted config: {backup_e}") - return "" - - -def write_text(file_path: str, data: str) -> None: - dir_path = os.path.dirname(file_path) - if dir_path: - os.makedirs(dir_path, mode=0o700, exist_ok=True) - tmp_path = file_path + ".tmp" - with open(tmp_path, "w", encoding="utf-8") as f: - f.write(data) - os.replace(tmp_path, file_path) - - -def parse_ini(text: str) -> tuple: - blocks = [] - current_block = {"name": None, "lines": []} - blocks.append(current_block) - has_trailing_newline = text.endswith("\n") - is_crlf = "\r\n" in text - - for line in text.splitlines(): - stripped = line.strip() - if not stripped: - current_block["lines"].append({"type": "empty", "raw": line}) - continue - if stripped.startswith("#") or stripped.startswith(";"): - current_block["lines"].append({"type": "comment", "raw": line}) - continue - - match_section = re.match(r"^\[(.*)\]$", stripped) - if match_section: - section_name = match_section.group(1).strip() - current_block = {"name": section_name, "lines": []} - blocks.append(current_block) - current_block["lines"].append({"type": "section", "raw": line}) - continue - - match_kv = re.match(r"^([^=]+)=(.*)$", stripped) - if match_kv: - key = match_kv.group(1).strip() - val = match_kv.group(2).strip() - current_block["lines"].append({"type": "kv", "raw": line, "key": key, "val": val}) - else: - current_block["lines"].append({"type": "unknown", "raw": line}) - - return blocks, has_trailing_newline, is_crlf - - -def serialize_ini(blocks: list, has_trailing_newline: bool, is_crlf: bool) -> str: - lines = [] - for b in blocks: - for line in b["lines"]: - lines.append(line["raw"]) - newline = "\r\n" if is_crlf else "\n" - res = newline.join(lines) - if has_trailing_newline and res and not res.endswith(newline): - res += newline - return res - - -def format_val(val) -> str: - if val is None: - return "" - if isinstance(val, bool): - return "true" if val else "false" - return str(val) - - -def merge_kv(block: dict, key: str, val) -> bool: - target_val_str = format_val(val) - lower_key = key.lower() - - for line in block["lines"]: - if line["type"] == "kv" and line["key"].lower() == lower_key: - if str(line["val"]) != target_val_str: - indent_match = re.match(r"^(\s*)", line["raw"]) - indent = indent_match.group(1) if indent_match else "" - - eq_match = re.search(r"(\s*=\s*)", line["raw"]) - eq_str = eq_match.group(1) if eq_match else "=" - - old_val_str = str(line.get("val", "")) - suffix = "" - if eq_match: - remainder = line["raw"][eq_match.end() :] - if remainder.startswith(old_val_str): - suffix = remainder[len(old_val_str) :] - - line["val"] = target_val_str - original_key = line.get("key", key) - line["raw"] = f"{indent}{original_key}{eq_str}{target_val_str}{suffix}" - return True - return False - - insert_idx = len(block["lines"]) - while insert_idx > 0 and block["lines"][insert_idx - 1]["type"] == "empty": - insert_idx -= 1 - - block["lines"].insert( - insert_idx, - { - "type": "kv", - "raw": f"{key}={target_val_str}", - "key": key, - "val": target_val_str, - }, - ) - return True - - -def merge_settings(blocks: list, args: dict) -> bool: - changed = False - settings = args.get("settings", {}) - - for section_name, section_settings in settings.items(): - if not isinstance(section_settings, dict): - continue - - block = next((b for b in blocks if b["name"] == section_name), None) - - if not block: - block = {"name": section_name, "lines": []} - if blocks and blocks[-1]["lines"] and blocks[-1]["lines"][-1]["type"] != "empty": - blocks[-1]["lines"].append({"type": "empty", "raw": ""}) - - block["lines"].append({"type": "section", "raw": f"[{section_name}]"}) - blocks.append(block) - changed = True - - for k, v in section_settings.items(): - if merge_kv(block, k, v): - changed = True - - return changed - - -def check_installed(args: dict, request_id: str) -> dict: - installed = False - if shutil.which("KeePassXC.exe") or shutil.which("keepassxc"): - installed = True - else: - program_files = os.getenv("PROGRAMFILES", "C:\\Program Files") - local_appdata = os.getenv("LOCALAPPDATA", "") - - paths_to_check = [ - os.path.join(program_files, "KeePassXC", "KeePassXC.exe"), - os.path.join(local_appdata, "KeePassXC", "KeePassXC.exe") if local_appdata else "", - ] - - for p in paths_to_check: - if p and os.path.exists(p): - installed = True - break - - return { - "requestId": request_id, - "success": True, - "changed": False, - "data": installed, - } - - -def apply_config(args: dict, context: dict, request_id: str) -> dict: - dry_run = context.get("dryRun", False) - - try: - config_path = get_config_path() - current_text = read_text(config_path) - - blocks, has_trailing_newline, is_crlf = parse_ini(current_text) - - if not current_text: - has_trailing_newline = True - - changed = merge_settings(blocks, args) - - if not changed: - return { - "requestId": request_id, - "success": True, - "changed": False, - } - - new_text = serialize_ini(blocks, has_trailing_newline, is_crlf) - - if dry_run: - log(f"Would update {config_path} with new settings") - return { - "requestId": request_id, - "success": True, - "changed": True, - } - - write_text(config_path, new_text) - log(f"Updated KeePassXC config: {config_path}") - - return { - "requestId": request_id, - "success": True, - "changed": True, - } - - except Exception as e: - log(f"Failed to apply config: {e}") - return { - "requestId": request_id, - "success": False, - "changed": False, - "error": str(e), - } - - -def main(): - input_data = sys.stdin.read() - if not input_data: - return - - try: - request = json.loads(input_data) - except json.JSONDecodeError as e: - log(f"Failed to parse request: {e}") - response = { - "requestId": "unknown", - "success": False, - "changed": False, - "error": f"Failed to parse JSON request: {str(e)}", - } - sys.stdout.write(json.dumps(response) + "\n") - sys.stdout.flush() - return - except Exception as e: - log(f"Failed to parse request: {e}") - response = { - "requestId": "unknown", - "success": False, - "changed": False, - "error": f"Failed to parse request: {str(e)}", - } - sys.stdout.write(json.dumps(response) + "\n") - sys.stdout.flush() - return - - request_id = request.get("requestId", "unknown") - command = request.get("command") - args = request.get("args", {}) - context = request.get("context", {}) - - response = { - "requestId": request_id, - "success": False, - "changed": False, - } - - try: - if command == "check_installed": - response = check_installed(args, request_id) - elif command == "apply": - response = apply_config(args, context, request_id) - else: - response["error"] = f"Unknown command: {command}" - except Exception as fatal_err: - response["error"] = f"Internal Script Error: {str(fatal_err)}" - - sys.stdout.write(json.dumps(response) + "\n") - sys.stdout.flush() - - -if __name__ == "__main__": - main() +import datetime +import json +import os +import re +import shutil +import sys +import uuid + + +def log(msg): + sys.stderr.write(f"[keepassxc-plugin] {msg}\n") + sys.stderr.flush() + + +def get_config_path(): + appdata = os.getenv("APPDATA") + if not appdata: + raise Exception("APPDATA environment variable not found") + return os.path.join(appdata, "KeePassXC", "keepassxc.ini") + + +def read_text(file_path: str) -> str: + if not os.path.exists(file_path): + return "" + try: + with open(file_path, "r", encoding="utf-8") as f: + return f.read() + except (OSError, UnicodeDecodeError) as e: + timestamp = datetime.datetime.now(datetime.timezone.utc).strftime( + "%Y%m%d%H%M%S" + ) + suffix_str = uuid.uuid4().hex[:8] + backup_path = f"{file_path}.corrupted.{timestamp}.{suffix_str}" + log( + f"Config corrupted. Backing up to {backup_path} and starting fresh. Error: {e}" + ) + try: + shutil.move(file_path, backup_path) + except Exception as backup_e: + log(f"Failed to backup corrupted config: {backup_e}") + return "" + + +def write_text(file_path: str, data: str) -> None: + dir_path = os.path.dirname(file_path) + if dir_path: + os.makedirs(dir_path, mode=0o700, exist_ok=True) + tmp_path = file_path + ".tmp" + with open(tmp_path, "w", encoding="utf-8") as f: + f.write(data) + os.replace(tmp_path, file_path) + + +def parse_ini(text: str) -> tuple: + blocks = [] + current_block = {"name": None, "lines": []} + blocks.append(current_block) + has_trailing_newline = text.endswith("\n") + is_crlf = "\r\n" in text + + for line in text.splitlines(): + stripped = line.strip() + if not stripped: + current_block["lines"].append({"type": "empty", "raw": line}) + continue + if stripped.startswith("#") or stripped.startswith(";"): + current_block["lines"].append({"type": "comment", "raw": line}) + continue + + match_section = re.match(r"^\[(.*)\]$", stripped) + if match_section: + section_name = match_section.group(1).strip() + current_block = {"name": section_name, "lines": []} + blocks.append(current_block) + current_block["lines"].append({"type": "section", "raw": line}) + continue + + match_kv = re.match(r"^([^=]+)=(.*)$", stripped) + if match_kv: + key = match_kv.group(1).strip() + val = match_kv.group(2).strip() + current_block["lines"].append( + {"type": "kv", "raw": line, "key": key, "val": val} + ) + else: + current_block["lines"].append({"type": "unknown", "raw": line}) + + return blocks, has_trailing_newline, is_crlf + + +def serialize_ini(blocks: list, has_trailing_newline: bool, is_crlf: bool) -> str: + lines = [] + for b in blocks: + for line in b["lines"]: + lines.append(line["raw"]) + newline = "\r\n" if is_crlf else "\n" + res = newline.join(lines) + if has_trailing_newline and res and not res.endswith(newline): + res += newline + return res + + +def format_val(val) -> str: + if val is None: + return "" + if isinstance(val, bool): + return "true" if val else "false" + return str(val) + + +def merge_kv(block: dict, key: str, val) -> bool: + target_val_str = format_val(val) + lower_key = key.lower() + + for line in block["lines"]: + if line["type"] == "kv" and line["key"].lower() == lower_key: + if str(line["val"]) != target_val_str: + indent_match = re.match(r"^(\s*)", line["raw"]) + indent = indent_match.group(1) if indent_match else "" + + eq_match = re.search(r"(\s*=\s*)", line["raw"]) + eq_str = eq_match.group(1) if eq_match else "=" + + old_val_str = str(line.get("val", "")) + suffix = "" + if eq_match: + remainder = line["raw"][eq_match.end() :] + if remainder.startswith(old_val_str): + suffix = remainder[len(old_val_str) :] + + line["val"] = target_val_str + original_key = line.get("key", key) + line["raw"] = f"{indent}{original_key}{eq_str}{target_val_str}{suffix}" + return True + return False + + insert_idx = len(block["lines"]) + while insert_idx > 0 and block["lines"][insert_idx - 1]["type"] == "empty": + insert_idx -= 1 + + block["lines"].insert( + insert_idx, + { + "type": "kv", + "raw": f"{key}={target_val_str}", + "key": key, + "val": target_val_str, + }, + ) + return True + + +def merge_settings(blocks: list, args: dict) -> bool: + changed = False + settings = args.get("settings", {}) + + for section_name, section_settings in settings.items(): + if not isinstance(section_settings, dict): + continue + + block = next((b for b in blocks if b["name"] == section_name), None) + + if not block: + block = {"name": section_name, "lines": []} + if ( + blocks + and blocks[-1]["lines"] + and blocks[-1]["lines"][-1]["type"] != "empty" + ): + blocks[-1]["lines"].append({"type": "empty", "raw": ""}) + + block["lines"].append({"type": "section", "raw": f"[{section_name}]"}) + blocks.append(block) + changed = True + + for k, v in section_settings.items(): + if merge_kv(block, k, v): + changed = True + + return changed + + +def check_installed(args: dict, request_id: str) -> dict: + installed = False + if shutil.which("KeePassXC.exe") or shutil.which("keepassxc"): + installed = True + else: + program_files = os.getenv("PROGRAMFILES", "C:\\Program Files") + local_appdata = os.getenv("LOCALAPPDATA", "") + + paths_to_check = [ + os.path.join(program_files, "KeePassXC", "KeePassXC.exe"), + ( + os.path.join(local_appdata, "KeePassXC", "KeePassXC.exe") + if local_appdata + else "" + ), + ] + + for p in paths_to_check: + if p and os.path.exists(p): + installed = True + break + + return { + "requestId": request_id, + "success": True, + "changed": False, + "data": installed, + } + + +def apply_config(args: dict, context: dict, request_id: str) -> dict: + dry_run = context.get("dryRun", False) + + try: + config_path = get_config_path() + current_text = read_text(config_path) + + blocks, has_trailing_newline, is_crlf = parse_ini(current_text) + + if not current_text: + has_trailing_newline = True + + changed = merge_settings(blocks, args) + + if not changed: + return { + "requestId": request_id, + "success": True, + "changed": False, + } + + new_text = serialize_ini(blocks, has_trailing_newline, is_crlf) + + if dry_run: + log(f"Would update {config_path} with new settings") + return { + "requestId": request_id, + "success": True, + "changed": True, + } + + write_text(config_path, new_text) + log(f"Updated KeePassXC config: {config_path}") + + return { + "requestId": request_id, + "success": True, + "changed": True, + } + + except Exception as e: + log(f"Failed to apply config: {e}") + return { + "requestId": request_id, + "success": False, + "changed": False, + "error": str(e), + } + + +def main(): + input_data = sys.stdin.read() + if not input_data: + return + + try: + request = json.loads(input_data) + except json.JSONDecodeError as e: + log(f"Failed to parse request: {e}") + response = { + "requestId": "unknown", + "success": False, + "changed": False, + "error": f"Failed to parse JSON request: {str(e)}", + } + sys.stdout.write(json.dumps(response) + "\n") + sys.stdout.flush() + return + except Exception as e: + log(f"Failed to parse request: {e}") + response = { + "requestId": "unknown", + "success": False, + "changed": False, + "error": f"Failed to parse request: {str(e)}", + } + sys.stdout.write(json.dumps(response) + "\n") + sys.stdout.flush() + return + + request_id = request.get("requestId", "unknown") + command = request.get("command") + args = request.get("args", {}) + context = request.get("context", {}) + + response = { + "requestId": request_id, + "success": False, + "changed": False, + } + + try: + if command == "check_installed": + response = check_installed(args, request_id) + elif command == "apply": + response = apply_config(args, context, request_id) + else: + response["error"] = f"Unknown command: {command}" + except Exception as fatal_err: + response["error"] = f"Internal Script Error: {str(fatal_err)}" + + sys.stdout.write(json.dumps(response) + "\n") + sys.stdout.flush() + + +if __name__ == "__main__": + main() diff --git a/plugins/keepassxc/test/test_keepassxc.py b/plugins/keepassxc/test/test_keepassxc.py index 17b7a020..7666ef72 100644 --- a/plugins/keepassxc/test/test_keepassxc.py +++ b/plugins/keepassxc/test/test_keepassxc.py @@ -1,139 +1,139 @@ -import os -import sys -import unittest -from unittest.mock import mock_open, patch - -_src_path = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "src")) -sys.path.append(_src_path) -try: - import plugin -finally: - sys.path.remove(_src_path) - - -class TestKeePassXCPlugin(unittest.TestCase): - def test_parse_and_serialize_ini(self): - text = "[General]\nAutoSaveOnExit=true\n# comment\n[Security]\nLockDatabaseIdle=false\n" - blocks, has_newline, is_crlf = plugin.parse_ini(text) - self.assertEqual(len(blocks), 3) - self.assertIsNone(blocks[0]["name"]) - self.assertEqual(blocks[1]["name"], "General") - self.assertEqual(blocks[2]["name"], "Security") - - output = plugin.serialize_ini(blocks, has_newline, is_crlf) - self.assertEqual(output, text) - - def test_merge_settings(self): - text = "[General]\nAutoSaveOnExit=false\n" - blocks, has_newline, is_crlf = plugin.parse_ini(text) - - args = { - "settings": { - "General": { - "AutoSaveOnExit": True, - "AutoSaveAfterEveryChange": True, - }, - "Security": {"LockDatabaseIdle": True}, - } - } - - changed = plugin.merge_settings(blocks, args) - self.assertTrue(changed) - - output = plugin.serialize_ini(blocks, has_newline, is_crlf) - - expected = ( - "[General]\nAutoSaveOnExit=true\nAutoSaveAfterEveryChange=true\n\n[Security]\nLockDatabaseIdle=true\n" - ) - self.assertEqual(output, expected) - - def test_merge_no_change(self): - text = "[Security]\nLockDatabaseIdleSeconds=300\n" - blocks, has_newline, is_crlf = plugin.parse_ini(text) - - args = {"settings": {"Security": {"LockDatabaseIdleSeconds": 300}}} - - changed = plugin.merge_settings(blocks, args) - self.assertFalse(changed) - - @patch("plugin.shutil.which") - @patch("plugin.os.path.exists") - def test_check_installed_via_path(self, mock_exists, mock_which): - mock_which.return_value = "C:\\path\\KeePassXC.exe" - res = plugin.check_installed({}, "req-1") - self.assertTrue(res["success"]) - self.assertTrue(res["data"]) - - @patch("plugin.shutil.which") - @patch("plugin.os.path.exists") - def test_check_installed_via_program_files(self, mock_exists, mock_which): - mock_which.return_value = None - mock_exists.return_value = True - res = plugin.check_installed({}, "req-2") - self.assertTrue(res["success"]) - self.assertTrue(res["data"]) - - @patch("plugin.get_config_path") - @patch("plugin.read_text") - @patch("plugin.write_text") - def test_apply_config_real_run(self, mock_write, mock_read, mock_get_path): - mock_get_path.return_value = "dummy.ini" - mock_read.return_value = "[General]\nAutoSaveOnExit=false\n" - - args = {"settings": {"General": {"AutoSaveOnExit": True}}} - context = {"dryRun": False} - - res = plugin.apply_config(args, context, "req-3") - self.assertTrue(res["success"]) - self.assertTrue(res["changed"]) - self.assertEqual(res["requestId"], "req-3") - mock_write.assert_called_once_with("dummy.ini", "[General]\nAutoSaveOnExit=true\n") - - @patch("plugin.get_config_path") - @patch("plugin.read_text") - @patch("plugin.write_text") - def test_apply_config_dry_run(self, mock_write, mock_read, mock_get_path): - mock_get_path.return_value = "dummy.ini" - mock_read.return_value = "[General]\nAutoSaveOnExit=false\n" - - args = {"settings": {"General": {"AutoSaveOnExit": True}}} - context = {"dryRun": True} - - res = plugin.apply_config(args, context, "req-4") - self.assertTrue(res["success"]) - self.assertTrue(res["changed"]) - mock_write.assert_not_called() - - @patch("plugin.get_config_path") - @patch("plugin.read_text") - @patch("plugin.write_text") - def test_apply_config_no_change(self, mock_write, mock_read, mock_get_path): - mock_get_path.return_value = "dummy.ini" - mock_read.return_value = "[General]\nAutoSaveOnExit=true\n" - - args = {"settings": {"General": {"AutoSaveOnExit": True}}} - context = {"dryRun": False} - - res = plugin.apply_config(args, context, "req-5") - self.assertTrue(res["success"]) - self.assertFalse(res["changed"]) - mock_write.assert_not_called() - - @patch("plugin.os.makedirs") - @patch("plugin.os.replace") - def test_write_text(self, mock_replace, mock_makedirs): - file_path = "dummy/path.ini" - data = "[General]\nAutoSaveOnExit=true\n" - m_open = mock_open() - with patch("plugin.open", m_open): - plugin.write_text(file_path, data) - - mock_makedirs.assert_called_once_with("dummy", mode=0o700, exist_ok=True) - m_open.assert_called_once_with(file_path + ".tmp", "w", encoding="utf-8") - handle = m_open() - handle.write.assert_called_with(data) - mock_replace.assert_called_once_with(file_path + ".tmp", file_path) - - -if __name__ == "__main__": - unittest.main() +import os +import sys +import unittest +from unittest.mock import mock_open, patch + +_src_path = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "src")) +sys.path.append(_src_path) +try: + import plugin +finally: + sys.path.remove(_src_path) + + +class TestKeePassXCPlugin(unittest.TestCase): + def test_parse_and_serialize_ini(self): + text = "[General]\nAutoSaveOnExit=true\n# comment\n[Security]\nLockDatabaseIdle=false\n" + blocks, has_newline, is_crlf = plugin.parse_ini(text) + self.assertEqual(len(blocks), 3) + self.assertIsNone(blocks[0]["name"]) + self.assertEqual(blocks[1]["name"], "General") + self.assertEqual(blocks[2]["name"], "Security") + + output = plugin.serialize_ini(blocks, has_newline, is_crlf) + self.assertEqual(output, text) + + def test_merge_settings(self): + text = "[General]\nAutoSaveOnExit=false\n" + blocks, has_newline, is_crlf = plugin.parse_ini(text) + + args = { + "settings": { + "General": { + "AutoSaveOnExit": True, + "AutoSaveAfterEveryChange": True, + }, + "Security": {"LockDatabaseIdle": True}, + } + } + + changed = plugin.merge_settings(blocks, args) + self.assertTrue(changed) + + output = plugin.serialize_ini(blocks, has_newline, is_crlf) + + expected = "[General]\nAutoSaveOnExit=true\nAutoSaveAfterEveryChange=true\n\n[Security]\nLockDatabaseIdle=true\n" + self.assertEqual(output, expected) + + def test_merge_no_change(self): + text = "[Security]\nLockDatabaseIdleSeconds=300\n" + blocks, has_newline, is_crlf = plugin.parse_ini(text) + + args = {"settings": {"Security": {"LockDatabaseIdleSeconds": 300}}} + + changed = plugin.merge_settings(blocks, args) + self.assertFalse(changed) + + @patch("plugin.shutil.which") + @patch("plugin.os.path.exists") + def test_check_installed_via_path(self, mock_exists, mock_which): + mock_which.return_value = "C:\\path\\KeePassXC.exe" + res = plugin.check_installed({}, "req-1") + self.assertTrue(res["success"]) + self.assertTrue(res["data"]) + + @patch("plugin.shutil.which") + @patch("plugin.os.path.exists") + def test_check_installed_via_program_files(self, mock_exists, mock_which): + mock_which.return_value = None + mock_exists.return_value = True + res = plugin.check_installed({}, "req-2") + self.assertTrue(res["success"]) + self.assertTrue(res["data"]) + + @patch("plugin.get_config_path") + @patch("plugin.read_text") + @patch("plugin.write_text") + def test_apply_config_real_run(self, mock_write, mock_read, mock_get_path): + mock_get_path.return_value = "dummy.ini" + mock_read.return_value = "[General]\nAutoSaveOnExit=false\n" + + args = {"settings": {"General": {"AutoSaveOnExit": True}}} + context = {"dryRun": False} + + res = plugin.apply_config(args, context, "req-3") + self.assertTrue(res["success"]) + self.assertTrue(res["changed"]) + self.assertEqual(res["requestId"], "req-3") + mock_write.assert_called_once_with( + "dummy.ini", "[General]\nAutoSaveOnExit=true\n" + ) + + @patch("plugin.get_config_path") + @patch("plugin.read_text") + @patch("plugin.write_text") + def test_apply_config_dry_run(self, mock_write, mock_read, mock_get_path): + mock_get_path.return_value = "dummy.ini" + mock_read.return_value = "[General]\nAutoSaveOnExit=false\n" + + args = {"settings": {"General": {"AutoSaveOnExit": True}}} + context = {"dryRun": True} + + res = plugin.apply_config(args, context, "req-4") + self.assertTrue(res["success"]) + self.assertTrue(res["changed"]) + mock_write.assert_not_called() + + @patch("plugin.get_config_path") + @patch("plugin.read_text") + @patch("plugin.write_text") + def test_apply_config_no_change(self, mock_write, mock_read, mock_get_path): + mock_get_path.return_value = "dummy.ini" + mock_read.return_value = "[General]\nAutoSaveOnExit=true\n" + + args = {"settings": {"General": {"AutoSaveOnExit": True}}} + context = {"dryRun": False} + + res = plugin.apply_config(args, context, "req-5") + self.assertTrue(res["success"]) + self.assertFalse(res["changed"]) + mock_write.assert_not_called() + + @patch("plugin.os.makedirs") + @patch("plugin.os.replace") + def test_write_text(self, mock_replace, mock_makedirs): + file_path = "dummy/path.ini" + data = "[General]\nAutoSaveOnExit=true\n" + m_open = mock_open() + with patch("plugin.open", m_open): + plugin.write_text(file_path, data) + + mock_makedirs.assert_called_once_with("dummy", mode=0o700, exist_ok=True) + m_open.assert_called_once_with(file_path + ".tmp", "w", encoding="utf-8") + handle = m_open() + handle.write.assert_called_with(data) + mock_replace.assert_called_once_with(file_path + ".tmp", file_path) + + +if __name__ == "__main__": + unittest.main() diff --git a/plugins/lazydocker/src/plugin.py b/plugins/lazydocker/src/plugin.py index 4b158143..76b8d5c9 100644 --- a/plugins/lazydocker/src/plugin.py +++ b/plugins/lazydocker/src/plugin.py @@ -1,191 +1,214 @@ -# /// script -# requires-python = ">=3.10" -# dependencies = [ -# "pyyaml", -# ] -# /// - -import copy -import json -import os -import shutil -import sys -import tempfile -import uuid - -try: - import yaml -except ImportError: - pass - - -def deep_merge(dict1, dict2): - """ - Deep merges dict2 into dict1. - Returns a tuple (merged_dict, changed_bool) - """ - merged = copy.deepcopy(dict1) - changed = False - - for key, value in dict2.items(): - if key in merged and isinstance(merged[key], dict) and isinstance(value, dict): - # Recurse - sub_merged, sub_changed = deep_merge(merged[key], value) - merged[key] = sub_merged - if sub_changed: - changed = True - else: - if key not in merged or merged[key] != value: - merged[key] = copy.deepcopy(value) - changed = True - - return merged, changed - - -def get_config_path(): - appdata = os.environ.get("APPDATA", "") - if not appdata: - # Fallback for testing if APPDATA is not set - appdata = os.path.expanduser("~\\AppData\\Roaming") - return os.path.join(appdata, "lazydocker", "config.yml") - - -def check_installed(args, request_id): - installed = shutil.which("lazydocker.exe") is not None - # On linux/mac for testing it might be 'lazydocker' - if not installed: - installed = shutil.which("lazydocker") is not None - - return {"requestId": request_id, "success": True, "changed": False, "data": installed} - - -def apply_config(args, context, request_id): - settings = args.get("settings", {}) - if not settings: - return {"requestId": request_id, "success": True, "changed": False, "data": None} - - config_path = get_config_path() - dry_run = context.get("dryRun", False) - - # Read existing config - existing_config = {} - if os.path.exists(config_path): - try: - with open(config_path, "r", encoding="utf-8") as f: - parsed = yaml.safe_load(f) - if isinstance(parsed, dict): - existing_config = parsed - except Exception as e: - sys.stderr.write( - "[lazydocker-plugin] Warning: Failed to parse " - f"existing config ({str(e)}). Backing up and starting fresh.\n" - ) - - # Backup corrupted config - backup_path = f"{config_path}.{uuid.uuid4().hex[:8]}.bak" - try: - shutil.copy2(config_path, backup_path) - except Exception as backup_e: - sys.stderr.write(f"[lazydocker-plugin] Warning: Failed to create backup: {str(backup_e)}\n") - - # Deep merge - merged_config, changed = deep_merge(existing_config, settings) - - if not changed: - return {"requestId": request_id, "success": True, "changed": False, "data": None} - - if not dry_run: - try: - config_dir = os.path.dirname(config_path) - if not os.path.exists(config_dir): - os.makedirs(config_dir, mode=0o700, exist_ok=True) - - fd, temp_path = tempfile.mkstemp(prefix="lazydocker-", suffix=".tmp", dir=os.path.dirname(config_path)) - try: - with os.fdopen(fd, "w", encoding="utf-8") as f: - yaml.dump(merged_config, f, default_flow_style=False, sort_keys=False) - os.replace(temp_path, config_path) - except BaseException: - os.unlink(temp_path) - raise - sys.stderr.write(f"[lazydocker-plugin] Updated config at {config_path}\n") - except Exception as e: - return { - "requestId": request_id, - "success": False, - "changed": False, - "data": None, - "error": f"Failed to write config: {str(e)}", - } - else: - sys.stderr.write(f"[lazydocker-plugin] Would update {config_path} with new merged config\n") - - return {"requestId": request_id, "success": True, "changed": True, "data": None} - - -def main(): - try: - raw_input = sys.stdin.read().strip() - if not raw_input: - print( - json.dumps( - { - "requestId": "unknown", - "success": False, - "changed": False, - "data": None, - "error": "Empty input received via stdin", - } - ) - ) - sys.stdout.flush() - return - - request = json.loads(raw_input) - command = request.get("command") - args = request.get("args", {}) - context = request.get("context", {}) - request_id = request.get("requestId", "unknown") - - try: - if command == "check_installed": - response = check_installed(args, request_id) - elif command == "apply": - response = apply_config(args, context, request_id) - else: - response = { - "requestId": request_id, - "success": False, - "changed": False, - "data": None, - "error": f"Unknown command: {command}", - } - except Exception as inner_e: - sys.stderr.write(f"[lazydocker-plugin] Command Error: {str(inner_e)}\n") - response = { - "requestId": request_id, - "success": False, - "changed": False, - "data": None, - "error": f"Internal Script Error: {str(inner_e)}", - } - - print(json.dumps(response)) - sys.stdout.flush() - except Exception as e: - sys.stderr.write(f"[lazydocker-plugin] Error: {str(e)}\n") - print( - json.dumps( - { - "requestId": "unknown", - "success": False, - "changed": False, - "data": None, - "error": f"Plugin crashed: {str(e)}", - } - ) - ) - - -if __name__ == "__main__": - main() +# /// script +# requires-python = ">=3.10" +# dependencies = [ +# "pyyaml", +# ] +# /// + +import copy +import json +import os +import shutil +import sys +import tempfile +import uuid + +try: + import yaml +except ImportError: + pass + + +def deep_merge(dict1, dict2): + """ + Deep merges dict2 into dict1. + Returns a tuple (merged_dict, changed_bool) + """ + merged = copy.deepcopy(dict1) + changed = False + + for key, value in dict2.items(): + if key in merged and isinstance(merged[key], dict) and isinstance(value, dict): + # Recurse + sub_merged, sub_changed = deep_merge(merged[key], value) + merged[key] = sub_merged + if sub_changed: + changed = True + else: + if key not in merged or merged[key] != value: + merged[key] = copy.deepcopy(value) + changed = True + + return merged, changed + + +def get_config_path(): + appdata = os.environ.get("APPDATA", "") + if not appdata: + # Fallback for testing if APPDATA is not set + appdata = os.path.expanduser("~\\AppData\\Roaming") + return os.path.join(appdata, "lazydocker", "config.yml") + + +def check_installed(args, request_id): + installed = shutil.which("lazydocker.exe") is not None + # On linux/mac for testing it might be 'lazydocker' + if not installed: + installed = shutil.which("lazydocker") is not None + + return { + "requestId": request_id, + "success": True, + "changed": False, + "data": installed, + } + + +def apply_config(args, context, request_id): + settings = args.get("settings", {}) + if not settings: + return { + "requestId": request_id, + "success": True, + "changed": False, + "data": None, + } + + config_path = get_config_path() + dry_run = context.get("dryRun", False) + + # Read existing config + existing_config = {} + if os.path.exists(config_path): + try: + with open(config_path, "r", encoding="utf-8") as f: + parsed = yaml.safe_load(f) + if isinstance(parsed, dict): + existing_config = parsed + except Exception as e: + sys.stderr.write( + "[lazydocker-plugin] Warning: Failed to parse " + f"existing config ({str(e)}). Backing up and starting fresh.\n" + ) + + # Backup corrupted config + backup_path = f"{config_path}.{uuid.uuid4().hex[:8]}.bak" + try: + shutil.copy2(config_path, backup_path) + except Exception as backup_e: + sys.stderr.write( + f"[lazydocker-plugin] Warning: Failed to create backup: {str(backup_e)}\n" + ) + + # Deep merge + merged_config, changed = deep_merge(existing_config, settings) + + if not changed: + return { + "requestId": request_id, + "success": True, + "changed": False, + "data": None, + } + + if not dry_run: + try: + config_dir = os.path.dirname(config_path) + if not os.path.exists(config_dir): + os.makedirs(config_dir, mode=0o700, exist_ok=True) + + fd, temp_path = tempfile.mkstemp( + prefix="lazydocker-", suffix=".tmp", dir=os.path.dirname(config_path) + ) + try: + with os.fdopen(fd, "w", encoding="utf-8") as f: + yaml.dump( + merged_config, f, default_flow_style=False, sort_keys=False + ) + os.replace(temp_path, config_path) + except BaseException: + os.unlink(temp_path) + raise + sys.stderr.write(f"[lazydocker-plugin] Updated config at {config_path}\n") + except Exception as e: + return { + "requestId": request_id, + "success": False, + "changed": False, + "data": None, + "error": f"Failed to write config: {str(e)}", + } + else: + sys.stderr.write( + f"[lazydocker-plugin] Would update {config_path} with new merged config\n" + ) + + return {"requestId": request_id, "success": True, "changed": True, "data": None} + + +def main(): + try: + raw_input = sys.stdin.read().strip() + if not raw_input: + print( + json.dumps( + { + "requestId": "unknown", + "success": False, + "changed": False, + "data": None, + "error": "Empty input received via stdin", + } + ) + ) + sys.stdout.flush() + return + + request = json.loads(raw_input) + command = request.get("command") + args = request.get("args", {}) + context = request.get("context", {}) + request_id = request.get("requestId", "unknown") + + try: + if command == "check_installed": + response = check_installed(args, request_id) + elif command == "apply": + response = apply_config(args, context, request_id) + else: + response = { + "requestId": request_id, + "success": False, + "changed": False, + "data": None, + "error": f"Unknown command: {command}", + } + except Exception as inner_e: + sys.stderr.write(f"[lazydocker-plugin] Command Error: {str(inner_e)}\n") + response = { + "requestId": request_id, + "success": False, + "changed": False, + "data": None, + "error": f"Internal Script Error: {str(inner_e)}", + } + + print(json.dumps(response)) + sys.stdout.flush() + except Exception as e: + sys.stderr.write(f"[lazydocker-plugin] Error: {str(e)}\n") + print( + json.dumps( + { + "requestId": "unknown", + "success": False, + "changed": False, + "data": None, + "error": f"Plugin crashed: {str(e)}", + } + ) + ) + + +if __name__ == "__main__": + main() diff --git a/plugins/lazydocker/test/test_lazydocker.py b/plugins/lazydocker/test/test_lazydocker.py index 2f861243..239c76d4 100644 --- a/plugins/lazydocker/test/test_lazydocker.py +++ b/plugins/lazydocker/test/test_lazydocker.py @@ -1,127 +1,135 @@ -import importlib.util -import json -import os -import shutil -import tempfile -import unittest -from io import StringIO -from pathlib import Path -from unittest.mock import patch - -try: - import yaml -except ImportError: - yaml = None - -plugin_path = Path(__file__).parent.parent / "src" / "plugin.py" -spec = importlib.util.spec_from_file_location("plugin", plugin_path) -plugin = importlib.util.module_from_spec(spec) -spec.loader.exec_module(plugin) - - -class TestLazyDockerPlugin(unittest.TestCase): - def setUp(self): - self.temp_dir = tempfile.mkdtemp() - self.config_path = os.path.join(self.temp_dir, "lazydocker", "config.yml") - - def tearDown(self): - shutil.rmtree(self.temp_dir) - - @unittest.skipIf(yaml is None, "PyYAML not installed") - def test_deep_merge(self): - dict1 = {"gui": {"theme": {"activeBorderColor": ["blue"]}}, "logs": {"timestamps": False}} - dict2 = {"gui": {"theme": {"activeBorderColor": ["green", "bold"]}, "scrollHeight": 3}} - - merged, changed = plugin.deep_merge(dict1, dict2) - - self.assertTrue(changed) - self.assertEqual(merged["gui"]["theme"]["activeBorderColor"], ["green", "bold"]) - self.assertEqual(merged["gui"]["scrollHeight"], 3) - self.assertEqual(merged["logs"]["timestamps"], False) - - @unittest.skipIf(yaml is None, "PyYAML not installed") - def test_deep_merge_no_change(self): - dict1 = {"gui": {"theme": {"activeBorderColor": ["blue"]}}} - dict2 = {"gui": {"theme": {"activeBorderColor": ["blue"]}}} - - merged, changed = plugin.deep_merge(dict1, dict2) - - self.assertFalse(changed) - - @patch.object(plugin.shutil, "which") - def test_check_installed_true(self, mock_which): - mock_which.return_value = "/usr/bin/lazydocker" - response = plugin.check_installed({}, "req-1") - self.assertTrue(response["success"]) - self.assertTrue(response["data"]) - self.assertEqual(response["requestId"], "req-1") - - @patch.object(plugin.shutil, "which") - def test_check_installed_false(self, mock_which): - mock_which.return_value = None - response = plugin.check_installed({}, "req-2") - self.assertTrue(response["success"]) - self.assertFalse(response["data"]) - - @patch.object(plugin, "get_config_path") - @unittest.skipIf(yaml is None, "PyYAML not installed") - def test_apply_config_creates_new(self, mock_get_path): - mock_get_path.return_value = self.config_path - - args = {"settings": {"gui": {"language": "auto", "returnImmediately": False}}} - response = plugin.apply_config(args, {}, "req-3") - - self.assertTrue(response["success"]) - self.assertTrue(response["changed"]) - - # Verify file contents - with open(self.config_path, "r", encoding="utf-8") as f: - data = yaml.safe_load(f) - - self.assertEqual(data["gui"]["language"], "auto") - self.assertEqual(data["gui"]["returnImmediately"], False) - - @patch.object(plugin, "get_config_path") - @unittest.skipIf(yaml is None, "PyYAML not installed") - def test_apply_config_idempotency(self, mock_get_path): - mock_get_path.return_value = self.config_path - - args = {"settings": {"logs": {"since": "60m"}}} - plugin.apply_config(args, {}, "req-4a") - - response = plugin.apply_config(args, {}, "req-4b") - - self.assertTrue(response["success"]) - self.assertFalse(response["changed"]) - - @patch.object(plugin, "get_config_path") - @unittest.skipIf(yaml is None, "PyYAML not installed") - def test_apply_config_dry_run(self, mock_get_path): - mock_get_path.return_value = self.config_path - - args = {"settings": {"stats": {"graphs": True}}} - context = {"dryRun": True} - response = plugin.apply_config(args, context, "req-5") - - self.assertTrue(response["success"]) - self.assertTrue(response["changed"]) - self.assertFalse(os.path.exists(self.config_path)) - - @patch("sys.stdin", new_callable=StringIO) - @patch("sys.stdout", new_callable=StringIO) - def test_invalid_json(self, mock_stdout, mock_stdin): - mock_stdin.write("INVALID { JSON") - mock_stdin.seek(0) - - plugin.main() - - output = mock_stdout.getvalue() - response = json.loads(output) - - self.assertFalse(response["success"]) - self.assertIn("Expecting value", response["error"]) - self.assertEqual(response["requestId"], "unknown") - - -if __name__ == "__main__": - unittest.main() +import importlib.util +import json +import os +import shutil +import tempfile +import unittest +from io import StringIO +from pathlib import Path +from unittest.mock import patch + +try: + import yaml +except ImportError: + yaml = None + +plugin_path = Path(__file__).parent.parent / "src" / "plugin.py" +spec = importlib.util.spec_from_file_location("plugin", plugin_path) +plugin = importlib.util.module_from_spec(spec) +spec.loader.exec_module(plugin) + + +class TestLazyDockerPlugin(unittest.TestCase): + def setUp(self): + self.temp_dir = tempfile.mkdtemp() + self.config_path = os.path.join(self.temp_dir, "lazydocker", "config.yml") + + def tearDown(self): + shutil.rmtree(self.temp_dir) + + @unittest.skipIf(yaml is None, "PyYAML not installed") + def test_deep_merge(self): + dict1 = { + "gui": {"theme": {"activeBorderColor": ["blue"]}}, + "logs": {"timestamps": False}, + } + dict2 = { + "gui": { + "theme": {"activeBorderColor": ["green", "bold"]}, + "scrollHeight": 3, + } + } + + merged, changed = plugin.deep_merge(dict1, dict2) + + self.assertTrue(changed) + self.assertEqual(merged["gui"]["theme"]["activeBorderColor"], ["green", "bold"]) + self.assertEqual(merged["gui"]["scrollHeight"], 3) + self.assertEqual(merged["logs"]["timestamps"], False) + + @unittest.skipIf(yaml is None, "PyYAML not installed") + def test_deep_merge_no_change(self): + dict1 = {"gui": {"theme": {"activeBorderColor": ["blue"]}}} + dict2 = {"gui": {"theme": {"activeBorderColor": ["blue"]}}} + + merged, changed = plugin.deep_merge(dict1, dict2) + + self.assertFalse(changed) + + @patch.object(plugin.shutil, "which") + def test_check_installed_true(self, mock_which): + mock_which.return_value = "/usr/bin/lazydocker" + response = plugin.check_installed({}, "req-1") + self.assertTrue(response["success"]) + self.assertTrue(response["data"]) + self.assertEqual(response["requestId"], "req-1") + + @patch.object(plugin.shutil, "which") + def test_check_installed_false(self, mock_which): + mock_which.return_value = None + response = plugin.check_installed({}, "req-2") + self.assertTrue(response["success"]) + self.assertFalse(response["data"]) + + @patch.object(plugin, "get_config_path") + @unittest.skipIf(yaml is None, "PyYAML not installed") + def test_apply_config_creates_new(self, mock_get_path): + mock_get_path.return_value = self.config_path + + args = {"settings": {"gui": {"language": "auto", "returnImmediately": False}}} + response = plugin.apply_config(args, {}, "req-3") + + self.assertTrue(response["success"]) + self.assertTrue(response["changed"]) + + # Verify file contents + with open(self.config_path, "r", encoding="utf-8") as f: + data = yaml.safe_load(f) + + self.assertEqual(data["gui"]["language"], "auto") + self.assertEqual(data["gui"]["returnImmediately"], False) + + @patch.object(plugin, "get_config_path") + @unittest.skipIf(yaml is None, "PyYAML not installed") + def test_apply_config_idempotency(self, mock_get_path): + mock_get_path.return_value = self.config_path + + args = {"settings": {"logs": {"since": "60m"}}} + plugin.apply_config(args, {}, "req-4a") + + response = plugin.apply_config(args, {}, "req-4b") + + self.assertTrue(response["success"]) + self.assertFalse(response["changed"]) + + @patch.object(plugin, "get_config_path") + @unittest.skipIf(yaml is None, "PyYAML not installed") + def test_apply_config_dry_run(self, mock_get_path): + mock_get_path.return_value = self.config_path + + args = {"settings": {"stats": {"graphs": True}}} + context = {"dryRun": True} + response = plugin.apply_config(args, context, "req-5") + + self.assertTrue(response["success"]) + self.assertTrue(response["changed"]) + self.assertFalse(os.path.exists(self.config_path)) + + @patch("sys.stdin", new_callable=StringIO) + @patch("sys.stdout", new_callable=StringIO) + def test_invalid_json(self, mock_stdout, mock_stdin): + mock_stdin.write("INVALID { JSON") + mock_stdin.seek(0) + + plugin.main() + + output = mock_stdout.getvalue() + response = json.loads(output) + + self.assertFalse(response["success"]) + self.assertIn("Expecting value", response["error"]) + self.assertEqual(response["requestId"], "unknown") + + +if __name__ == "__main__": + unittest.main() diff --git a/plugins/lazygit/src/plugin.py b/plugins/lazygit/src/plugin.py index 97d189a0..7bd5de83 100644 --- a/plugins/lazygit/src/plugin.py +++ b/plugins/lazygit/src/plugin.py @@ -67,7 +67,9 @@ def merge_settings(target: dict, source: dict) -> bool: def check_installed(args: dict, request_id: str) -> dict: - installed = shutil.which("lazygit.exe") is not None or shutil.which("lazygit") is not None + installed = ( + shutil.which("lazygit.exe") is not None or shutil.which("lazygit") is not None + ) return { "requestId": request_id, "success": True, diff --git a/plugins/lazygit/test/test_lazygit.py b/plugins/lazygit/test/test_lazygit.py index de60082d..2a616567 100644 --- a/plugins/lazygit/test/test_lazygit.py +++ b/plugins/lazygit/test/test_lazygit.py @@ -1,144 +1,148 @@ -#!/usr/bin/env python3 -# /// script -# dependencies = [ -# "pyyaml", -# ] -# /// - -import json -import os -import subprocess -import sys -import tempfile - -import yaml - -PLUGIN = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "src", "plugin.py")) - - -def run_plugin(payload: dict) -> dict: - result = subprocess.run( - [sys.executable, PLUGIN], - input=json.dumps(payload), - capture_output=True, - text=True, - ) - if result.returncode != 0: - print(f"Error output: {result.stderr}") - return json.loads(result.stdout.strip()) - - -def test_check_installed(): - res = run_plugin( - { - "requestId": "1", - "command": "check_installed", - "args": {}, - "context": {}, - } - ) - assert "success" in res - assert res["success"] is True - print("OK: check_installed") - - -def test_apply_config_dry_run(): - with tempfile.TemporaryDirectory() as tmp: - os.environ["APPDATA"] = tmp - - res = run_plugin( - { - "requestId": "2", - "command": "apply", - "args": {"gui": {"theme": {"lightTheme": False}}}, - "context": {"dryRun": True}, - } - ) - - assert res["success"] - assert res["changed"] is False - - config_path = os.path.join(tmp, "lazygit", "config.yml") - assert not os.path.exists(config_path) - - print("OK: apply_config_dry_run") - - -def test_apply_config(): - with tempfile.TemporaryDirectory() as tmp: - os.environ["APPDATA"] = tmp - - res = run_plugin( - { - "requestId": "3", - "command": "apply", - "args": { - "gui": { - "language": "en", - "theme": { - "lightTheme": False, - "optionsTextColor": "yellow", - }, - }, - "git": {"paging": {"colorArg": "always"}}, - }, - "context": {"dryRun": False}, - } - ) - - assert res["success"] - assert res["changed"] - - config_path = os.path.join(tmp, "lazygit", "config.yml") - assert os.path.exists(config_path) - - with open(config_path, "r", encoding="utf-8") as f: - content = yaml.safe_load(f) - - assert content["gui"]["language"] == "en" - assert content["gui"]["theme"]["lightTheme"] is False - assert content["gui"]["theme"]["optionsTextColor"] == "yellow" - assert content["git"]["paging"]["colorArg"] == "always" - - print("OK: apply_config") - - -def test_idempotent_apply(): - with tempfile.TemporaryDirectory() as tmp: - os.environ["APPDATA"] = tmp - - payload = { - "requestId": "4", - "command": "apply", - "args": {"update": {"method": "never"}}, - "context": {"dryRun": False}, - } - - # First run should create/modify and return changed: true - res1 = run_plugin(payload) - assert res1["success"] - assert res1["changed"] - - # Second run should return changed: false - res2 = run_plugin(payload) - assert res2["success"] - assert not res2["changed"] - - print("OK: idempotent_apply") - - -def test_unknown_command(): - res = run_plugin({"requestId": "5", "command": "explode", "args": {}, "context": {}}) - - assert not res["success"] - assert "error" in res - print("OK: unknown_command") - - -if __name__ == "__main__": - test_check_installed() - test_apply_config_dry_run() - test_apply_config() - test_idempotent_apply() - test_unknown_command() - print("\nAll tests passed.") +#!/usr/bin/env python3 +# /// script +# dependencies = [ +# "pyyaml", +# ] +# /// + +import json +import os +import subprocess +import sys +import tempfile + +import yaml + +PLUGIN = os.path.abspath( + os.path.join(os.path.dirname(__file__), "..", "src", "plugin.py") +) + + +def run_plugin(payload: dict) -> dict: + result = subprocess.run( + [sys.executable, PLUGIN], + input=json.dumps(payload), + capture_output=True, + text=True, + ) + if result.returncode != 0: + print(f"Error output: {result.stderr}") + return json.loads(result.stdout.strip()) + + +def test_check_installed(): + res = run_plugin( + { + "requestId": "1", + "command": "check_installed", + "args": {}, + "context": {}, + } + ) + assert "success" in res + assert res["success"] is True + print("OK: check_installed") + + +def test_apply_config_dry_run(): + with tempfile.TemporaryDirectory() as tmp: + os.environ["APPDATA"] = tmp + + res = run_plugin( + { + "requestId": "2", + "command": "apply", + "args": {"gui": {"theme": {"lightTheme": False}}}, + "context": {"dryRun": True}, + } + ) + + assert res["success"] + assert res["changed"] is False + + config_path = os.path.join(tmp, "lazygit", "config.yml") + assert not os.path.exists(config_path) + + print("OK: apply_config_dry_run") + + +def test_apply_config(): + with tempfile.TemporaryDirectory() as tmp: + os.environ["APPDATA"] = tmp + + res = run_plugin( + { + "requestId": "3", + "command": "apply", + "args": { + "gui": { + "language": "en", + "theme": { + "lightTheme": False, + "optionsTextColor": "yellow", + }, + }, + "git": {"paging": {"colorArg": "always"}}, + }, + "context": {"dryRun": False}, + } + ) + + assert res["success"] + assert res["changed"] + + config_path = os.path.join(tmp, "lazygit", "config.yml") + assert os.path.exists(config_path) + + with open(config_path, "r", encoding="utf-8") as f: + content = yaml.safe_load(f) + + assert content["gui"]["language"] == "en" + assert content["gui"]["theme"]["lightTheme"] is False + assert content["gui"]["theme"]["optionsTextColor"] == "yellow" + assert content["git"]["paging"]["colorArg"] == "always" + + print("OK: apply_config") + + +def test_idempotent_apply(): + with tempfile.TemporaryDirectory() as tmp: + os.environ["APPDATA"] = tmp + + payload = { + "requestId": "4", + "command": "apply", + "args": {"update": {"method": "never"}}, + "context": {"dryRun": False}, + } + + # First run should create/modify and return changed: true + res1 = run_plugin(payload) + assert res1["success"] + assert res1["changed"] + + # Second run should return changed: false + res2 = run_plugin(payload) + assert res2["success"] + assert not res2["changed"] + + print("OK: idempotent_apply") + + +def test_unknown_command(): + res = run_plugin( + {"requestId": "5", "command": "explode", "args": {}, "context": {}} + ) + + assert not res["success"] + assert "error" in res + print("OK: unknown_command") + + +if __name__ == "__main__": + test_check_installed() + test_apply_config_dry_run() + test_apply_config() + test_idempotent_apply() + test_unknown_command() + print("\nAll tests passed.") diff --git a/plugins/miniconda/src/plugin.py b/plugins/miniconda/src/plugin.py index 5cd1a6cb..ee37ec84 100644 --- a/plugins/miniconda/src/plugin.py +++ b/plugins/miniconda/src/plugin.py @@ -88,7 +88,9 @@ def main(): changed = True if dry_run: - send_response(request_id, data={"status": "dry-run complete"}, changed=changed) + send_response( + request_id, data={"status": "dry-run complete"}, changed=changed + ) return if changed: diff --git a/plugins/miniconda/test/test_plugin.py b/plugins/miniconda/test/test_plugin.py index c373bdae..fdf5e1b3 100644 --- a/plugins/miniconda/test/test_plugin.py +++ b/plugins/miniconda/test/test_plugin.py @@ -80,7 +80,11 @@ def test_actual_apply_and_idempotent(self, mock_stdout): # Test actual apply (creates file) input_data1 = json.dumps( - {"requestId": "3", "command": "apply", "args": {"settings": {"channels": ["conda-forge"]}}} + { + "requestId": "3", + "command": "apply", + "args": {"settings": {"channels": ["conda-forge"]}}, + } ) with patch("sys.stdin", StringIO(input_data1)): with patch("plugin.Path.home", return_value=Path(tmpdir)): @@ -94,7 +98,11 @@ def test_actual_apply_and_idempotent(self, mock_stdout): # Test idempotent apply (runs again, but no changes) input_data2 = json.dumps( - {"requestId": "4", "command": "apply", "args": {"settings": {"channels": ["conda-forge"]}}} + { + "requestId": "4", + "command": "apply", + "args": {"settings": {"channels": ["conda-forge"]}}, + } ) with patch("sys.stdin", StringIO(input_data2)): with patch("plugin.Path.home", return_value=Path(tmpdir)): diff --git a/plugins/mise/src/plugin.py b/plugins/mise/src/plugin.py index 401dd93b..0d7f95ea 100644 --- a/plugins/mise/src/plugin.py +++ b/plugins/mise/src/plugin.py @@ -67,7 +67,9 @@ def write_section(section_name, section_data): if isinstance(contents, dict): write_section(section, contents) - fd, temp_path = tempfile.mkstemp(dir=os.path.dirname(file_path), prefix="config.toml.") + fd, temp_path = tempfile.mkstemp( + dir=os.path.dirname(file_path), prefix="config.toml." + ) try: with os.fdopen(fd, "w", encoding="utf-8") as f: f.write("\n".join(lines).strip() + "\n") diff --git a/plugins/mise/test/test_mise.py b/plugins/mise/test/test_mise.py index 581867c8..b7ab16f8 100644 --- a/plugins/mise/test/test_mise.py +++ b/plugins/mise/test/test_mise.py @@ -1,172 +1,176 @@ -import json -import os -import sys -import tempfile -from unittest.mock import patch - -import pytest - -src_path = os.path.join(os.path.dirname(__file__), "..", "src") -sys.path.append(src_path) -import plugin - -sys.path.remove(src_path) - - -@pytest.fixture -def mock_context(): - return { - "os": "windows", - "arch": "x64", - "dryRun": False, - } - - -@patch("plugin.shutil.which") -def test_check_installed_true(mock_which): - mock_which.return_value = "/usr/local/bin/mise" - result = plugin.handle({"requestId": "req-1", "command": "check_installed", "args": {}}) - - assert result["success"] is True - assert result["data"] is True - - -@patch("plugin.shutil.which") -def test_check_installed_false(mock_which): - mock_which.return_value = None - result = plugin.handle({"requestId": "req-1", "command": "check_installed", "args": {}}) - - assert result["success"] is True - assert result["data"] is False - - -@patch("plugin.get_config_path") -def test_apply_config_creates_new(mock_get_config_path, mock_context): - with tempfile.TemporaryDirectory() as temp_dir: - config_path = os.path.join(temp_dir, "config.toml") - mock_get_config_path.return_value = config_path - - settings = {"tools": {"node": "lts"}, "settings": {"jobs": 4}} - - result = plugin.handle( - { - "requestId": "req-2", - "command": "apply", - "args": {"settings": settings}, - "context": mock_context, - } - ) - - assert result["success"] is True - assert result["changed"] is True - assert os.path.exists(config_path) - - with open(config_path, "r") as f: - content = f.read() - assert "[tools]" in content - assert 'node = "lts"' in content - assert "[settings]" in content - assert "jobs = 4" in content - - -@patch("plugin.get_config_path") -def test_apply_config_dry_run(mock_get_config_path): - with tempfile.TemporaryDirectory() as temp_dir: - config_path = os.path.join(temp_dir, "config.toml") - mock_get_config_path.return_value = config_path - - settings = {"tools": {"node": "lts"}} - - result = plugin.handle( - { - "requestId": "req-3", - "command": "apply", - "args": {"settings": settings}, - "context": {"dryRun": True}, - } - ) - - assert result["success"] is True - assert result["changed"] is True - assert not os.path.exists(config_path) - - -@patch("plugin.get_config_path") -def test_apply_config_invalid_toml_backup(mock_get_config_path, mock_context): - with tempfile.TemporaryDirectory() as temp_dir: - config_path = os.path.join(temp_dir, "config.toml") - mock_get_config_path.return_value = config_path - - # Write invalid TOML - with open(config_path, "w") as f: - f.write("invalid = toml = format") - - settings = {"tools": {"node": "lts"}} - - result = plugin.handle( - { - "requestId": "req-4", - "command": "apply", - "args": {"settings": settings}, - "context": mock_context, - } - ) - - assert result["success"] is True - assert result["changed"] is True - - # Check backup was created - backups = [f for f in os.listdir(temp_dir) if f.endswith(".bak")] - assert len(backups) == 1 - - with open(os.path.join(temp_dir, backups[0]), "r") as f: - assert f.read() == "invalid = toml = format" - - -def test_empty_stdin(): - with patch("sys.stdin.read", return_value=""): - with patch("sys.stdout.write") as mock_stdout: - plugin.main() - written = mock_stdout.call_args[0][0] - result = json.loads(written) - assert result["success"] is False - assert "Empty input" in result["error"] - - -def test_apply_config_invalid_args(): - result = plugin.handle( - { - "requestId": "req-invalid-args", - "command": "apply", - "args": "not-a-dict", - "context": {}, - } - ) - assert result["success"] is False - assert "args must be an object" in result["error"] - - -def test_apply_config_invalid_context(): - result = plugin.handle( - { - "requestId": "req-invalid-context", - "command": "apply", - "args": {}, - "context": "not-a-dict", - } - ) - assert result["success"] is False - assert "context must be an object" in result["error"] - - -def test_unknown_command(): - result = plugin.handle( - { - "requestId": "req-unknown", - "command": "some_unknown_cmd", - "args": {}, - "context": {}, - } - ) - assert result["success"] is False - assert "Unknown command: some_unknown_cmd" in result["error"] +import json +import os +import sys +import tempfile +from unittest.mock import patch + +import pytest + +src_path = os.path.join(os.path.dirname(__file__), "..", "src") +sys.path.append(src_path) +import plugin + +sys.path.remove(src_path) + + +@pytest.fixture +def mock_context(): + return { + "os": "windows", + "arch": "x64", + "dryRun": False, + } + + +@patch("plugin.shutil.which") +def test_check_installed_true(mock_which): + mock_which.return_value = "/usr/local/bin/mise" + result = plugin.handle( + {"requestId": "req-1", "command": "check_installed", "args": {}} + ) + + assert result["success"] is True + assert result["data"] is True + + +@patch("plugin.shutil.which") +def test_check_installed_false(mock_which): + mock_which.return_value = None + result = plugin.handle( + {"requestId": "req-1", "command": "check_installed", "args": {}} + ) + + assert result["success"] is True + assert result["data"] is False + + +@patch("plugin.get_config_path") +def test_apply_config_creates_new(mock_get_config_path, mock_context): + with tempfile.TemporaryDirectory() as temp_dir: + config_path = os.path.join(temp_dir, "config.toml") + mock_get_config_path.return_value = config_path + + settings = {"tools": {"node": "lts"}, "settings": {"jobs": 4}} + + result = plugin.handle( + { + "requestId": "req-2", + "command": "apply", + "args": {"settings": settings}, + "context": mock_context, + } + ) + + assert result["success"] is True + assert result["changed"] is True + assert os.path.exists(config_path) + + with open(config_path, "r") as f: + content = f.read() + assert "[tools]" in content + assert 'node = "lts"' in content + assert "[settings]" in content + assert "jobs = 4" in content + + +@patch("plugin.get_config_path") +def test_apply_config_dry_run(mock_get_config_path): + with tempfile.TemporaryDirectory() as temp_dir: + config_path = os.path.join(temp_dir, "config.toml") + mock_get_config_path.return_value = config_path + + settings = {"tools": {"node": "lts"}} + + result = plugin.handle( + { + "requestId": "req-3", + "command": "apply", + "args": {"settings": settings}, + "context": {"dryRun": True}, + } + ) + + assert result["success"] is True + assert result["changed"] is True + assert not os.path.exists(config_path) + + +@patch("plugin.get_config_path") +def test_apply_config_invalid_toml_backup(mock_get_config_path, mock_context): + with tempfile.TemporaryDirectory() as temp_dir: + config_path = os.path.join(temp_dir, "config.toml") + mock_get_config_path.return_value = config_path + + # Write invalid TOML + with open(config_path, "w") as f: + f.write("invalid = toml = format") + + settings = {"tools": {"node": "lts"}} + + result = plugin.handle( + { + "requestId": "req-4", + "command": "apply", + "args": {"settings": settings}, + "context": mock_context, + } + ) + + assert result["success"] is True + assert result["changed"] is True + + # Check backup was created + backups = [f for f in os.listdir(temp_dir) if f.endswith(".bak")] + assert len(backups) == 1 + + with open(os.path.join(temp_dir, backups[0]), "r") as f: + assert f.read() == "invalid = toml = format" + + +def test_empty_stdin(): + with patch("sys.stdin.read", return_value=""): + with patch("sys.stdout.write") as mock_stdout: + plugin.main() + written = mock_stdout.call_args[0][0] + result = json.loads(written) + assert result["success"] is False + assert "Empty input" in result["error"] + + +def test_apply_config_invalid_args(): + result = plugin.handle( + { + "requestId": "req-invalid-args", + "command": "apply", + "args": "not-a-dict", + "context": {}, + } + ) + assert result["success"] is False + assert "args must be an object" in result["error"] + + +def test_apply_config_invalid_context(): + result = plugin.handle( + { + "requestId": "req-invalid-context", + "command": "apply", + "args": {}, + "context": "not-a-dict", + } + ) + assert result["success"] is False + assert "context must be an object" in result["error"] + + +def test_unknown_command(): + result = plugin.handle( + { + "requestId": "req-unknown", + "command": "some_unknown_cmd", + "args": {}, + "context": {}, + } + ) + assert result["success"] is False + assert "Unknown command: some_unknown_cmd" in result["error"] diff --git a/plugins/notepadplusplus/test/test_notepadplusplus.py b/plugins/notepadplusplus/test/test_notepadplusplus.py index 04cdb1a9..a2f3832c 100644 --- a/plugins/notepadplusplus/test/test_notepadplusplus.py +++ b/plugins/notepadplusplus/test/test_notepadplusplus.py @@ -4,7 +4,9 @@ import sys import tempfile -PLUGIN = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "src", "plugin.py")) +PLUGIN = os.path.abspath( + os.path.join(os.path.dirname(__file__), "..", "src", "plugin.py") +) def run_plugin(payload: dict) -> dict: @@ -101,7 +103,9 @@ def test_idempotent_apply(): def test_unknown_command(): - res = run_plugin({"requestId": "5", "command": "explode", "args": {}, "context": {}}) + res = run_plugin( + {"requestId": "5", "command": "explode", "args": {}, "context": {}} + ) assert not res["success"] assert "error" in res diff --git a/plugins/npm/test/test_npm.py b/plugins/npm/test/test_npm.py index e2a9d756..ab716e52 100644 --- a/plugins/npm/test/test_npm.py +++ b/plugins/npm/test/test_npm.py @@ -1,126 +1,124 @@ -import json -import os -import sys -from io import StringIO -from unittest.mock import patch - -sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) -from src.plugin import main, read_npmrc, write_npmrc - - -def run_plugin(input_dict): - """Helper to run the plugin with JSON input and return JSON output.""" - input_str = json.dumps(input_dict) - - # Mock stdin and stdout - old_stdin = sys.stdin - old_stdout = sys.stdout - sys.stdin = StringIO(input_str) - sys.stdout = StringIO() - - try: - main() - output_str = sys.stdout.getvalue() - return json.loads(output_str) - finally: - sys.stdin = old_stdin - sys.stdout = old_stdout - - -def test_read_npmrc(tmp_path): - npmrc_content = ( - "registry=https://registry.npmjs.org/\n@scope:registry=https://custom.com/\n; comment\nsave-exact=true\n" - ) - npmrc_file = tmp_path / ".npmrc" - npmrc_file.write_text(npmrc_content) - - config = read_npmrc(str(npmrc_file)) - assert config == { - "registry": "https://registry.npmjs.org/", - "@scope:registry": "https://custom.com/", - "save-exact": "true", - } - - -def test_write_npmrc(tmp_path): - npmrc_file = tmp_path / ".npmrc" - config = { - "registry": "https://registry.npmjs.org/", - "@scope:registry": "https://custom.com/", - "save-exact": "true", - } - write_npmrc(str(npmrc_file), config) - - content = npmrc_file.read_text() - assert "registry=https://registry.npmjs.org/" in content - assert "@scope:registry=https://custom.com/" in content - assert "save-exact=true" in content - - -@patch("src.plugin.get_npmrc_path") -def test_apply_command(mock_get_path, tmp_path): - npmrc_file = tmp_path / ".npmrc" - npmrc_file.write_text("registry=https://registry.npmjs.org/\n") - mock_get_path.return_value = str(npmrc_file) - - request = { - "requestId": "test-req-1", - "command": "apply", - "args": {"@scope:registry": "https://custom.com/", "save-exact": True}, - } - - response = run_plugin(request) - assert response["success"] is True - assert response["changed"] is True - - # Verify file was updated - content = npmrc_file.read_text() - assert "registry=https://registry.npmjs.org/" in content - assert "@scope:registry=https://custom.com/" in content - assert "save-exact=true" in content - - -@patch("src.plugin.get_npmrc_path") -def test_apply_dry_run(mock_get_path, tmp_path): - npmrc_file = tmp_path / ".npmrc" - npmrc_file.write_text("registry=https://registry.npmjs.org/\n") - mock_get_path.return_value = str(npmrc_file) - - request = { - "requestId": "test-req-2", - "command": "apply", - "args": {"save-exact": True}, - "context": {"dryRun": True}, - } - - response = run_plugin(request) - assert response["success"] is True - assert response["changed"] is True - - # Verify file was NOT updated - content = npmrc_file.read_text() - assert "registry=https://registry.npmjs.org/" in content - assert "save-exact" not in content - - -@patch("shutil.which") -def test_check_installed(mock_which): - mock_which.return_value = "/usr/local/bin/npm" - request = {"requestId": "test-req-3", "command": "check_installed"} - response = run_plugin(request) - assert response["success"] is True - assert response["data"] is True - - # Test when npm is not installed - mock_which.return_value = None - response = run_plugin(request) - assert response["success"] is True - assert response["data"] is False - - -def test_unknown_command(): - request = {"requestId": "test-req-4", "command": "unknown_cmd"} - response = run_plugin(request) - assert response["success"] is False - assert "error" in response - assert response["error"] == "Unknown command: unknown_cmd" +import json +import os +import sys +from io import StringIO +from unittest.mock import patch + +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) +from src.plugin import main, read_npmrc, write_npmrc + + +def run_plugin(input_dict): + """Helper to run the plugin with JSON input and return JSON output.""" + input_str = json.dumps(input_dict) + + # Mock stdin and stdout + old_stdin = sys.stdin + old_stdout = sys.stdout + sys.stdin = StringIO(input_str) + sys.stdout = StringIO() + + try: + main() + output_str = sys.stdout.getvalue() + return json.loads(output_str) + finally: + sys.stdin = old_stdin + sys.stdout = old_stdout + + +def test_read_npmrc(tmp_path): + npmrc_content = "registry=https://registry.npmjs.org/\n@scope:registry=https://custom.com/\n; comment\nsave-exact=true\n" + npmrc_file = tmp_path / ".npmrc" + npmrc_file.write_text(npmrc_content) + + config = read_npmrc(str(npmrc_file)) + assert config == { + "registry": "https://registry.npmjs.org/", + "@scope:registry": "https://custom.com/", + "save-exact": "true", + } + + +def test_write_npmrc(tmp_path): + npmrc_file = tmp_path / ".npmrc" + config = { + "registry": "https://registry.npmjs.org/", + "@scope:registry": "https://custom.com/", + "save-exact": "true", + } + write_npmrc(str(npmrc_file), config) + + content = npmrc_file.read_text() + assert "registry=https://registry.npmjs.org/" in content + assert "@scope:registry=https://custom.com/" in content + assert "save-exact=true" in content + + +@patch("src.plugin.get_npmrc_path") +def test_apply_command(mock_get_path, tmp_path): + npmrc_file = tmp_path / ".npmrc" + npmrc_file.write_text("registry=https://registry.npmjs.org/\n") + mock_get_path.return_value = str(npmrc_file) + + request = { + "requestId": "test-req-1", + "command": "apply", + "args": {"@scope:registry": "https://custom.com/", "save-exact": True}, + } + + response = run_plugin(request) + assert response["success"] is True + assert response["changed"] is True + + # Verify file was updated + content = npmrc_file.read_text() + assert "registry=https://registry.npmjs.org/" in content + assert "@scope:registry=https://custom.com/" in content + assert "save-exact=true" in content + + +@patch("src.plugin.get_npmrc_path") +def test_apply_dry_run(mock_get_path, tmp_path): + npmrc_file = tmp_path / ".npmrc" + npmrc_file.write_text("registry=https://registry.npmjs.org/\n") + mock_get_path.return_value = str(npmrc_file) + + request = { + "requestId": "test-req-2", + "command": "apply", + "args": {"save-exact": True}, + "context": {"dryRun": True}, + } + + response = run_plugin(request) + assert response["success"] is True + assert response["changed"] is True + + # Verify file was NOT updated + content = npmrc_file.read_text() + assert "registry=https://registry.npmjs.org/" in content + assert "save-exact" not in content + + +@patch("shutil.which") +def test_check_installed(mock_which): + mock_which.return_value = "/usr/local/bin/npm" + request = {"requestId": "test-req-3", "command": "check_installed"} + response = run_plugin(request) + assert response["success"] is True + assert response["data"] is True + + # Test when npm is not installed + mock_which.return_value = None + response = run_plugin(request) + assert response["success"] is True + assert response["data"] is False + + +def test_unknown_command(): + request = {"requestId": "test-req-4", "command": "unknown_cmd"} + response = run_plugin(request) + assert response["success"] is False + assert "error" in response + assert response["error"] == "Unknown command: unknown_cmd" diff --git a/plugins/nuget/src/plugin.py b/plugins/nuget/src/plugin.py index 77c8fd6a..0693f065 100644 --- a/plugins/nuget/src/plugin.py +++ b/plugins/nuget/src/plugin.py @@ -32,7 +32,9 @@ def read_xml(file_path): backup_path = f"{file_path}.corrupted.{uuid.uuid4().hex}.bak" try: shutil.copy2(file_path, backup_path) - log(f"Warning: could not parse {file_path}: {e}. Backed up to {backup_path}.") + log( + f"Warning: could not parse {file_path}: {e}. Backed up to {backup_path}." + ) except Exception: log(f"Warning: could not parse {file_path}: {e}. Starting with default.") root = ET.Element("configuration") @@ -171,7 +173,11 @@ def merge_settings(tree, settings): def check_installed(): config_path = get_config_path() - return shutil.which("nuget") is not None or shutil.which("dotnet") is not None or os.path.exists(config_path) + return ( + shutil.which("nuget") is not None + or shutil.which("dotnet") is not None + or os.path.exists(config_path) + ) def apply_config(args, request_id): @@ -207,14 +213,21 @@ def main(): input_data = sys.stdin.read() if not input_data: - sys.stdout.write(json.dumps({"requestId": "unknown", "error": "No input received"}) + "\n") + sys.stdout.write( + json.dumps({"requestId": "unknown", "error": "No input received"}) + "\n" + ) sys.stdout.flush() return try: request = json.loads(input_data) except Exception as e: - sys.stdout.write(json.dumps({"requestId": "unknown", "error": f"Failed to parse request: {e}"}) + "\n") + sys.stdout.write( + json.dumps( + {"requestId": "unknown", "error": f"Failed to parse request: {e}"} + ) + + "\n" + ) sys.stdout.flush() return @@ -231,7 +244,10 @@ def main(): else: response = {"requestId": request_id, "error": f"Unknown command: {command}"} except Exception as fatal_err: - response = {"requestId": request_id, "error": f"Internal Script Error: {str(fatal_err)}"} + response = { + "requestId": request_id, + "error": f"Internal Script Error: {str(fatal_err)}", + } sys.stdout.write(json.dumps(response) + "\n") sys.stdout.flush() diff --git a/plugins/nuget/test/test_nuget.py b/plugins/nuget/test/test_nuget.py index 0edb64d2..15a0d1c7 100644 --- a/plugins/nuget/test/test_nuget.py +++ b/plugins/nuget/test/test_nuget.py @@ -37,7 +37,11 @@ def test_apply_dry_run_no_write(tmp_path, monkeypatch): monkeypatch.setattr(plugin, "get_config_path", lambda: str(config_file)) args = { "dryRun": True, - "settings": {"packageSources": [{"name": "nuget", "source": "https://api.nuget.org/v3/index.json"}]}, + "settings": { + "packageSources": [ + {"name": "nuget", "source": "https://api.nuget.org/v3/index.json"} + ] + }, } result = plugin.apply_config(args, "test-001") assert result["changed"] is True @@ -53,7 +57,11 @@ def test_apply_success(tmp_path, monkeypatch): monkeypatch.setattr(plugin, "get_config_path", lambda: str(config_file)) args = { "dryRun": False, - "settings": {"packageSources": [{"name": "nuget", "source": "https://api.nuget.org/v3/index.json"}]}, + "settings": { + "packageSources": [ + {"name": "nuget", "source": "https://api.nuget.org/v3/index.json"} + ] + }, } result = plugin.apply_config(args, "test-001") assert result["changed"] is True @@ -64,13 +72,19 @@ def test_apply_noop(tmp_path, monkeypatch): config_file = tmp_path / "NuGet.Config" root = ET.Element("configuration") sources = ET.SubElement(root, "packageSources") - ET.SubElement(sources, "add", {"key": "nuget", "value": "https://api.nuget.org/v3/index.json"}) + ET.SubElement( + sources, "add", {"key": "nuget", "value": "https://api.nuget.org/v3/index.json"} + ) tree = ET.ElementTree(root) tree.write(config_file, encoding="utf-8") monkeypatch.setattr(plugin, "get_config_path", lambda: str(config_file)) args = { "dryRun": False, - "settings": {"packageSources": [{"name": "nuget", "source": "https://api.nuget.org/v3/index.json"}]}, + "settings": { + "packageSources": [ + {"name": "nuget", "source": "https://api.nuget.org/v3/index.json"} + ] + }, } result = plugin.apply_config(args, "test-001") assert result["changed"] is False @@ -88,10 +102,16 @@ def test_invalid_settings_handled(tmp_path, monkeypatch): def test_config_missing(tmp_path, monkeypatch): - monkeypatch.setattr(plugin, "get_config_path", lambda: str(tmp_path / "missing.Config")) + monkeypatch.setattr( + plugin, "get_config_path", lambda: str(tmp_path / "missing.Config") + ) args = { "dryRun": False, - "settings": {"packageSources": [{"name": "nuget", "source": "https://api.nuget.org/v3/index.json"}]}, + "settings": { + "packageSources": [ + {"name": "nuget", "source": "https://api.nuget.org/v3/index.json"} + ] + }, } result = plugin.apply_config(args, "test-001") assert result["changed"] is True diff --git a/plugins/nvm/src/plugin.py b/plugins/nvm/src/plugin.py index abc38579..fb0e1c64 100644 --- a/plugins/nvm/src/plugin.py +++ b/plugins/nvm/src/plugin.py @@ -8,7 +8,9 @@ from typing import Optional SETTING_FILE = "settings.txt" -LINE_RE = re.compile(r"^(?P\s*)(?P[^=\s#;]+)(?P\s*=\s*)(?P.*)$") +LINE_RE = re.compile( + r"^(?P\s*)(?P[^=\s#;]+)(?P\s*=\s*)(?P.*)$" +) def log(msg: str) -> None: @@ -17,7 +19,9 @@ def log(msg: str) -> None: def get_appdata_dir() -> str: - appdata = (os.environ.get("APPDATA") or os.path.join(os.path.expanduser("~"), "AppData", "Roaming")) + appdata = os.environ.get("APPDATA") or os.path.join( + os.path.expanduser("~"), "AppData", "Roaming" + ) if appdata: return appdata @@ -124,7 +128,9 @@ def render_lines(lines: list[SettingLine]) -> str: continue value = "" if line.value is None else line.value - rendered.append(f"{line.leading}{line.key}{line.separator}{value}{line.suffix}\n") + rendered.append( + f"{line.leading}{line.key}{line.separator}{value}{line.suffix}\n" + ) text = "".join(rendered) @@ -202,7 +208,9 @@ def write_atomic(file_path: str, content: str) -> None: def check_installed(_args: dict, request_id: str) -> dict: installed = ( - os.path.exists(get_settings_path()) or shutil.which("nvm.exe") is not None or shutil.which("nvm") is not None + os.path.exists(get_settings_path()) + or shutil.which("nvm.exe") is not None + or shutil.which("nvm") is not None ) return { diff --git a/plugins/nvm/test/test_nvm.py b/plugins/nvm/test/test_nvm.py index 58785609..cac30fbe 100644 --- a/plugins/nvm/test/test_nvm.py +++ b/plugins/nvm/test/test_nvm.py @@ -138,7 +138,9 @@ def test_apply_preserves_inline_comment_suffix(tmp_path): assert response["success"] is True assert response["changed"] is True - assert settings_path.read_text(encoding="utf-8") == ("root=D:\\tools\\nvm # install directory\n") + assert settings_path.read_text(encoding="utf-8") == ( + "root=D:\\tools\\nvm # install directory\n" + ) def test_apply_dry_run_does_not_write(tmp_path): diff --git a/plugins/obs-studio/src/plugin.py b/plugins/obs-studio/src/plugin.py index 98c60a2d..da356920 100644 --- a/plugins/obs-studio/src/plugin.py +++ b/plugins/obs-studio/src/plugin.py @@ -1,347 +1,363 @@ -import configparser -import datetime -import json -import os -import shutil -import sys -import tempfile -import uuid - - -def log(msg): - sys.stderr.write(f"[obs-studio] {msg}\n") - sys.stderr.flush() - - -def get_obs_appdata(): - appdata = os.getenv("APPDATA") - if not appdata: - raise Exception("APPDATA environment variable not found") - return os.path.join(appdata, "obs-studio") - - -def read_ini(path): - config = configparser.RawConfigParser() - if os.path.exists(path): - try: - config.read(path, encoding="utf-8") - except configparser.Error as e: - timestamp = datetime.datetime.now(datetime.timezone.utc).strftime("%Y%m%d%H%M%S") - suffix = uuid.uuid4().hex[:8] - backup_path = f"{path}.corrupted.{timestamp}.{suffix}" - log(f"Config corrupted. Backing up to {backup_path} and starting fresh. Error: {e}") - try: - shutil.move(path, backup_path) - except Exception as backup_e: - log(f"Failed to backup corrupted config: {backup_e}") - return config - - -def write_ini(path, config): - os.makedirs(os.path.dirname(path), exist_ok=True) - fd, temp_path = tempfile.mkstemp(prefix="obs-studio-", dir=os.path.dirname(path)) - try: - with os.fdopen(fd, "w", encoding="utf-8") as f: - config.write(f) - os.replace(temp_path, path) - except Exception: - os.unlink(temp_path) - raise - - -def merge_ini_section(config, section, values): - changed = False - if not config.has_section(section): - config.add_section(section) - for key, value in values.items(): - str_value = str(value) - if not config.has_option(section, key) or config.get(section, key) != str_value: - config.set(section, key, str_value) - changed = True - return changed - - -def get_obs_install_dir(): - override = os.getenv("OBS_INSTALL_DIR") - if override: - return override - program_files = os.getenv("PROGRAMFILES", "C:\\Program Files") - return os.path.join(program_files, "obs-studio") - - -def check_installed(args, request_id): - obs_exe = os.path.join(get_obs_install_dir(), "bin", "64bit", "obs64.exe") - installed = os.path.exists(obs_exe) - return { - "requestId": request_id, - "success": True, - "changed": False, - "data": installed, - } - - -GENERAL_KEY_MAP = { - "theme": "Theme", - "language": "Language", - "prevents_display_sleep": "PreventDisplaySleep", -} - - -def apply_global_ini(obs_dir, general, dry_run): - global_ini_path = os.path.join(obs_dir, "global.ini") - config = read_ini(global_ini_path) - changed = False - - if general: - mapped = {GENERAL_KEY_MAP[k]: v for k, v in general.items() if k in GENERAL_KEY_MAP} - if mapped: - if dry_run: - log(f"Would update {global_ini_path} [General] with: {mapped}") - changed = merge_ini_section(config, "General", mapped) - else: - changed = merge_ini_section(config, "General", mapped) - if changed: - write_ini(global_ini_path, config) - log(f"Updated {global_ini_path}") - - return changed - - -def get_active_profile(obs_dir): - global_ini_path = os.path.join(obs_dir, "global.ini") - config = read_ini(global_ini_path) - if config.has_option("Basic", "ProfileDir"): - return config.get("Basic", "ProfileDir") - return None - - -VIDEO_KEY_MAP = { - "base_resolution": "Base", - "output_resolution": "Output", - "fps_type": "FPSType", - "fps_common": "FPSCommon", -} - -AUDIO_KEY_MAP = { - "sample_rate": "SampleRate", - "channels": "ChannelSetup", - "desktop_device": "DesktopAudioDevice1", - "mic_device": "AuxAudioDevice1", -} - - -def apply_profile_ini(obs_dir, profile_name, args, dry_run): - profile_dir = os.path.join(obs_dir, "basic", "profiles", profile_name) - basic_ini_path = os.path.join(profile_dir, "basic.ini") - config = read_ini(basic_ini_path) - changed = False - - video = args.get("video") - if video: - mapped = {VIDEO_KEY_MAP[k]: v for k, v in video.items() if k in VIDEO_KEY_MAP} - if dry_run: - log(f"Would update {basic_ini_path} [Video] with: {mapped}") - changed = merge_ini_section(config, "Video", mapped) or changed - else: - changed = merge_ini_section(config, "Video", mapped) or changed - - audio = args.get("audio") - if audio: - mapped = {AUDIO_KEY_MAP[k]: v for k, v in audio.items() if k in AUDIO_KEY_MAP} - if dry_run: - log(f"Would update {basic_ini_path} [Audio] with: {mapped}") - changed = merge_ini_section(config, "Audio", mapped) or changed - else: - changed = merge_ini_section(config, "Audio", mapped) or changed - - output = args.get("output") - if output: - output_section = {"Mode": output["mode"]} if "mode" in output else {} - recording = output.get("recording", {}) - streaming = output.get("streaming", {}) - simple_output = {} - if recording: - simple_output.update( - { - "FilePath": recording.get("path", ""), - "RecQuality": recording.get("quality", ""), - "RecFormat": recording.get("format", ""), - "RecEncoder": recording.get("encoder", ""), - } - ) - if streaming: - simple_output.update( - { - "VBitrate": str(streaming.get("bitrate", "")), - "StreamEncoder": streaming.get("encoder", ""), - } - ) - if dry_run: - log(f"Would update {basic_ini_path} [Output] with: {output_section}") - log(f"Would update {basic_ini_path} [SimpleOutput] with: {simple_output}") - if output_section: - changed = merge_ini_section(config, "Output", output_section) or changed - if simple_output: - changed = merge_ini_section(config, "SimpleOutput", simple_output) or changed - else: - if output_section: - changed = merge_ini_section(config, "Output", output_section) or changed - if simple_output: - changed = merge_ini_section(config, "SimpleOutput", simple_output) or changed - - hotkeys = args.get("hotkeys") - if hotkeys: - if dry_run: - log(f"Would update {basic_ini_path} [Hotkeys] with: {hotkeys}") - changed = merge_ini_section(config, "Hotkeys", hotkeys) or changed - else: - changed = merge_ini_section(config, "Hotkeys", hotkeys) or changed - - if changed and not dry_run: - write_ini(basic_ini_path, config) - log(f"Updated {basic_ini_path}") - - return changed - - -def ensure_profiles(obs_dir, profiles, dry_run): - changed = False - for profile in profiles: - name = profile.get("name") - if not name: - log("Skipping profile entry with no name") - continue - - profile_dir = os.path.join(obs_dir, "basic", "profiles", name) - basic_ini_path = os.path.join(profile_dir, "basic.ini") - - if dry_run: - log(f"Would create profile directory: {profile_dir}") - continue - - os.makedirs(profile_dir, exist_ok=True) - - if not os.path.exists(basic_ini_path): - config = configparser.RawConfigParser() - write_ini(basic_ini_path, config) - log(f"Created profile: {name}") - changed = True - else: - log(f"Profile already exists: {name}") - - settings = profile.get("settings", {}) - if settings: - config = read_ini(basic_ini_path) - section_changed = merge_ini_section(config, "General", settings) - if section_changed: - write_ini(basic_ini_path, config) - changed = True - - return changed - - -def apply_config(args, context, request_id): - dry_run = context.get("dryRun", False) - changed = False - - try: - obs_dir = get_obs_appdata() - - general = args.get("general") - if general: - changed = apply_global_ini(obs_dir, general, dry_run) or changed - - profile_name = args.get("profile") or get_active_profile(obs_dir) - has_profile_settings = any(args.get(k) for k in ("video", "audio", "output", "hotkeys")) - - if has_profile_settings: - if not profile_name: - log("No profile specified and no active profile found — skipping video/audio/output/hotkeys") - else: - changed = apply_profile_ini(obs_dir, profile_name, args, dry_run) or changed - - profiles = args.get("profiles", []) - if profiles: - changed = ensure_profiles(obs_dir, profiles, dry_run) or changed - - except Exception as e: - log(f"Error applying config: {e}") - return { - "requestId": request_id, - "success": False, - "changed": False, - "error": str(e), - "data": None, - } - - return { - "requestId": request_id, - "success": True, - "changed": changed, - "data": None, - } - - -def main(): - input_data = sys.stdin.read() - if not input_data: - sys.stdout.write( - json.dumps( - { - "requestId": "unknown", - "success": False, - "changed": False, - "error": "No input", - } - ) - + "\n" - ) - sys.stdout.flush() - return - - try: - request = json.loads(input_data) - except Exception as e: - log(f"Failed to parse request: {e}") - sys.stdout.write( - json.dumps( - { - "requestId": "unknown", - "success": False, - "changed": False, - "error": f"Failed to parse request: {e}", - } - ) - + "\n" - ) - sys.stdout.flush() - return - - request_id = request.get("requestId", "unknown") - command = request.get("command") - args = request.get("args", {}) - context = request.get("context", {}) - - response = { - "requestId": request_id, - "success": False, - "changed": False, - "data": None, - } - - try: - if command == "check_installed": - response = check_installed(args, request_id) - elif command == "apply": - response = apply_config(args, context, request_id) - else: - response["error"] = f"Unknown command: {command}" - except Exception as e: - response["error"] = f"Internal error: {str(e)}" - - sys.stdout.write(json.dumps(response) + "\n") - sys.stdout.flush() - - -if __name__ == "__main__": - main() +import configparser +import datetime +import json +import os +import shutil +import sys +import tempfile +import uuid + + +def log(msg): + sys.stderr.write(f"[obs-studio] {msg}\n") + sys.stderr.flush() + + +def get_obs_appdata(): + appdata = os.getenv("APPDATA") + if not appdata: + raise Exception("APPDATA environment variable not found") + return os.path.join(appdata, "obs-studio") + + +def read_ini(path): + config = configparser.RawConfigParser() + if os.path.exists(path): + try: + config.read(path, encoding="utf-8") + except configparser.Error as e: + timestamp = datetime.datetime.now(datetime.timezone.utc).strftime( + "%Y%m%d%H%M%S" + ) + suffix = uuid.uuid4().hex[:8] + backup_path = f"{path}.corrupted.{timestamp}.{suffix}" + log( + f"Config corrupted. Backing up to {backup_path} and starting fresh. Error: {e}" + ) + try: + shutil.move(path, backup_path) + except Exception as backup_e: + log(f"Failed to backup corrupted config: {backup_e}") + return config + + +def write_ini(path, config): + os.makedirs(os.path.dirname(path), exist_ok=True) + fd, temp_path = tempfile.mkstemp(prefix="obs-studio-", dir=os.path.dirname(path)) + try: + with os.fdopen(fd, "w", encoding="utf-8") as f: + config.write(f) + os.replace(temp_path, path) + except Exception: + os.unlink(temp_path) + raise + + +def merge_ini_section(config, section, values): + changed = False + if not config.has_section(section): + config.add_section(section) + for key, value in values.items(): + str_value = str(value) + if not config.has_option(section, key) or config.get(section, key) != str_value: + config.set(section, key, str_value) + changed = True + return changed + + +def get_obs_install_dir(): + override = os.getenv("OBS_INSTALL_DIR") + if override: + return override + program_files = os.getenv("PROGRAMFILES", "C:\\Program Files") + return os.path.join(program_files, "obs-studio") + + +def check_installed(args, request_id): + obs_exe = os.path.join(get_obs_install_dir(), "bin", "64bit", "obs64.exe") + installed = os.path.exists(obs_exe) + return { + "requestId": request_id, + "success": True, + "changed": False, + "data": installed, + } + + +GENERAL_KEY_MAP = { + "theme": "Theme", + "language": "Language", + "prevents_display_sleep": "PreventDisplaySleep", +} + + +def apply_global_ini(obs_dir, general, dry_run): + global_ini_path = os.path.join(obs_dir, "global.ini") + config = read_ini(global_ini_path) + changed = False + + if general: + mapped = { + GENERAL_KEY_MAP[k]: v for k, v in general.items() if k in GENERAL_KEY_MAP + } + if mapped: + if dry_run: + log(f"Would update {global_ini_path} [General] with: {mapped}") + changed = merge_ini_section(config, "General", mapped) + else: + changed = merge_ini_section(config, "General", mapped) + if changed: + write_ini(global_ini_path, config) + log(f"Updated {global_ini_path}") + + return changed + + +def get_active_profile(obs_dir): + global_ini_path = os.path.join(obs_dir, "global.ini") + config = read_ini(global_ini_path) + if config.has_option("Basic", "ProfileDir"): + return config.get("Basic", "ProfileDir") + return None + + +VIDEO_KEY_MAP = { + "base_resolution": "Base", + "output_resolution": "Output", + "fps_type": "FPSType", + "fps_common": "FPSCommon", +} + +AUDIO_KEY_MAP = { + "sample_rate": "SampleRate", + "channels": "ChannelSetup", + "desktop_device": "DesktopAudioDevice1", + "mic_device": "AuxAudioDevice1", +} + + +def apply_profile_ini(obs_dir, profile_name, args, dry_run): + profile_dir = os.path.join(obs_dir, "basic", "profiles", profile_name) + basic_ini_path = os.path.join(profile_dir, "basic.ini") + config = read_ini(basic_ini_path) + changed = False + + video = args.get("video") + if video: + mapped = {VIDEO_KEY_MAP[k]: v for k, v in video.items() if k in VIDEO_KEY_MAP} + if dry_run: + log(f"Would update {basic_ini_path} [Video] with: {mapped}") + changed = merge_ini_section(config, "Video", mapped) or changed + else: + changed = merge_ini_section(config, "Video", mapped) or changed + + audio = args.get("audio") + if audio: + mapped = {AUDIO_KEY_MAP[k]: v for k, v in audio.items() if k in AUDIO_KEY_MAP} + if dry_run: + log(f"Would update {basic_ini_path} [Audio] with: {mapped}") + changed = merge_ini_section(config, "Audio", mapped) or changed + else: + changed = merge_ini_section(config, "Audio", mapped) or changed + + output = args.get("output") + if output: + output_section = {"Mode": output["mode"]} if "mode" in output else {} + recording = output.get("recording", {}) + streaming = output.get("streaming", {}) + simple_output = {} + if recording: + simple_output.update( + { + "FilePath": recording.get("path", ""), + "RecQuality": recording.get("quality", ""), + "RecFormat": recording.get("format", ""), + "RecEncoder": recording.get("encoder", ""), + } + ) + if streaming: + simple_output.update( + { + "VBitrate": str(streaming.get("bitrate", "")), + "StreamEncoder": streaming.get("encoder", ""), + } + ) + if dry_run: + log(f"Would update {basic_ini_path} [Output] with: {output_section}") + log(f"Would update {basic_ini_path} [SimpleOutput] with: {simple_output}") + if output_section: + changed = merge_ini_section(config, "Output", output_section) or changed + if simple_output: + changed = ( + merge_ini_section(config, "SimpleOutput", simple_output) or changed + ) + else: + if output_section: + changed = merge_ini_section(config, "Output", output_section) or changed + if simple_output: + changed = ( + merge_ini_section(config, "SimpleOutput", simple_output) or changed + ) + + hotkeys = args.get("hotkeys") + if hotkeys: + if dry_run: + log(f"Would update {basic_ini_path} [Hotkeys] with: {hotkeys}") + changed = merge_ini_section(config, "Hotkeys", hotkeys) or changed + else: + changed = merge_ini_section(config, "Hotkeys", hotkeys) or changed + + if changed and not dry_run: + write_ini(basic_ini_path, config) + log(f"Updated {basic_ini_path}") + + return changed + + +def ensure_profiles(obs_dir, profiles, dry_run): + changed = False + for profile in profiles: + name = profile.get("name") + if not name: + log("Skipping profile entry with no name") + continue + + profile_dir = os.path.join(obs_dir, "basic", "profiles", name) + basic_ini_path = os.path.join(profile_dir, "basic.ini") + + if dry_run: + log(f"Would create profile directory: {profile_dir}") + continue + + os.makedirs(profile_dir, exist_ok=True) + + if not os.path.exists(basic_ini_path): + config = configparser.RawConfigParser() + write_ini(basic_ini_path, config) + log(f"Created profile: {name}") + changed = True + else: + log(f"Profile already exists: {name}") + + settings = profile.get("settings", {}) + if settings: + config = read_ini(basic_ini_path) + section_changed = merge_ini_section(config, "General", settings) + if section_changed: + write_ini(basic_ini_path, config) + changed = True + + return changed + + +def apply_config(args, context, request_id): + dry_run = context.get("dryRun", False) + changed = False + + try: + obs_dir = get_obs_appdata() + + general = args.get("general") + if general: + changed = apply_global_ini(obs_dir, general, dry_run) or changed + + profile_name = args.get("profile") or get_active_profile(obs_dir) + has_profile_settings = any( + args.get(k) for k in ("video", "audio", "output", "hotkeys") + ) + + if has_profile_settings: + if not profile_name: + log( + "No profile specified and no active profile found — skipping video/audio/output/hotkeys" + ) + else: + changed = ( + apply_profile_ini(obs_dir, profile_name, args, dry_run) or changed + ) + + profiles = args.get("profiles", []) + if profiles: + changed = ensure_profiles(obs_dir, profiles, dry_run) or changed + + except Exception as e: + log(f"Error applying config: {e}") + return { + "requestId": request_id, + "success": False, + "changed": False, + "error": str(e), + "data": None, + } + + return { + "requestId": request_id, + "success": True, + "changed": changed, + "data": None, + } + + +def main(): + input_data = sys.stdin.read() + if not input_data: + sys.stdout.write( + json.dumps( + { + "requestId": "unknown", + "success": False, + "changed": False, + "error": "No input", + } + ) + + "\n" + ) + sys.stdout.flush() + return + + try: + request = json.loads(input_data) + except Exception as e: + log(f"Failed to parse request: {e}") + sys.stdout.write( + json.dumps( + { + "requestId": "unknown", + "success": False, + "changed": False, + "error": f"Failed to parse request: {e}", + } + ) + + "\n" + ) + sys.stdout.flush() + return + + request_id = request.get("requestId", "unknown") + command = request.get("command") + args = request.get("args", {}) + context = request.get("context", {}) + + response = { + "requestId": request_id, + "success": False, + "changed": False, + "data": None, + } + + try: + if command == "check_installed": + response = check_installed(args, request_id) + elif command == "apply": + response = apply_config(args, context, request_id) + else: + response["error"] = f"Unknown command: {command}" + except Exception as e: + response["error"] = f"Internal error: {str(e)}" + + sys.stdout.write(json.dumps(response) + "\n") + sys.stdout.flush() + + +if __name__ == "__main__": + main() diff --git a/plugins/obs-studio/test/test_obs_studio.py b/plugins/obs-studio/test/test_obs_studio.py index 1356f48b..7cba06b3 100644 --- a/plugins/obs-studio/test/test_obs_studio.py +++ b/plugins/obs-studio/test/test_obs_studio.py @@ -1,271 +1,279 @@ -import configparser -import json -import os -import subprocess -import sys -import tempfile - -PLUGIN = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "src", "plugin.py")) - - -def run_plugin(payload: dict, env=None) -> dict: - merged_env = dict(os.environ) - if env: - upper_map = {k.upper(): k for k in merged_env} - for k, v in env.items(): - existing_key = upper_map.get(k.upper(), k) - merged_env[existing_key] = v - result = subprocess.run( - [sys.executable, PLUGIN], - input=json.dumps(payload), - capture_output=True, - text=True, - env=merged_env, - ) - return json.loads(result.stdout.strip()) - - -def write_global_ini(obs_dir, profile_name): - global_ini = os.path.join(obs_dir, "global.ini") - config = configparser.RawConfigParser() - config.add_section("Basic") - config.set("Basic", "ProfileDir", profile_name) - os.makedirs(obs_dir, exist_ok=True) - with open(global_ini, "w") as f: - config.write(f) - - -def test_check_installed_false(): - with tempfile.TemporaryDirectory() as tmp: - res = run_plugin( - { - "requestId": "1", - "command": "check_installed", - "args": {}, - "context": {}, - }, - env={"OBS_INSTALL_DIR": tmp}, - ) - assert res["success"] - assert res["data"] is False - print("PASS: check_installed_false") - - -def test_check_installed_true(): - with tempfile.TemporaryDirectory() as tmp: - exe_dir = os.path.join(tmp, "bin", "64bit") - os.makedirs(exe_dir) - open(os.path.join(exe_dir, "obs64.exe"), "w").close() - - res = run_plugin( - { - "requestId": "2", - "command": "check_installed", - "args": {}, - "context": {}, - }, - env={"OBS_INSTALL_DIR": tmp}, - ) - assert res["success"] - assert res["data"] is True - print("PASS: check_installed_true") - - -def test_apply_general_creates_global_ini(): - with tempfile.TemporaryDirectory() as tmp: - obs_dir = os.path.join(tmp, "obs-studio") - - res = run_plugin( - { - "requestId": "3", - "command": "apply", - "args": {"general": {"theme": "Yami", "language": "en-US"}}, - "context": {"dryRun": False}, - }, - env={"APPDATA": tmp}, - ) - - assert res["success"] - assert res["changed"] - - global_ini = os.path.join(obs_dir, "global.ini") - assert os.path.exists(global_ini) - - config = configparser.RawConfigParser() - config.read(global_ini) - assert config.get("General", "theme") == "Yami" - assert config.get("General", "language") == "en-US" - print("PASS: apply_general_creates_global_ini") - - -def test_apply_general_merges_preserves_existing(): - with tempfile.TemporaryDirectory() as tmp: - obs_dir = os.path.join(tmp, "obs-studio") - os.makedirs(obs_dir) - - global_ini = os.path.join(obs_dir, "global.ini") - config = configparser.RawConfigParser() - config.add_section("General") - config.set("General", "Theme", "Default") - config.set("General", "ExistingKey", "KeepMe") - with open(global_ini, "w") as f: - config.write(f) - - run_plugin( - { - "requestId": "4", - "command": "apply", - "args": {"general": {"theme": "Yami"}}, - "context": {"dryRun": False}, - }, - env={"APPDATA": tmp}, - ) - - config2 = configparser.RawConfigParser() - config2.read(global_ini) - assert config2.get("General", "theme") == "Yami" - assert config2.get("General", "existingkey") == "KeepMe" - print("PASS: apply_general_merges_preserves_existing") - - -def test_apply_profile_video_audio(): - with tempfile.TemporaryDirectory() as tmp: - obs_dir = os.path.join(tmp, "obs-studio") - write_global_ini(obs_dir, "Streaming") - - res = run_plugin( - { - "requestId": "5", - "command": "apply", - "args": { - "video": {"base_resolution": "1920x1080", "fps_common": 60}, - "audio": {"sample_rate": 48000, "channels": "Stereo"}, - }, - "context": {"dryRun": False}, - }, - env={"APPDATA": tmp}, - ) - - assert res["success"] - assert res["changed"] - - basic_ini = os.path.join(obs_dir, "basic", "profiles", "Streaming", "basic.ini") - assert os.path.exists(basic_ini) - - config = configparser.RawConfigParser() - config.read(basic_ini) - assert config.get("Video", "base") == "1920x1080" - assert config.get("Video", "fpscommon") == "60" - assert config.get("Audio", "samplerate") == "48000" - print("PASS: apply_profile_video_audio") - - -def test_apply_dry_run_no_files_written(): - with tempfile.TemporaryDirectory() as tmp: - obs_dir = os.path.join(tmp, "obs-studio") - - res = run_plugin( - { - "requestId": "6", - "command": "apply", - "args": {"general": {"theme": "Yami"}}, - "context": {"dryRun": True}, - }, - env={"APPDATA": tmp}, - ) - - assert res["success"] - assert res["changed"] - assert not os.path.exists(os.path.join(obs_dir, "global.ini")) - print("PASS: apply_dry_run_no_files_written") - - -def test_apply_no_changes_idempotent(): - with tempfile.TemporaryDirectory() as tmp: - payload = { - "requestId": "7", - "command": "apply", - "args": {"general": {"theme": "Yami"}}, - "context": {"dryRun": False}, - } - env = {"APPDATA": tmp} - - run_plugin(payload, env=env) - res = run_plugin(payload, env=env) - - assert res["success"] - assert not res["changed"] - print("PASS: apply_no_changes_idempotent") - - -def test_apply_creates_profile_directories(): - with tempfile.TemporaryDirectory() as tmp: - obs_dir = os.path.join(tmp, "obs-studio") - - res = run_plugin( - { - "requestId": "8", - "command": "apply", - "args": { - "profiles": [ - {"name": "Streaming", "settings": {}}, - {"name": "Recording", "settings": {}}, - ] - }, - "context": {"dryRun": False}, - }, - env={"APPDATA": tmp}, - ) - - assert res["success"] - assert res["changed"] - assert os.path.exists(os.path.join(obs_dir, "basic", "profiles", "Streaming", "basic.ini")) - assert os.path.exists(os.path.join(obs_dir, "basic", "profiles", "Recording", "basic.ini")) - print("PASS: apply_creates_profile_directories") - - -def test_apply_profile_explicit_name(): - with tempfile.TemporaryDirectory() as tmp: - res = run_plugin( - { - "requestId": "9", - "command": "apply", - "args": { - "profile": "MyProfile", - "video": {"base_resolution": "2560x1440"}, - }, - "context": {"dryRun": False}, - }, - env={"APPDATA": tmp}, - ) - - assert res["success"] - obs_dir = os.path.join(tmp, "obs-studio") - basic_ini = os.path.join(obs_dir, "basic", "profiles", "MyProfile", "basic.ini") - assert os.path.exists(basic_ini) - - config = configparser.RawConfigParser() - config.read(basic_ini) - assert config.get("Video", "base") == "2560x1440" - print("PASS: apply_profile_explicit_name") - - -def test_unknown_command(): - res = run_plugin({"requestId": "10", "command": "explode", "args": {}, "context": {}}) - assert not res["success"] - assert "error" in res - print("PASS: unknown_command") - - -if __name__ == "__main__": - test_check_installed_false() - test_check_installed_true() - test_apply_general_creates_global_ini() - test_apply_general_merges_preserves_existing() - test_apply_profile_video_audio() - test_apply_dry_run_no_files_written() - test_apply_no_changes_idempotent() - test_apply_creates_profile_directories() - test_apply_profile_explicit_name() - test_unknown_command() - print("\nAll tests passed.") +import configparser +import json +import os +import subprocess +import sys +import tempfile + +PLUGIN = os.path.abspath( + os.path.join(os.path.dirname(__file__), "..", "src", "plugin.py") +) + + +def run_plugin(payload: dict, env=None) -> dict: + merged_env = dict(os.environ) + if env: + upper_map = {k.upper(): k for k in merged_env} + for k, v in env.items(): + existing_key = upper_map.get(k.upper(), k) + merged_env[existing_key] = v + result = subprocess.run( + [sys.executable, PLUGIN], + input=json.dumps(payload), + capture_output=True, + text=True, + env=merged_env, + ) + return json.loads(result.stdout.strip()) + + +def write_global_ini(obs_dir, profile_name): + global_ini = os.path.join(obs_dir, "global.ini") + config = configparser.RawConfigParser() + config.add_section("Basic") + config.set("Basic", "ProfileDir", profile_name) + os.makedirs(obs_dir, exist_ok=True) + with open(global_ini, "w") as f: + config.write(f) + + +def test_check_installed_false(): + with tempfile.TemporaryDirectory() as tmp: + res = run_plugin( + { + "requestId": "1", + "command": "check_installed", + "args": {}, + "context": {}, + }, + env={"OBS_INSTALL_DIR": tmp}, + ) + assert res["success"] + assert res["data"] is False + print("PASS: check_installed_false") + + +def test_check_installed_true(): + with tempfile.TemporaryDirectory() as tmp: + exe_dir = os.path.join(tmp, "bin", "64bit") + os.makedirs(exe_dir) + open(os.path.join(exe_dir, "obs64.exe"), "w").close() + + res = run_plugin( + { + "requestId": "2", + "command": "check_installed", + "args": {}, + "context": {}, + }, + env={"OBS_INSTALL_DIR": tmp}, + ) + assert res["success"] + assert res["data"] is True + print("PASS: check_installed_true") + + +def test_apply_general_creates_global_ini(): + with tempfile.TemporaryDirectory() as tmp: + obs_dir = os.path.join(tmp, "obs-studio") + + res = run_plugin( + { + "requestId": "3", + "command": "apply", + "args": {"general": {"theme": "Yami", "language": "en-US"}}, + "context": {"dryRun": False}, + }, + env={"APPDATA": tmp}, + ) + + assert res["success"] + assert res["changed"] + + global_ini = os.path.join(obs_dir, "global.ini") + assert os.path.exists(global_ini) + + config = configparser.RawConfigParser() + config.read(global_ini) + assert config.get("General", "theme") == "Yami" + assert config.get("General", "language") == "en-US" + print("PASS: apply_general_creates_global_ini") + + +def test_apply_general_merges_preserves_existing(): + with tempfile.TemporaryDirectory() as tmp: + obs_dir = os.path.join(tmp, "obs-studio") + os.makedirs(obs_dir) + + global_ini = os.path.join(obs_dir, "global.ini") + config = configparser.RawConfigParser() + config.add_section("General") + config.set("General", "Theme", "Default") + config.set("General", "ExistingKey", "KeepMe") + with open(global_ini, "w") as f: + config.write(f) + + run_plugin( + { + "requestId": "4", + "command": "apply", + "args": {"general": {"theme": "Yami"}}, + "context": {"dryRun": False}, + }, + env={"APPDATA": tmp}, + ) + + config2 = configparser.RawConfigParser() + config2.read(global_ini) + assert config2.get("General", "theme") == "Yami" + assert config2.get("General", "existingkey") == "KeepMe" + print("PASS: apply_general_merges_preserves_existing") + + +def test_apply_profile_video_audio(): + with tempfile.TemporaryDirectory() as tmp: + obs_dir = os.path.join(tmp, "obs-studio") + write_global_ini(obs_dir, "Streaming") + + res = run_plugin( + { + "requestId": "5", + "command": "apply", + "args": { + "video": {"base_resolution": "1920x1080", "fps_common": 60}, + "audio": {"sample_rate": 48000, "channels": "Stereo"}, + }, + "context": {"dryRun": False}, + }, + env={"APPDATA": tmp}, + ) + + assert res["success"] + assert res["changed"] + + basic_ini = os.path.join(obs_dir, "basic", "profiles", "Streaming", "basic.ini") + assert os.path.exists(basic_ini) + + config = configparser.RawConfigParser() + config.read(basic_ini) + assert config.get("Video", "base") == "1920x1080" + assert config.get("Video", "fpscommon") == "60" + assert config.get("Audio", "samplerate") == "48000" + print("PASS: apply_profile_video_audio") + + +def test_apply_dry_run_no_files_written(): + with tempfile.TemporaryDirectory() as tmp: + obs_dir = os.path.join(tmp, "obs-studio") + + res = run_plugin( + { + "requestId": "6", + "command": "apply", + "args": {"general": {"theme": "Yami"}}, + "context": {"dryRun": True}, + }, + env={"APPDATA": tmp}, + ) + + assert res["success"] + assert res["changed"] + assert not os.path.exists(os.path.join(obs_dir, "global.ini")) + print("PASS: apply_dry_run_no_files_written") + + +def test_apply_no_changes_idempotent(): + with tempfile.TemporaryDirectory() as tmp: + payload = { + "requestId": "7", + "command": "apply", + "args": {"general": {"theme": "Yami"}}, + "context": {"dryRun": False}, + } + env = {"APPDATA": tmp} + + run_plugin(payload, env=env) + res = run_plugin(payload, env=env) + + assert res["success"] + assert not res["changed"] + print("PASS: apply_no_changes_idempotent") + + +def test_apply_creates_profile_directories(): + with tempfile.TemporaryDirectory() as tmp: + obs_dir = os.path.join(tmp, "obs-studio") + + res = run_plugin( + { + "requestId": "8", + "command": "apply", + "args": { + "profiles": [ + {"name": "Streaming", "settings": {}}, + {"name": "Recording", "settings": {}}, + ] + }, + "context": {"dryRun": False}, + }, + env={"APPDATA": tmp}, + ) + + assert res["success"] + assert res["changed"] + assert os.path.exists( + os.path.join(obs_dir, "basic", "profiles", "Streaming", "basic.ini") + ) + assert os.path.exists( + os.path.join(obs_dir, "basic", "profiles", "Recording", "basic.ini") + ) + print("PASS: apply_creates_profile_directories") + + +def test_apply_profile_explicit_name(): + with tempfile.TemporaryDirectory() as tmp: + res = run_plugin( + { + "requestId": "9", + "command": "apply", + "args": { + "profile": "MyProfile", + "video": {"base_resolution": "2560x1440"}, + }, + "context": {"dryRun": False}, + }, + env={"APPDATA": tmp}, + ) + + assert res["success"] + obs_dir = os.path.join(tmp, "obs-studio") + basic_ini = os.path.join(obs_dir, "basic", "profiles", "MyProfile", "basic.ini") + assert os.path.exists(basic_ini) + + config = configparser.RawConfigParser() + config.read(basic_ini) + assert config.get("Video", "base") == "2560x1440" + print("PASS: apply_profile_explicit_name") + + +def test_unknown_command(): + res = run_plugin( + {"requestId": "10", "command": "explode", "args": {}, "context": {}} + ) + assert not res["success"] + assert "error" in res + print("PASS: unknown_command") + + +if __name__ == "__main__": + test_check_installed_false() + test_check_installed_true() + test_apply_general_creates_global_ini() + test_apply_general_merges_preserves_existing() + test_apply_profile_video_audio() + test_apply_dry_run_no_files_written() + test_apply_no_changes_idempotent() + test_apply_creates_profile_directories() + test_apply_profile_explicit_name() + test_unknown_command() + print("\nAll tests passed.") diff --git a/plugins/obsidian/src/plugin.py b/plugins/obsidian/src/plugin.py index 6ad21575..82058ec0 100644 --- a/plugins/obsidian/src/plugin.py +++ b/plugins/obsidian/src/plugin.py @@ -53,18 +53,24 @@ def make_request(url: str): """Make an HTTP request to the given URL with a user agent""" if os.environ.get("WINHOME_TEST_MOCK_URLOPEN") == "1": if "community-plugins.json" in url: - data = json.dumps([{"id": "obsidian-git", "repo": "mgmeyers/obsidian-git"}]).encode("utf-8") + data = json.dumps( + [{"id": "obsidian-git", "repo": "mgmeyers/obsidian-git"}] + ).encode("utf-8") return MockResponse(data) elif "releases/latest" in url: data = json.dumps({"tag_name": "v1.2.3"}).encode("utf-8") return MockResponse(data) elif "manifest.json" in url: - data = json.dumps({"id": "obsidian-git", "version": "1.2.3"}).encode("utf-8") + data = json.dumps({"id": "obsidian-git", "version": "1.2.3"}).encode( + "utf-8" + ) return MockResponse(data) else: return MockResponse(b"mock content") - req = urllib.request.Request(url, headers={"User-Agent": "WinHome-Environment-Manager/1.0"}) + req = urllib.request.Request( + url, headers={"User-Agent": "WinHome-Environment-Manager/1.0"} + ) return urllib.request.urlopen(req) @@ -218,7 +224,9 @@ def download_plugin(vault_path: str, plugin_id: str, repo: str, version: str) -> continue if not success and file != "styles.css": - raise Exception(f"Failed to download required asset '{file}' for {plugin_id}: {last_error}") + raise Exception( + f"Failed to download required asset '{file}' for {plugin_id}: {last_error}" + ) # Apply Changes @@ -340,7 +348,9 @@ def apply_config(args: dict, context: dict, request_id: str) -> dict: continue if vault.get("settings"): - res = apply_vault_settings(vault_path, vault["settings"], context.get("dryRun", False)) + res = apply_vault_settings( + vault_path, vault["settings"], context.get("dryRun", False) + ) if not res["success"]: overall_success = False if res["changed"]: diff --git a/plugins/obsidian/test/test_obsidian.py b/plugins/obsidian/test/test_obsidian.py index da7ec87e..7edc1e67 100644 --- a/plugins/obsidian/test/test_obsidian.py +++ b/plugins/obsidian/test/test_obsidian.py @@ -4,7 +4,9 @@ import sys import tempfile -PLUGIN = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "src", "plugin.py")) +PLUGIN = os.path.abspath( + os.path.join(os.path.dirname(__file__), "..", "src", "plugin.py") +) def run_plugin(payload: dict) -> dict: @@ -54,7 +56,9 @@ def test_apply_settings(): assert res["changed"] app = json.loads(open(os.path.join(vault, ".obsidian", "app.json")).read()) - appearance = json.loads(open(os.path.join(vault, ".obsidian", "appearance.json")).read()) + appearance = json.loads( + open(os.path.join(vault, ".obsidian", "appearance.json")).read() + ) assert app["spellcheck"] == True assert appearance["accentColor"] == "#002aff" print("✓ apply_settings") @@ -131,7 +135,9 @@ def test_nonexistent_vault(): def test_unknown_command(): - res = run_plugin({"requestId": "6", "command": "explode", "args": {}, "context": {}}) + res = run_plugin( + {"requestId": "6", "command": "explode", "args": {}, "context": {}} + ) assert not res["success"] assert "error" in res print("✓ unknown_command") @@ -155,7 +161,9 @@ def test_install_plugin(): assert os.path.exists(os.path.join(plugin_dir, "main.js")) assert os.path.exists(os.path.join(plugin_dir, "manifest.json")) - enabled = json.loads(open(os.path.join(vault, ".obsidian", "community-plugins.json")).read()) + enabled = json.loads( + open(os.path.join(vault, ".obsidian", "community-plugins.json")).read() + ) assert "obsidian-git" in enabled print("✓ install_plugin") @@ -209,7 +217,9 @@ def test_uninstall_plugin(): plugin_dir = os.path.join(vault, ".obsidian", "plugins", "obsidian-git") assert not os.path.exists(plugin_dir) - enabled = json.loads(open(os.path.join(vault, ".obsidian", "community-plugins.json")).read()) + enabled = json.loads( + open(os.path.join(vault, ".obsidian", "community-plugins.json")).read() + ) assert "obsidian-git" not in enabled print("✓ uninstall_plugin") diff --git a/plugins/ohmyposh/src/plugin.py b/plugins/ohmyposh/src/plugin.py index 9989b948..e73e9b0c 100644 --- a/plugins/ohmyposh/src/plugin.py +++ b/plugins/ohmyposh/src/plugin.py @@ -4,11 +4,17 @@ # Dynamic Profile Discovery -_MODERN_PATH = os.path.expandvars(r"%USERPROFILE%\Documents\PowerShell\Microsoft.PowerShell_profile.ps1") -_LEGACY_PATH = os.path.expandvars(r"%USERPROFILE%\Documents\WindowsPowerShell\Microsoft.PowerShell_profile.ps1") +_MODERN_PATH = os.path.expandvars( + r"%USERPROFILE%\Documents\PowerShell\Microsoft.PowerShell_profile.ps1" +) +_LEGACY_PATH = os.path.expandvars( + r"%USERPROFILE%\Documents\WindowsPowerShell\Microsoft.PowerShell_profile.ps1" +) # Default modern path, fallback to legacy -PROFILE_PATH = _MODERN_PATH if os.path.exists(os.path.dirname(_MODERN_PATH)) else _LEGACY_PATH +PROFILE_PATH = ( + _MODERN_PATH if os.path.exists(os.path.dirname(_MODERN_PATH)) else _LEGACY_PATH +) OMP_BEGIN = "# OH-MY-POSH-PLUGIN BEGIN" OMP_END = "# OH-MY-POSH-PLUGIN END" @@ -52,7 +58,9 @@ def apply_config(args: dict, context: dict, request_id: str) -> dict: "error": "No theme specified", } - profile_path = args.get("profile") or args.get("settings", {}).get("profile") or PROFILE_PATH + profile_path = ( + args.get("profile") or args.get("settings", {}).get("profile") or PROFILE_PATH + ) desired_line = build_omp_line(theme) current_content = read_profile(profile_path) @@ -82,7 +90,12 @@ def apply_config(args: dict, context: dict, request_id: str) -> dict: log(f"Updated theme to {theme}") else: # first time setting - new_profile = current_content.rstrip("\n") + ("\n\n" if current_content else "") + omp_block + "\n" + new_profile = ( + current_content.rstrip("\n") + + ("\n\n" if current_content else "") + + omp_block + + "\n" + ) log(f"Created new omp block for {theme}") write_profile(profile_path, new_profile) @@ -99,7 +112,9 @@ def apply_config(args: dict, context: dict, request_id: str) -> dict: def check_installed(args: dict, request_id: str) -> dict: theme = args.get("theme") or args.get("settings", {}).get("theme") - profile_path = args.get("profile") or args.get("settings", {}).get("profile") or PROFILE_PATH + profile_path = ( + args.get("profile") or args.get("settings", {}).get("profile") or PROFILE_PATH + ) current_content = read_profile(profile_path) if theme: diff --git a/plugins/ohmyposh/test/test_omp.py b/plugins/ohmyposh/test/test_omp.py index b08e0354..e1d74620 100644 --- a/plugins/ohmyposh/test/test_omp.py +++ b/plugins/ohmyposh/test/test_omp.py @@ -5,7 +5,9 @@ import tempfile # Compute dynamic path to the oh-my-posh plugin script -PLUGIN = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "src", "plugin.py")) +PLUGIN = os.path.abspath( + os.path.join(os.path.dirname(__file__), "..", "src", "plugin.py") +) def run_plugin(payload: dict) -> dict: @@ -52,7 +54,10 @@ def test_apply_fresh_install(): content = read_file(profile) assert "# OH-MY-POSH-PLUGIN BEGIN" in content - assert 'oh-my-posh init pwsh --config "tokyonight_storm" | Invoke-Expression' in content + assert ( + 'oh-my-posh init pwsh --config "tokyonight_storm" | Invoke-Expression' + in content + ) assert "# OH-MY-POSH-PLUGIN END" in content print("✓ test_apply_fresh_install") @@ -212,7 +217,9 @@ def test_check_installed(): def test_invalid_payloads(): - res_err = run_plugin({"requestId": "6a", "command": "apply", "args": {}, "context": {}}) + res_err = run_plugin( + {"requestId": "6a", "command": "apply", "args": {}, "context": {}} + ) assert not res_err["success"] assert "error" in res_err diff --git a/plugins/opencode/src/plugin.py b/plugins/opencode/src/plugin.py index eae86d8d..510a29f6 100644 --- a/plugins/opencode/src/plugin.py +++ b/plugins/opencode/src/plugin.py @@ -1,251 +1,271 @@ -import copy -import json -import os -import shutil -import sys -from pathlib import Path - -PLUGIN_NAME = "opencode" -CONFIG_DIR = os.path.join(".config", "opencode") -CONFIG_FILE = "opencode.json" -PATH_ARG_KEYS = { - "projectRoot", - "project_root", - "configPath", - "config_path", -} - - -def log(message: str) -> None: - sys.stderr.write(f"[{PLUGIN_NAME}-plugin] {message}\n") - sys.stderr.flush() - - -def response(request_id: str, success: bool, changed: bool, error=None, data=None) -> dict: - result = { - "requestId": request_id, - "success": success, - "changed": changed, - } - - if error is not None: - result["error"] = error - - if data is not None: - result["data"] = data - - return result - - -def strip_jsonc_comments(text: str) -> str: - output = [] - in_string = False - escaped = False - index = 0 - - while index < len(text): - char = text[index] - next_char = text[index + 1] if index + 1 < len(text) else "" - - if in_string: - output.append(char) - if escaped: - escaped = False - elif char == "\\": - escaped = True - elif char == '"': - in_string = False - index += 1 - continue - - if char == '"': - in_string = True - output.append(char) - index += 1 - continue - - if char == "/" and next_char == "/": - while index < len(text) and text[index] not in "\r\n": - index += 1 - continue - - if char == "/" and next_char == "*": - index += 2 - while index < len(text): - if text[index] == "*" and index + 1 < len(text) and text[index + 1] == "/": - index += 2 - break - index += 1 - continue - - output.append(char) - index += 1 - - return "".join(output) - - -def read_jsonc(file_path: str) -> dict: - if not os.path.exists(file_path): - return {} - - try: - with open(file_path, "r", encoding="utf-8") as config_file: - text = config_file.read() - - if not text.strip(): - return {} - - parsed = json.loads(strip_jsonc_comments(text)) - if isinstance(parsed, dict): - return parsed - - log(f"Warning: expected object in {file_path}, got {type(parsed).__name__}") - return {} - except Exception as exc: - log(f"Warning: could not parse {file_path}: {exc}") - return {} - - -def write_json(file_path: str, data: dict) -> None: - os.makedirs(os.path.dirname(file_path), exist_ok=True) - tmp_path = f"{file_path}.tmp" - - with open(tmp_path, "w", encoding="utf-8") as config_file: - json.dump(data, config_file, indent=2) - config_file.write("\n") - - os.replace(tmp_path, file_path) - - -def user_home() -> str: - return os.getenv("USERPROFILE") or str(Path.home()) - - -def get_config_path(args: dict, context: dict) -> str: - explicit_path = ( - args.get("configPath") or args.get("config_path") or context.get("configPath") or context.get("config_path") - ) - if explicit_path: - return os.path.abspath(os.path.expandvars(os.path.expanduser(str(explicit_path)))) - - project_root = ( - args.get("projectRoot") or args.get("project_root") or context.get("projectRoot") or context.get("project_root") - ) - if project_root: - return os.path.join( - os.path.abspath(os.path.expandvars(os.path.expanduser(str(project_root)))), - CONFIG_FILE, - ) - - return os.path.join(user_home(), CONFIG_DIR, CONFIG_FILE) - - -def desired_config_from_args(args: dict) -> dict: - if not isinstance(args, dict): - return {} - - if "settings" in args and isinstance(args["settings"], dict): - return copy.deepcopy(args["settings"]) - - return {key: copy.deepcopy(value) for key, value in args.items() if key not in PATH_ARG_KEYS} - - -def deep_merge(target: dict, source: dict) -> bool: - changed = False - - for key, value in source.items(): - if isinstance(value, dict) and isinstance(target.get(key), dict): - changed = deep_merge(target[key], value) or changed - continue - - if target.get(key) != value: - target[key] = copy.deepcopy(value) - changed = True - - return changed - - -def check_installed(args: dict, request_id: str) -> dict: - installed = shutil.which("opencode") is not None - - return response( - request_id, - success=True, - changed=False, - data=installed, - ) - - -def apply_config(args: dict, context: dict, request_id: str) -> dict: - dry_run = bool(context.get("dryRun", False)) - - try: - config_path = get_config_path(args, context) - desired = desired_config_from_args(args) - current = read_jsonc(config_path) - next_config = copy.deepcopy(current) - changed = deep_merge(next_config, desired) - - if not changed: - return response(request_id, success=True, changed=False) - - if dry_run: - log(f"Would update {config_path} with keys: {', '.join(sorted(desired.keys()))}") - return response(request_id, success=True, changed=True) - - write_json(config_path, next_config) - log(f"Updated opencode config: {config_path}") - - return response(request_id, success=True, changed=True) - - except Exception as exc: - log(f"Failed to apply config: {exc}") - return response(request_id, success=False, changed=False, error=str(exc)) - - -def process_request(request: dict) -> dict: - request_id = request.get("requestId", "unknown") - command = request.get("command") - args = request.get("args", request.get("config", {})) - context = request.get("context", {}) - - if "dryRun" in request and "dryRun" not in context: - context = dict(context) - context["dryRun"] = request.get("dryRun") - - if command == "check_installed": - return check_installed(args, request_id) - - if command == "apply": - return apply_config(args, context, request_id) - - return response( - request_id, - success=False, - changed=False, - error=f"Unknown command: {command}", - ) - - -def main() -> None: - input_data = sys.stdin.read() - - if not input_data: - result = response("unknown", success=False, changed=False, error="Empty input") - sys.stdout.write(json.dumps(result) + "\n") - sys.stdout.flush() - return - - try: - request = json.loads(input_data) - result = process_request(request) - except Exception as exc: - log(f"Internal Script Error: {exc}") - result = response("unknown", success=False, changed=False, error=str(exc)) - - sys.stdout.write(json.dumps(result) + "\n") - sys.stdout.flush() - - -if __name__ == "__main__": - main() +import copy +import json +import os +import shutil +import sys +from pathlib import Path + +PLUGIN_NAME = "opencode" +CONFIG_DIR = os.path.join(".config", "opencode") +CONFIG_FILE = "opencode.json" +PATH_ARG_KEYS = { + "projectRoot", + "project_root", + "configPath", + "config_path", +} + + +def log(message: str) -> None: + sys.stderr.write(f"[{PLUGIN_NAME}-plugin] {message}\n") + sys.stderr.flush() + + +def response( + request_id: str, success: bool, changed: bool, error=None, data=None +) -> dict: + result = { + "requestId": request_id, + "success": success, + "changed": changed, + } + + if error is not None: + result["error"] = error + + if data is not None: + result["data"] = data + + return result + + +def strip_jsonc_comments(text: str) -> str: + output = [] + in_string = False + escaped = False + index = 0 + + while index < len(text): + char = text[index] + next_char = text[index + 1] if index + 1 < len(text) else "" + + if in_string: + output.append(char) + if escaped: + escaped = False + elif char == "\\": + escaped = True + elif char == '"': + in_string = False + index += 1 + continue + + if char == '"': + in_string = True + output.append(char) + index += 1 + continue + + if char == "/" and next_char == "/": + while index < len(text) and text[index] not in "\r\n": + index += 1 + continue + + if char == "/" and next_char == "*": + index += 2 + while index < len(text): + if ( + text[index] == "*" + and index + 1 < len(text) + and text[index + 1] == "/" + ): + index += 2 + break + index += 1 + continue + + output.append(char) + index += 1 + + return "".join(output) + + +def read_jsonc(file_path: str) -> dict: + if not os.path.exists(file_path): + return {} + + try: + with open(file_path, "r", encoding="utf-8") as config_file: + text = config_file.read() + + if not text.strip(): + return {} + + parsed = json.loads(strip_jsonc_comments(text)) + if isinstance(parsed, dict): + return parsed + + log(f"Warning: expected object in {file_path}, got {type(parsed).__name__}") + return {} + except Exception as exc: + log(f"Warning: could not parse {file_path}: {exc}") + return {} + + +def write_json(file_path: str, data: dict) -> None: + os.makedirs(os.path.dirname(file_path), exist_ok=True) + tmp_path = f"{file_path}.tmp" + + with open(tmp_path, "w", encoding="utf-8") as config_file: + json.dump(data, config_file, indent=2) + config_file.write("\n") + + os.replace(tmp_path, file_path) + + +def user_home() -> str: + return os.getenv("USERPROFILE") or str(Path.home()) + + +def get_config_path(args: dict, context: dict) -> str: + explicit_path = ( + args.get("configPath") + or args.get("config_path") + or context.get("configPath") + or context.get("config_path") + ) + if explicit_path: + return os.path.abspath( + os.path.expandvars(os.path.expanduser(str(explicit_path))) + ) + + project_root = ( + args.get("projectRoot") + or args.get("project_root") + or context.get("projectRoot") + or context.get("project_root") + ) + if project_root: + return os.path.join( + os.path.abspath(os.path.expandvars(os.path.expanduser(str(project_root)))), + CONFIG_FILE, + ) + + return os.path.join(user_home(), CONFIG_DIR, CONFIG_FILE) + + +def desired_config_from_args(args: dict) -> dict: + if not isinstance(args, dict): + return {} + + if "settings" in args and isinstance(args["settings"], dict): + return copy.deepcopy(args["settings"]) + + return { + key: copy.deepcopy(value) + for key, value in args.items() + if key not in PATH_ARG_KEYS + } + + +def deep_merge(target: dict, source: dict) -> bool: + changed = False + + for key, value in source.items(): + if isinstance(value, dict) and isinstance(target.get(key), dict): + changed = deep_merge(target[key], value) or changed + continue + + if target.get(key) != value: + target[key] = copy.deepcopy(value) + changed = True + + return changed + + +def check_installed(args: dict, request_id: str) -> dict: + installed = shutil.which("opencode") is not None + + return response( + request_id, + success=True, + changed=False, + data=installed, + ) + + +def apply_config(args: dict, context: dict, request_id: str) -> dict: + dry_run = bool(context.get("dryRun", False)) + + try: + config_path = get_config_path(args, context) + desired = desired_config_from_args(args) + current = read_jsonc(config_path) + next_config = copy.deepcopy(current) + changed = deep_merge(next_config, desired) + + if not changed: + return response(request_id, success=True, changed=False) + + if dry_run: + log( + f"Would update {config_path} with keys: {', '.join(sorted(desired.keys()))}" + ) + return response(request_id, success=True, changed=True) + + write_json(config_path, next_config) + log(f"Updated opencode config: {config_path}") + + return response(request_id, success=True, changed=True) + + except Exception as exc: + log(f"Failed to apply config: {exc}") + return response(request_id, success=False, changed=False, error=str(exc)) + + +def process_request(request: dict) -> dict: + request_id = request.get("requestId", "unknown") + command = request.get("command") + args = request.get("args", request.get("config", {})) + context = request.get("context", {}) + + if "dryRun" in request and "dryRun" not in context: + context = dict(context) + context["dryRun"] = request.get("dryRun") + + if command == "check_installed": + return check_installed(args, request_id) + + if command == "apply": + return apply_config(args, context, request_id) + + return response( + request_id, + success=False, + changed=False, + error=f"Unknown command: {command}", + ) + + +def main() -> None: + input_data = sys.stdin.read() + + if not input_data: + result = response("unknown", success=False, changed=False, error="Empty input") + sys.stdout.write(json.dumps(result) + "\n") + sys.stdout.flush() + return + + try: + request = json.loads(input_data) + result = process_request(request) + except Exception as exc: + log(f"Internal Script Error: {exc}") + result = response("unknown", success=False, changed=False, error=str(exc)) + + sys.stdout.write(json.dumps(result) + "\n") + sys.stdout.flush() + + +if __name__ == "__main__": + main() diff --git a/plugins/opencode/test/test_opencode.py b/plugins/opencode/test/test_opencode.py index 83ea6387..31538ab2 100644 --- a/plugins/opencode/test/test_opencode.py +++ b/plugins/opencode/test/test_opencode.py @@ -1,291 +1,295 @@ -import importlib.util -import json -import os -import subprocess -import sys -import tempfile - -PLUGIN = os.path.abspath( - os.path.join( - os.path.dirname(__file__), - "..", - "src", - "plugin.py", - ) -) - - -def load_plugin(): - spec = importlib.util.spec_from_file_location("opencode_plugin", PLUGIN) - module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) - return module - - -def run_plugin(payload: dict, env=None) -> dict: - process_env = os.environ.copy() - if env: - process_env.update(env) - - result = subprocess.run( - [sys.executable, PLUGIN], - input=json.dumps(payload), - capture_output=True, - text=True, - env=process_env, - check=False, - ) - - assert result.returncode == 0, result.stderr - return json.loads(result.stdout.strip()) - - -def apply_payload(args: dict, dry_run: bool = False) -> dict: - return { - "requestId": "apply-1", - "command": "apply", - "args": args, - "context": {"dryRun": dry_run}, - } - - -def test_check_installed_returns_bare_installed_boolean(): - response = run_plugin( - { - "requestId": "check-1", - "command": "check_installed", - "args": {}, - "context": {}, - } - ) - - assert response["requestId"] == "check-1" - assert response["success"] is True - assert response["changed"] is False - assert isinstance(response["data"], bool) - - -def test_apply_creates_missing_global_config_directory(): - with tempfile.TemporaryDirectory() as tmp: - response = run_plugin( - apply_payload( - { - "model": "anthropic/claude-sonnet-4-5", - "small_model": "anthropic/claude-haiku-4-5", - } - ), - env={"USERPROFILE": tmp}, - ) - - config_path = os.path.join(tmp, ".config", "opencode", "opencode.json") - - assert response["success"] is True - assert response["changed"] is True - assert os.path.exists(config_path) - assert not os.path.exists(f"{config_path}.tmp") - - with open(config_path, "r", encoding="utf-8") as config_file: - saved = json.load(config_file) - - assert saved["model"] == "anthropic/claude-sonnet-4-5" - assert saved["small_model"] == "anthropic/claude-haiku-4-5" - - -def test_apply_reads_jsonc_and_preserves_existing_keys(): - with tempfile.TemporaryDirectory() as tmp: - config_dir = os.path.join(tmp, ".config", "opencode") - os.makedirs(config_dir) - config_path = os.path.join(config_dir, "opencode.json") - - with open(config_path, "w", encoding="utf-8") as config_file: - config_file.write( - "{\n" - " // keep this unrelated setting\n" - ' "theme": "dark",\n' - ' "url": "https://example.com//not-a-comment",\n' - ' "permission": {\n' - ' "write": "deny",\n' - ' "read": "allow"\n' - " },\n" - ' "agent": {\n' - ' "existing": { "mode": "subagent" }\n' - " }\n" - "}\n" - ) - - response = run_plugin( - apply_payload( - { - "permission": { - "write": "allow", - "edit": "allow", - }, - "agent": { - "code-reviewer": { - "description": "Reviews code for best practices", - "model": "anthropic/claude-sonnet-4-5", - "permission": { - "write": "deny", - "read": "allow", - }, - "mode": "subagent", - }, - }, - } - ), - env={"USERPROFILE": tmp}, - ) - - with open(config_path, "r", encoding="utf-8") as config_file: - saved = json.load(config_file) - - assert response["success"] is True - assert response["changed"] is True - assert saved["theme"] == "dark" - assert saved["url"] == "https://example.com//not-a-comment" - assert saved["permission"]["read"] == "allow" - assert saved["permission"]["write"] == "allow" - assert saved["permission"]["edit"] == "allow" - assert saved["agent"]["existing"]["mode"] == "subagent" - assert saved["agent"]["code-reviewer"]["mode"] == "subagent" - - -def test_apply_dry_run_reports_change_without_writing(): - with tempfile.TemporaryDirectory() as tmp: - response = run_plugin( - apply_payload({"default_agent": "build"}, dry_run=True), - env={"USERPROFILE": tmp}, - ) - - config_path = os.path.join(tmp, ".config", "opencode", "opencode.json") - - assert response["success"] is True - assert response["changed"] is True - assert not os.path.exists(config_path) - - -def test_idempotent_apply_reports_unchanged_second_time(): - with tempfile.TemporaryDirectory() as tmp: - env = {"USERPROFILE": tmp} - payload = apply_payload( - { - "command": { - "test": { - "template": "Run the full test suite with coverage...", - "description": "Run tests with coverage", - }, - }, - } - ) - - first = run_plugin(payload, env=env) - second = run_plugin(payload, env=env) - - assert first["changed"] is True - assert second["success"] is True - assert second["changed"] is False - - -def test_apply_supports_project_level_config(): - with tempfile.TemporaryDirectory() as tmp: - response = run_plugin( - apply_payload( - { - "projectRoot": tmp, - "mcp": { - "filesystem": { - "type": "local", - "command": [ - "npx", - "-y", - "@modelcontextprotocol/server-filesystem", - "/path", - ], - "enabled": True, - }, - }, - } - ), - env={"USERPROFILE": os.path.join(tmp, "home")}, - ) - - config_path = os.path.join(tmp, "opencode.json") - - assert response["success"] is True - assert response["changed"] is True - assert os.path.exists(config_path) - - with open(config_path, "r", encoding="utf-8") as config_file: - saved = json.load(config_file) - - assert saved["mcp"]["filesystem"]["enabled"] is True - assert "projectRoot" not in saved - - -def test_apply_supports_legacy_config_and_top_level_dry_run(): - with tempfile.TemporaryDirectory() as tmp: - response = run_plugin( - { - "requestId": "legacy-1", - "command": "apply", - "config": {"model": "anthropic/claude-sonnet-4-5"}, - "dryRun": True, - }, - env={"USERPROFILE": tmp}, - ) - - config_path = os.path.join(tmp, ".config", "opencode", "opencode.json") - - assert response["success"] is True - assert response["changed"] is True - assert not os.path.exists(config_path) - - -def test_strip_jsonc_comments_keeps_comment_like_text_in_strings(): - plugin = load_plugin() - cleaned = plugin.strip_jsonc_comments('{"url": "https://example.com//ok", // remove me\n "value": 1}') - - assert json.loads(cleaned) == { - "url": "https://example.com//ok", - "value": 1, - } - - -def test_strip_jsonc_comments_handles_block_comments(): - plugin = load_plugin() - cleaned = plugin.strip_jsonc_comments('{"url": "https://example.com//ok", /* remove\n me */ "value": 1}') - - assert json.loads(cleaned) == { - "url": "https://example.com//ok", - "value": 1, - } - - -def test_unknown_command(): - response = run_plugin( - { - "requestId": "unknown-1", - "command": "explode", - "args": {}, - "context": {}, - } - ) - - assert response["requestId"] == "unknown-1" - assert response["success"] is False - assert "Unknown command" in response["error"] - - -if __name__ == "__main__": - test_check_installed_returns_bare_installed_boolean() - test_apply_creates_missing_global_config_directory() - test_apply_reads_jsonc_and_preserves_existing_keys() - test_apply_dry_run_reports_change_without_writing() - test_idempotent_apply_reports_unchanged_second_time() - test_apply_supports_project_level_config() - test_apply_supports_legacy_config_and_top_level_dry_run() - test_strip_jsonc_comments_keeps_comment_like_text_in_strings() - test_strip_jsonc_comments_handles_block_comments() - test_unknown_command() - - print("\nAll tests passed.") +import importlib.util +import json +import os +import subprocess +import sys +import tempfile + +PLUGIN = os.path.abspath( + os.path.join( + os.path.dirname(__file__), + "..", + "src", + "plugin.py", + ) +) + + +def load_plugin(): + spec = importlib.util.spec_from_file_location("opencode_plugin", PLUGIN) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def run_plugin(payload: dict, env=None) -> dict: + process_env = os.environ.copy() + if env: + process_env.update(env) + + result = subprocess.run( + [sys.executable, PLUGIN], + input=json.dumps(payload), + capture_output=True, + text=True, + env=process_env, + check=False, + ) + + assert result.returncode == 0, result.stderr + return json.loads(result.stdout.strip()) + + +def apply_payload(args: dict, dry_run: bool = False) -> dict: + return { + "requestId": "apply-1", + "command": "apply", + "args": args, + "context": {"dryRun": dry_run}, + } + + +def test_check_installed_returns_bare_installed_boolean(): + response = run_plugin( + { + "requestId": "check-1", + "command": "check_installed", + "args": {}, + "context": {}, + } + ) + + assert response["requestId"] == "check-1" + assert response["success"] is True + assert response["changed"] is False + assert isinstance(response["data"], bool) + + +def test_apply_creates_missing_global_config_directory(): + with tempfile.TemporaryDirectory() as tmp: + response = run_plugin( + apply_payload( + { + "model": "anthropic/claude-sonnet-4-5", + "small_model": "anthropic/claude-haiku-4-5", + } + ), + env={"USERPROFILE": tmp}, + ) + + config_path = os.path.join(tmp, ".config", "opencode", "opencode.json") + + assert response["success"] is True + assert response["changed"] is True + assert os.path.exists(config_path) + assert not os.path.exists(f"{config_path}.tmp") + + with open(config_path, "r", encoding="utf-8") as config_file: + saved = json.load(config_file) + + assert saved["model"] == "anthropic/claude-sonnet-4-5" + assert saved["small_model"] == "anthropic/claude-haiku-4-5" + + +def test_apply_reads_jsonc_and_preserves_existing_keys(): + with tempfile.TemporaryDirectory() as tmp: + config_dir = os.path.join(tmp, ".config", "opencode") + os.makedirs(config_dir) + config_path = os.path.join(config_dir, "opencode.json") + + with open(config_path, "w", encoding="utf-8") as config_file: + config_file.write( + "{\n" + " // keep this unrelated setting\n" + ' "theme": "dark",\n' + ' "url": "https://example.com//not-a-comment",\n' + ' "permission": {\n' + ' "write": "deny",\n' + ' "read": "allow"\n' + " },\n" + ' "agent": {\n' + ' "existing": { "mode": "subagent" }\n' + " }\n" + "}\n" + ) + + response = run_plugin( + apply_payload( + { + "permission": { + "write": "allow", + "edit": "allow", + }, + "agent": { + "code-reviewer": { + "description": "Reviews code for best practices", + "model": "anthropic/claude-sonnet-4-5", + "permission": { + "write": "deny", + "read": "allow", + }, + "mode": "subagent", + }, + }, + } + ), + env={"USERPROFILE": tmp}, + ) + + with open(config_path, "r", encoding="utf-8") as config_file: + saved = json.load(config_file) + + assert response["success"] is True + assert response["changed"] is True + assert saved["theme"] == "dark" + assert saved["url"] == "https://example.com//not-a-comment" + assert saved["permission"]["read"] == "allow" + assert saved["permission"]["write"] == "allow" + assert saved["permission"]["edit"] == "allow" + assert saved["agent"]["existing"]["mode"] == "subagent" + assert saved["agent"]["code-reviewer"]["mode"] == "subagent" + + +def test_apply_dry_run_reports_change_without_writing(): + with tempfile.TemporaryDirectory() as tmp: + response = run_plugin( + apply_payload({"default_agent": "build"}, dry_run=True), + env={"USERPROFILE": tmp}, + ) + + config_path = os.path.join(tmp, ".config", "opencode", "opencode.json") + + assert response["success"] is True + assert response["changed"] is True + assert not os.path.exists(config_path) + + +def test_idempotent_apply_reports_unchanged_second_time(): + with tempfile.TemporaryDirectory() as tmp: + env = {"USERPROFILE": tmp} + payload = apply_payload( + { + "command": { + "test": { + "template": "Run the full test suite with coverage...", + "description": "Run tests with coverage", + }, + }, + } + ) + + first = run_plugin(payload, env=env) + second = run_plugin(payload, env=env) + + assert first["changed"] is True + assert second["success"] is True + assert second["changed"] is False + + +def test_apply_supports_project_level_config(): + with tempfile.TemporaryDirectory() as tmp: + response = run_plugin( + apply_payload( + { + "projectRoot": tmp, + "mcp": { + "filesystem": { + "type": "local", + "command": [ + "npx", + "-y", + "@modelcontextprotocol/server-filesystem", + "/path", + ], + "enabled": True, + }, + }, + } + ), + env={"USERPROFILE": os.path.join(tmp, "home")}, + ) + + config_path = os.path.join(tmp, "opencode.json") + + assert response["success"] is True + assert response["changed"] is True + assert os.path.exists(config_path) + + with open(config_path, "r", encoding="utf-8") as config_file: + saved = json.load(config_file) + + assert saved["mcp"]["filesystem"]["enabled"] is True + assert "projectRoot" not in saved + + +def test_apply_supports_legacy_config_and_top_level_dry_run(): + with tempfile.TemporaryDirectory() as tmp: + response = run_plugin( + { + "requestId": "legacy-1", + "command": "apply", + "config": {"model": "anthropic/claude-sonnet-4-5"}, + "dryRun": True, + }, + env={"USERPROFILE": tmp}, + ) + + config_path = os.path.join(tmp, ".config", "opencode", "opencode.json") + + assert response["success"] is True + assert response["changed"] is True + assert not os.path.exists(config_path) + + +def test_strip_jsonc_comments_keeps_comment_like_text_in_strings(): + plugin = load_plugin() + cleaned = plugin.strip_jsonc_comments( + '{"url": "https://example.com//ok", // remove me\n "value": 1}' + ) + + assert json.loads(cleaned) == { + "url": "https://example.com//ok", + "value": 1, + } + + +def test_strip_jsonc_comments_handles_block_comments(): + plugin = load_plugin() + cleaned = plugin.strip_jsonc_comments( + '{"url": "https://example.com//ok", /* remove\n me */ "value": 1}' + ) + + assert json.loads(cleaned) == { + "url": "https://example.com//ok", + "value": 1, + } + + +def test_unknown_command(): + response = run_plugin( + { + "requestId": "unknown-1", + "command": "explode", + "args": {}, + "context": {}, + } + ) + + assert response["requestId"] == "unknown-1" + assert response["success"] is False + assert "Unknown command" in response["error"] + + +if __name__ == "__main__": + test_check_installed_returns_bare_installed_boolean() + test_apply_creates_missing_global_config_directory() + test_apply_reads_jsonc_and_preserves_existing_keys() + test_apply_dry_run_reports_change_without_writing() + test_idempotent_apply_reports_unchanged_second_time() + test_apply_supports_project_level_config() + test_apply_supports_legacy_config_and_top_level_dry_run() + test_strip_jsonc_comments_keeps_comment_like_text_in_strings() + test_strip_jsonc_comments_handles_block_comments() + test_unknown_command() + + print("\nAll tests passed.") diff --git a/plugins/openssh/src/plugin.py b/plugins/openssh/src/plugin.py index 5ed77d28..cdab44dc 100644 --- a/plugins/openssh/src/plugin.py +++ b/plugins/openssh/src/plugin.py @@ -1,288 +1,302 @@ -import json -import os -import re -import shutil -import sys - - -def log(msg): - sys.stderr.write(f"[openssh-plugin] {msg}\n") - sys.stderr.flush() - - -def get_config_path(): - home_dir = os.path.expanduser("~") - if not home_dir or home_dir == "~": - home_dir = os.getenv("HOME") or os.getenv("USERPROFILE") - if not home_dir: - raise Exception("Could not determine the user's home directory") - - config_dir = os.path.join(home_dir, ".ssh") - return os.path.join(config_dir, "config") - - -def read_text(file_path: str) -> str: - if not os.path.exists(file_path): - return "" - try: - with open(file_path, "r", encoding="utf-8") as f: - return f.read() - except Exception as e: - raise Exception(f"Could not read {file_path}: {e}") from e - - -def write_text(file_path: str, data: str) -> None: - os.makedirs(os.path.dirname(file_path), mode=0o700, exist_ok=True) - tmp_path = file_path + ".tmp" - with open(tmp_path, "w", encoding="utf-8") as f: - f.write(data) - os.chmod(tmp_path, 0o600) - # Note: On Windows, os.chmod and mode=0o700 are largely no-ops for ACLs. - # We rely on the user's home directory inheriting secure default ACLs. - # For full Windows ACL enforcement, icacls or pywin32 would be required. - os.replace(tmp_path, file_path) - - -def parse_ssh_config(text: str) -> tuple: - blocks = [] - current_block = {"name": None, "lines": []} - blocks.append(current_block) - has_trailing_newline = text.endswith("\n") - - for line in text.splitlines(): - stripped = line.strip() - if not stripped: - current_block["lines"].append({"type": "empty", "raw": line}) - continue - if stripped.startswith("#"): - current_block["lines"].append({"type": "comment", "raw": line}) - continue - - match = re.match(r"^([a-zA-Z0-9_-]+)[\s=]+(.*)$", stripped) - if match: - key = match.group(1) - val = match.group(2).strip() - if key.lower() == "host": - current_block = {"name": val, "lines": []} - blocks.append(current_block) - current_block["lines"].append({"type": "kv", "raw": line, "key": key, "val": val}) - else: - current_block["lines"].append({"type": "kv", "raw": line, "key": key, "val": val}) - else: - current_block["lines"].append({"type": "unknown", "raw": line}) - - return blocks, has_trailing_newline - - -def serialize_ssh_config(blocks: list, has_trailing_newline: bool) -> str: - lines = [] - for b in blocks: - for line in b["lines"]: - lines.append(line["raw"]) - res = "\n".join(lines) - if has_trailing_newline and res and not res.endswith("\n"): - res += "\n" - return res - - -def merge_kv(block: dict, key: str, val: str) -> bool: - lower_key = key.lower() - - # Check if key exists - for line in block["lines"]: - if line["type"] == "kv" and line["key"].lower() == lower_key: - if str(line["val"]) != str(val): - # preserve indentation - indent_match = re.match(r"^(\s+)", line["raw"]) - indent = indent_match.group(1) if indent_match else "" - if not indent and block["name"] is not None: - indent = " " - - str_val = str(val) - line["val"] = str_val - line["raw"] = f"{indent}{key} {str_val}" - return True - return False - - # Key not found, append it - indent = " " if block["name"] is not None else "" - - # insert before trailing empty lines if any - insert_idx = len(block["lines"]) - while insert_idx > 0 and block["lines"][insert_idx - 1]["type"] == "empty": - insert_idx -= 1 - - block["lines"].insert( - insert_idx, - { - "type": "kv", - "raw": f"{indent}{key} {val}", - "key": key, - "val": str(val), - }, - ) - return True - - -def merge_settings(blocks: list, args: dict) -> bool: - changed = False - - global_args = args.get("global", {}) - hosts_args = args.get("hosts", {}) - - # 1. Merge global - global_block = blocks[0] - for k, v in global_args.items(): - if merge_kv(global_block, k, v): - changed = True - - # 2. Merge hosts - for host_name, host_settings in hosts_args.items(): - # find host blocks (case-insensitive) while preserving original casing in output - normalized_host_name = str(host_name).casefold() - matching_blocks = [ - b for b in blocks if b["name"] is not None and str(b["name"]).casefold() == normalized_host_name - ] - - if not matching_blocks: - # create new host block - new_block = {"name": host_name, "lines": []} - - # ensure previous block ends with empty line for spacing - if blocks and blocks[-1]["lines"] and blocks[-1]["lines"][-1]["type"] != "empty": - blocks[-1]["lines"].append({"type": "empty", "raw": ""}) - - new_block["lines"].append( - { - "type": "kv", - "raw": f"Host {host_name}", - "key": "Host", - "val": host_name, - } - ) - blocks.append(new_block) - matching_blocks = [new_block] - changed = True - - for k, v in host_settings.items(): - if k.lower() == "host": - continue - - # Update key in all blocks where it exists - key_found = False - for b in matching_blocks: - if any(line["type"] == "kv" and line["key"].lower() == k.lower() for line in b["lines"]): - if merge_kv(b, k, v): - changed = True - key_found = True - - # If key didn't exist in any matching block, append to the first one - if not key_found: - if merge_kv(matching_blocks[0], k, v): - changed = True - - return changed - - -def check_installed(args: dict, request_id: str) -> dict: - installed = shutil.which("ssh.exe") is not None or shutil.which("ssh") is not None - return { - "requestId": request_id, - "success": True, - "changed": False, - "data": {"installed": installed}, - } - - -def apply_config(args: dict, context: dict, request_id: str) -> dict: - dry_run = context.get("dryRun", False) - - try: - config_path = get_config_path() - current_text = read_text(config_path) - - blocks, has_trailing_newline = parse_ssh_config(current_text) - changed = merge_settings(blocks, args) - - if not changed: - return { - "requestId": request_id, - "success": True, - "changed": False, - } - - new_text = serialize_ssh_config(blocks, has_trailing_newline) - - if dry_run: - log(f"Would update {config_path} with new settings") - return { - "requestId": request_id, - "success": True, - "changed": True, - } - - write_text(config_path, new_text) - log(f"Updated SSH config: {config_path}") - - return { - "requestId": request_id, - "success": True, - "changed": True, - } - - except Exception as e: - log(f"Failed to apply config: {e}") - return { - "requestId": request_id, - "success": False, - "changed": False, - "error": str(e), - } - - -def main(): - input_data = sys.stdin.read() - if not input_data: - return - - try: - request = json.loads(input_data) - except Exception as e: - log(f"Failed to parse request: {e}") - response = { - "requestId": "unknown", - "success": False, - "changed": False, - "error": f"Failed to parse request: {str(e)}", - } - sys.stdout.write(json.dumps(response) + "\n") - sys.stdout.flush() - return - - request_id = request.get("requestId", "unknown") - command = request.get("command") - args = request.get("args", {}) - context = request.get("context", {}) - - response = { - "requestId": request_id, - "success": False, - "changed": False, - } - - try: - if command == "check_installed": - response = check_installed(args, request_id) - elif command == "apply": - response = apply_config(args, context, request_id) - else: - response["error"] = f"Unknown command: {command}" - except Exception as fatal_err: - response["error"] = f"Internal Script Error: {str(fatal_err)}" - - sys.stdout.write(json.dumps(response) + "\n") - sys.stdout.flush() - - -if __name__ == "__main__": - main() +import json +import os +import re +import shutil +import sys + + +def log(msg): + sys.stderr.write(f"[openssh-plugin] {msg}\n") + sys.stderr.flush() + + +def get_config_path(): + home_dir = os.path.expanduser("~") + if not home_dir or home_dir == "~": + home_dir = os.getenv("HOME") or os.getenv("USERPROFILE") + if not home_dir: + raise Exception("Could not determine the user's home directory") + + config_dir = os.path.join(home_dir, ".ssh") + return os.path.join(config_dir, "config") + + +def read_text(file_path: str) -> str: + if not os.path.exists(file_path): + return "" + try: + with open(file_path, "r", encoding="utf-8") as f: + return f.read() + except Exception as e: + raise Exception(f"Could not read {file_path}: {e}") from e + + +def write_text(file_path: str, data: str) -> None: + os.makedirs(os.path.dirname(file_path), mode=0o700, exist_ok=True) + tmp_path = file_path + ".tmp" + with open(tmp_path, "w", encoding="utf-8") as f: + f.write(data) + os.chmod(tmp_path, 0o600) + # Note: On Windows, os.chmod and mode=0o700 are largely no-ops for ACLs. + # We rely on the user's home directory inheriting secure default ACLs. + # For full Windows ACL enforcement, icacls or pywin32 would be required. + os.replace(tmp_path, file_path) + + +def parse_ssh_config(text: str) -> tuple: + blocks = [] + current_block = {"name": None, "lines": []} + blocks.append(current_block) + has_trailing_newline = text.endswith("\n") + + for line in text.splitlines(): + stripped = line.strip() + if not stripped: + current_block["lines"].append({"type": "empty", "raw": line}) + continue + if stripped.startswith("#"): + current_block["lines"].append({"type": "comment", "raw": line}) + continue + + match = re.match(r"^([a-zA-Z0-9_-]+)[\s=]+(.*)$", stripped) + if match: + key = match.group(1) + val = match.group(2).strip() + if key.lower() == "host": + current_block = {"name": val, "lines": []} + blocks.append(current_block) + current_block["lines"].append( + {"type": "kv", "raw": line, "key": key, "val": val} + ) + else: + current_block["lines"].append( + {"type": "kv", "raw": line, "key": key, "val": val} + ) + else: + current_block["lines"].append({"type": "unknown", "raw": line}) + + return blocks, has_trailing_newline + + +def serialize_ssh_config(blocks: list, has_trailing_newline: bool) -> str: + lines = [] + for b in blocks: + for line in b["lines"]: + lines.append(line["raw"]) + res = "\n".join(lines) + if has_trailing_newline and res and not res.endswith("\n"): + res += "\n" + return res + + +def merge_kv(block: dict, key: str, val: str) -> bool: + lower_key = key.lower() + + # Check if key exists + for line in block["lines"]: + if line["type"] == "kv" and line["key"].lower() == lower_key: + if str(line["val"]) != str(val): + # preserve indentation + indent_match = re.match(r"^(\s+)", line["raw"]) + indent = indent_match.group(1) if indent_match else "" + if not indent and block["name"] is not None: + indent = " " + + str_val = str(val) + line["val"] = str_val + line["raw"] = f"{indent}{key} {str_val}" + return True + return False + + # Key not found, append it + indent = " " if block["name"] is not None else "" + + # insert before trailing empty lines if any + insert_idx = len(block["lines"]) + while insert_idx > 0 and block["lines"][insert_idx - 1]["type"] == "empty": + insert_idx -= 1 + + block["lines"].insert( + insert_idx, + { + "type": "kv", + "raw": f"{indent}{key} {val}", + "key": key, + "val": str(val), + }, + ) + return True + + +def merge_settings(blocks: list, args: dict) -> bool: + changed = False + + global_args = args.get("global", {}) + hosts_args = args.get("hosts", {}) + + # 1. Merge global + global_block = blocks[0] + for k, v in global_args.items(): + if merge_kv(global_block, k, v): + changed = True + + # 2. Merge hosts + for host_name, host_settings in hosts_args.items(): + # find host blocks (case-insensitive) while preserving original casing in output + normalized_host_name = str(host_name).casefold() + matching_blocks = [ + b + for b in blocks + if b["name"] is not None + and str(b["name"]).casefold() == normalized_host_name + ] + + if not matching_blocks: + # create new host block + new_block = {"name": host_name, "lines": []} + + # ensure previous block ends with empty line for spacing + if ( + blocks + and blocks[-1]["lines"] + and blocks[-1]["lines"][-1]["type"] != "empty" + ): + blocks[-1]["lines"].append({"type": "empty", "raw": ""}) + + new_block["lines"].append( + { + "type": "kv", + "raw": f"Host {host_name}", + "key": "Host", + "val": host_name, + } + ) + blocks.append(new_block) + matching_blocks = [new_block] + changed = True + + for k, v in host_settings.items(): + if k.lower() == "host": + continue + + # Update key in all blocks where it exists + key_found = False + for b in matching_blocks: + if any( + line["type"] == "kv" and line["key"].lower() == k.lower() + for line in b["lines"] + ): + if merge_kv(b, k, v): + changed = True + key_found = True + + # If key didn't exist in any matching block, append to the first one + if not key_found: + if merge_kv(matching_blocks[0], k, v): + changed = True + + return changed + + +def check_installed(args: dict, request_id: str) -> dict: + installed = shutil.which("ssh.exe") is not None or shutil.which("ssh") is not None + return { + "requestId": request_id, + "success": True, + "changed": False, + "data": {"installed": installed}, + } + + +def apply_config(args: dict, context: dict, request_id: str) -> dict: + dry_run = context.get("dryRun", False) + + try: + config_path = get_config_path() + current_text = read_text(config_path) + + blocks, has_trailing_newline = parse_ssh_config(current_text) + changed = merge_settings(blocks, args) + + if not changed: + return { + "requestId": request_id, + "success": True, + "changed": False, + } + + new_text = serialize_ssh_config(blocks, has_trailing_newline) + + if dry_run: + log(f"Would update {config_path} with new settings") + return { + "requestId": request_id, + "success": True, + "changed": True, + } + + write_text(config_path, new_text) + log(f"Updated SSH config: {config_path}") + + return { + "requestId": request_id, + "success": True, + "changed": True, + } + + except Exception as e: + log(f"Failed to apply config: {e}") + return { + "requestId": request_id, + "success": False, + "changed": False, + "error": str(e), + } + + +def main(): + input_data = sys.stdin.read() + if not input_data: + return + + try: + request = json.loads(input_data) + except Exception as e: + log(f"Failed to parse request: {e}") + response = { + "requestId": "unknown", + "success": False, + "changed": False, + "error": f"Failed to parse request: {str(e)}", + } + sys.stdout.write(json.dumps(response) + "\n") + sys.stdout.flush() + return + + request_id = request.get("requestId", "unknown") + command = request.get("command") + args = request.get("args", {}) + context = request.get("context", {}) + + response = { + "requestId": request_id, + "success": False, + "changed": False, + } + + try: + if command == "check_installed": + response = check_installed(args, request_id) + elif command == "apply": + response = apply_config(args, context, request_id) + else: + response["error"] = f"Unknown command: {command}" + except Exception as fatal_err: + response["error"] = f"Internal Script Error: {str(fatal_err)}" + + sys.stdout.write(json.dumps(response) + "\n") + sys.stdout.flush() + + +if __name__ == "__main__": + main() diff --git a/plugins/pip/src/plugin.py b/plugins/pip/src/plugin.py index c401551f..08f446b2 100644 --- a/plugins/pip/src/plugin.py +++ b/plugins/pip/src/plugin.py @@ -1,165 +1,170 @@ -import configparser -import json -import os -import shutil -import sys -import time -from pathlib import Path - - -def log(msg): - sys.stderr.write(f"[pip-plugin] {msg}\n") - sys.stderr.flush() - - -def get_pip_ini_path(): - appdata = os.getenv("APPDATA") - if appdata: - return os.path.join(appdata, "pip", "pip.ini") - return str(Path.home() / ".config" / "pip" / "pip.conf") - - -def check_installed(args, request_id): - installed = shutil.which("pip.exe") is not None or shutil.which("pip") is not None - return { - "requestId": request_id, - "success": True, - "changed": False, - "data": installed, - } - - -def apply_config(args, context, request_id): - dry_run = context.get("dryRun", False) - - try: - pip_ini_path = get_pip_ini_path() - - config = configparser.ConfigParser() - # Preserve case of keys - config.optionxform = str - - if os.path.exists(pip_ini_path): - try: - config.read(pip_ini_path, encoding="utf-8") - except configparser.Error as e: - log(f"Warning: Failed to parse existing config ({e}). Backing up and starting fresh.") - backup_path = f"{pip_ini_path}.{int(time.time())}.bak" - shutil.copy2(pip_ini_path, backup_path) - # Start fresh with empty config - - if not config.has_section("global"): - config.add_section("global") - - changed = False - settings = args.get("settings", {}) - for key, value in settings.items(): - if value is None: - if config.has_option("global", key): - config.remove_option("global", key) - changed = True - continue - - if isinstance(value, bool): - str_value = "true" if value else "false" - else: - str_value = str(value) - - if not config.has_option("global", key) or config.get("global", key) != str_value: - config.set("global", key, str_value) - changed = True - - if not changed: - return { - "requestId": request_id, - "success": True, - "changed": False, - } - - if dry_run: - log(f"Would update {pip_ini_path} with: {json.dumps(settings)}") - return { - "requestId": request_id, - "success": True, - "changed": True, - } - - os.makedirs(os.path.dirname(pip_ini_path), mode=0o700, exist_ok=True) - temp_path = pip_ini_path + ".tmp" - try: - with open(temp_path, "w", encoding="utf-8") as f: - config.write(f) - os.replace(temp_path, pip_ini_path) - except Exception: - if os.path.exists(temp_path): - os.remove(temp_path) - raise - - log(f"Updated pip config: {pip_ini_path}") - return { - "requestId": request_id, - "success": True, - "changed": True, - } - - except Exception as e: - log(f"Failed to apply config: {e}") - return { - "requestId": request_id, - "success": False, - "changed": False, - "error": str(e), - } - - -def main(): - input_data = sys.stdin.read() - if not input_data: - return - - try: - request = json.loads(input_data) - except Exception as e: - log(f"Failed to parse request: {e}") - sys.stdout.write( - json.dumps( - { - "requestId": "unknown", - "success": False, - "changed": False, - "error": f"Failed to parse request: {e}", - } - ) - + "\n" - ) - sys.stdout.flush() - return - - request_id = request.get("requestId", "unknown") - command = request.get("command") - args = request.get("args", {}) - context = request.get("context", {}) - - response = { - "requestId": request_id, - "success": False, - "changed": False, - } - - try: - if command == "check_installed": - response = check_installed(args, request_id) - elif command == "apply": - response = apply_config(args, context, request_id) - else: - response["error"] = f"Unknown command: {command}" - - except Exception as e: - response["error"] = f"Internal Script Error: {str(e)}" - - sys.stdout.write(json.dumps(response) + "\n") - sys.stdout.flush() - - -if __name__ == "__main__": - main() +import configparser +import json +import os +import shutil +import sys +import time +from pathlib import Path + + +def log(msg): + sys.stderr.write(f"[pip-plugin] {msg}\n") + sys.stderr.flush() + + +def get_pip_ini_path(): + appdata = os.getenv("APPDATA") + if appdata: + return os.path.join(appdata, "pip", "pip.ini") + return str(Path.home() / ".config" / "pip" / "pip.conf") + + +def check_installed(args, request_id): + installed = shutil.which("pip.exe") is not None or shutil.which("pip") is not None + return { + "requestId": request_id, + "success": True, + "changed": False, + "data": installed, + } + + +def apply_config(args, context, request_id): + dry_run = context.get("dryRun", False) + + try: + pip_ini_path = get_pip_ini_path() + + config = configparser.ConfigParser() + # Preserve case of keys + config.optionxform = str + + if os.path.exists(pip_ini_path): + try: + config.read(pip_ini_path, encoding="utf-8") + except configparser.Error as e: + log( + f"Warning: Failed to parse existing config ({e}). Backing up and starting fresh." + ) + backup_path = f"{pip_ini_path}.{int(time.time())}.bak" + shutil.copy2(pip_ini_path, backup_path) + # Start fresh with empty config + + if not config.has_section("global"): + config.add_section("global") + + changed = False + settings = args.get("settings", {}) + for key, value in settings.items(): + if value is None: + if config.has_option("global", key): + config.remove_option("global", key) + changed = True + continue + + if isinstance(value, bool): + str_value = "true" if value else "false" + else: + str_value = str(value) + + if ( + not config.has_option("global", key) + or config.get("global", key) != str_value + ): + config.set("global", key, str_value) + changed = True + + if not changed: + return { + "requestId": request_id, + "success": True, + "changed": False, + } + + if dry_run: + log(f"Would update {pip_ini_path} with: {json.dumps(settings)}") + return { + "requestId": request_id, + "success": True, + "changed": True, + } + + os.makedirs(os.path.dirname(pip_ini_path), mode=0o700, exist_ok=True) + temp_path = pip_ini_path + ".tmp" + try: + with open(temp_path, "w", encoding="utf-8") as f: + config.write(f) + os.replace(temp_path, pip_ini_path) + except Exception: + if os.path.exists(temp_path): + os.remove(temp_path) + raise + + log(f"Updated pip config: {pip_ini_path}") + return { + "requestId": request_id, + "success": True, + "changed": True, + } + + except Exception as e: + log(f"Failed to apply config: {e}") + return { + "requestId": request_id, + "success": False, + "changed": False, + "error": str(e), + } + + +def main(): + input_data = sys.stdin.read() + if not input_data: + return + + try: + request = json.loads(input_data) + except Exception as e: + log(f"Failed to parse request: {e}") + sys.stdout.write( + json.dumps( + { + "requestId": "unknown", + "success": False, + "changed": False, + "error": f"Failed to parse request: {e}", + } + ) + + "\n" + ) + sys.stdout.flush() + return + + request_id = request.get("requestId", "unknown") + command = request.get("command") + args = request.get("args", {}) + context = request.get("context", {}) + + response = { + "requestId": request_id, + "success": False, + "changed": False, + } + + try: + if command == "check_installed": + response = check_installed(args, request_id) + elif command == "apply": + response = apply_config(args, context, request_id) + else: + response["error"] = f"Unknown command: {command}" + + except Exception as e: + response["error"] = f"Internal Script Error: {str(e)}" + + sys.stdout.write(json.dumps(response) + "\n") + sys.stdout.flush() + + +if __name__ == "__main__": + main() diff --git a/plugins/pip/test/test_pip.py b/plugins/pip/test/test_pip.py index b64a5c38..f09ca378 100644 --- a/plugins/pip/test/test_pip.py +++ b/plugins/pip/test/test_pip.py @@ -1,128 +1,132 @@ -import configparser -import importlib.util -import json -import os -import shutil - -# Import the plugin script -import tempfile -import unittest -from io import StringIO -from pathlib import Path -from unittest.mock import patch - -plugin_path = Path(__file__).parent.parent / "src" / "plugin.py" -spec = importlib.util.spec_from_file_location("plugin", plugin_path) -plugin = importlib.util.module_from_spec(spec) -spec.loader.exec_module(plugin) - - -class TestPipPlugin(unittest.TestCase): - def setUp(self): - self.temp_dir = tempfile.mkdtemp() - self.mock_appdata = self.temp_dir - self.pip_ini_path = os.path.join(self.temp_dir, "pip", "pip.ini") - - def tearDown(self): - shutil.rmtree(self.temp_dir) - - @patch.object(plugin.shutil, "which") - def test_check_installed_true(self, mock_which): - mock_which.return_value = "/usr/bin/pip" - response = plugin.check_installed({}, "req-1") - self.assertTrue(response["success"]) - self.assertTrue(response["data"]) - self.assertEqual(response["requestId"], "req-1") - - @patch.object(plugin.shutil, "which") - def test_check_installed_false(self, mock_which): - mock_which.return_value = None - response = plugin.check_installed({}, "req-2") - self.assertTrue(response["success"]) - self.assertFalse(response["data"]) - - @patch.object(plugin, "get_pip_ini_path") - def test_apply_config_creates_new(self, mock_get_path): - mock_get_path.return_value = self.pip_ini_path - - args = {"settings": {"index-url": "https://pypi.org/simple", "timeout": 60}} - response = plugin.apply_config(args, {}, "req-3") - - self.assertTrue(response["success"]) - self.assertTrue(response["changed"]) - - # Verify file contents - config = configparser.ConfigParser() - config.read(self.pip_ini_path) - self.assertTrue(config.has_section("global")) - self.assertEqual(config.get("global", "index-url"), "https://pypi.org/simple") - self.assertEqual(config.get("global", "timeout"), "60") - - @patch.object(plugin, "get_pip_ini_path") - def test_apply_config_idempotency(self, mock_get_path): - mock_get_path.return_value = self.pip_ini_path - - # First run - args = {"settings": {"timeout": 120}} - plugin.apply_config(args, {}, "req-4a") - - # Second run with exact same args - response = plugin.apply_config(args, {}, "req-4b") - - self.assertTrue(response["success"]) - self.assertFalse(response["changed"]) # Should be false on second run - - @patch.object(plugin, "get_pip_ini_path") - def test_apply_config_dry_run(self, mock_get_path): - mock_get_path.return_value = self.pip_ini_path - - args = {"settings": {"timeout": 30}} - context = {"dryRun": True} - response = plugin.apply_config(args, context, "req-5") - - self.assertTrue(response["success"]) - self.assertTrue(response["changed"]) - self.assertFalse(os.path.exists(self.pip_ini_path)) # Should not actually write - - @patch("sys.stdin", new_callable=StringIO) - @patch("sys.stdout", new_callable=StringIO) - def test_invalid_json(self, mock_stdout, mock_stdin): - mock_stdin.write("INVALID { JSON") - mock_stdin.seek(0) - - plugin.main() - - output = mock_stdout.getvalue() - response = json.loads(output) - - self.assertFalse(response["success"]) - self.assertIn("Failed to parse request", response["error"]) - self.assertEqual(response["requestId"], "unknown") - - @patch.object(plugin, "get_pip_ini_path") - def test_apply_config_corrupted_recovery(self, mock_get_path): - mock_get_path.return_value = self.pip_ini_path - - # Create corrupted config - os.makedirs(os.path.dirname(self.pip_ini_path), exist_ok=True) - with open(self.pip_ini_path, "w", encoding="utf-8") as f: - f.write("[global\ninvalid format") - - args = {"settings": {"timeout": "100"}} - response = plugin.apply_config(args, {}, "req-corr") - - self.assertTrue(response["success"]) - self.assertTrue(response["changed"]) - - # Verify it backed up the file - backups = [f for f in os.listdir(os.path.dirname(self.pip_ini_path)) if f.endswith(".bak")] - self.assertTrue(len(backups) > 0) - - # Verify new config - config = configparser.ConfigParser() - config.read(self.pip_ini_path) - self.assertEqual(config.get("global", "timeout"), "100") - - -if __name__ == "__main__": - unittest.main() +import configparser +import importlib.util +import json +import os +import shutil + +# Import the plugin script +import tempfile +import unittest +from io import StringIO +from pathlib import Path +from unittest.mock import patch + +plugin_path = Path(__file__).parent.parent / "src" / "plugin.py" +spec = importlib.util.spec_from_file_location("plugin", plugin_path) +plugin = importlib.util.module_from_spec(spec) +spec.loader.exec_module(plugin) + + +class TestPipPlugin(unittest.TestCase): + def setUp(self): + self.temp_dir = tempfile.mkdtemp() + self.mock_appdata = self.temp_dir + self.pip_ini_path = os.path.join(self.temp_dir, "pip", "pip.ini") + + def tearDown(self): + shutil.rmtree(self.temp_dir) + + @patch.object(plugin.shutil, "which") + def test_check_installed_true(self, mock_which): + mock_which.return_value = "/usr/bin/pip" + response = plugin.check_installed({}, "req-1") + self.assertTrue(response["success"]) + self.assertTrue(response["data"]) + self.assertEqual(response["requestId"], "req-1") + + @patch.object(plugin.shutil, "which") + def test_check_installed_false(self, mock_which): + mock_which.return_value = None + response = plugin.check_installed({}, "req-2") + self.assertTrue(response["success"]) + self.assertFalse(response["data"]) + + @patch.object(plugin, "get_pip_ini_path") + def test_apply_config_creates_new(self, mock_get_path): + mock_get_path.return_value = self.pip_ini_path + + args = {"settings": {"index-url": "https://pypi.org/simple", "timeout": 60}} + response = plugin.apply_config(args, {}, "req-3") + + self.assertTrue(response["success"]) + self.assertTrue(response["changed"]) + + # Verify file contents + config = configparser.ConfigParser() + config.read(self.pip_ini_path) + self.assertTrue(config.has_section("global")) + self.assertEqual(config.get("global", "index-url"), "https://pypi.org/simple") + self.assertEqual(config.get("global", "timeout"), "60") + + @patch.object(plugin, "get_pip_ini_path") + def test_apply_config_idempotency(self, mock_get_path): + mock_get_path.return_value = self.pip_ini_path + + # First run + args = {"settings": {"timeout": 120}} + plugin.apply_config(args, {}, "req-4a") + + # Second run with exact same args + response = plugin.apply_config(args, {}, "req-4b") + + self.assertTrue(response["success"]) + self.assertFalse(response["changed"]) # Should be false on second run + + @patch.object(plugin, "get_pip_ini_path") + def test_apply_config_dry_run(self, mock_get_path): + mock_get_path.return_value = self.pip_ini_path + + args = {"settings": {"timeout": 30}} + context = {"dryRun": True} + response = plugin.apply_config(args, context, "req-5") + + self.assertTrue(response["success"]) + self.assertTrue(response["changed"]) + self.assertFalse(os.path.exists(self.pip_ini_path)) # Should not actually write + + @patch("sys.stdin", new_callable=StringIO) + @patch("sys.stdout", new_callable=StringIO) + def test_invalid_json(self, mock_stdout, mock_stdin): + mock_stdin.write("INVALID { JSON") + mock_stdin.seek(0) + + plugin.main() + + output = mock_stdout.getvalue() + response = json.loads(output) + + self.assertFalse(response["success"]) + self.assertIn("Failed to parse request", response["error"]) + self.assertEqual(response["requestId"], "unknown") + + @patch.object(plugin, "get_pip_ini_path") + def test_apply_config_corrupted_recovery(self, mock_get_path): + mock_get_path.return_value = self.pip_ini_path + + # Create corrupted config + os.makedirs(os.path.dirname(self.pip_ini_path), exist_ok=True) + with open(self.pip_ini_path, "w", encoding="utf-8") as f: + f.write("[global\ninvalid format") + + args = {"settings": {"timeout": "100"}} + response = plugin.apply_config(args, {}, "req-corr") + + self.assertTrue(response["success"]) + self.assertTrue(response["changed"]) + + # Verify it backed up the file + backups = [ + f + for f in os.listdir(os.path.dirname(self.pip_ini_path)) + if f.endswith(".bak") + ] + self.assertTrue(len(backups) > 0) + + # Verify new config + config = configparser.ConfigParser() + config.read(self.pip_ini_path) + self.assertEqual(config.get("global", "timeout"), "100") + + +if __name__ == "__main__": + unittest.main() diff --git a/plugins/pnpm/src/plugin.py b/plugins/pnpm/src/plugin.py index 5c20d7e8..4f55786c 100644 --- a/plugins/pnpm/src/plugin.py +++ b/plugins/pnpm/src/plugin.py @@ -131,7 +131,9 @@ def write_npmrc(file_path: str, config: dict) -> None: def check_installed(args: dict, request_id: str) -> dict: installed = ( - shutil.which("pnpm.cmd") is not None or shutil.which("pnpm.exe") is not None or shutil.which("pnpm") is not None + shutil.which("pnpm.cmd") is not None + or shutil.which("pnpm.exe") is not None + or shutil.which("pnpm") is not None ) return { diff --git a/plugins/pnpm/test/test_pnpm.py b/plugins/pnpm/test/test_pnpm.py index 2c1010e7..0918305a 100644 --- a/plugins/pnpm/test/test_pnpm.py +++ b/plugins/pnpm/test/test_pnpm.py @@ -1,181 +1,189 @@ -import json -import os -import subprocess -import sys -import tempfile - -PLUGIN = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "src", "plugin.py")) - - -def run_plugin(payload: dict) -> dict: - result = subprocess.run( - [sys.executable, PLUGIN], - input=json.dumps(payload), - capture_output=True, - text=True, - ) - - return json.loads(result.stdout.strip()) - - -def test_check_installed_response_format(): - res = run_plugin({"requestId": "1", "command": "check_installed", "args": {}, "context": {}}) - - assert res["requestId"] == "1" - assert res["success"] - assert res["changed"] is False - assert "data" in res - assert isinstance(res["data"], bool) - - -def test_apply_config_dry_run(): - with tempfile.TemporaryDirectory() as tmp: - os.environ["USERPROFILE"] = tmp - - res = run_plugin( - { - "requestId": "2", - "command": "apply", - "args": {"settings": {"storeDir": "C:/pnpm/store", "autoInstallPeers": True}}, - "context": {"dryRun": True}, - } - ) - - npmrc_path = os.path.join(tmp, ".npmrc") - - assert res["requestId"] == "2" - assert res["success"] - assert res["changed"] - assert "data" in res - assert not os.path.exists(npmrc_path) - - -def test_apply_config_writes_supported_settings(): - with tempfile.TemporaryDirectory() as tmp: - os.environ["USERPROFILE"] = tmp - - res = run_plugin( - { - "requestId": "3", - "command": "apply", - "args": { - "settings": { - "storeDir": "C:/pnpm/store", - "globalDir": "C:/pnpm/global", - "globalBinDir": "C:/pnpm/bin", - "nodeVersion": "22.0.0", - "packageManager": "pnpm@9.0.0", - "autoInstallPeers": True, - "strictPeerDependencies": False, - "shamefullyHoist": True, - } - }, - "context": {"dryRun": False}, - } - ) - - npmrc_path = os.path.join(tmp, ".npmrc") - - assert res["requestId"] == "3" - assert res["success"] - assert res["changed"] - assert os.path.exists(npmrc_path) - - with open(npmrc_path, "r", encoding="utf-8") as f: - content = f.read() - - assert "store-dir=C:/pnpm/store" in content - assert "global-dir=C:/pnpm/global" in content - assert "global-bin-dir=C:/pnpm/bin" in content - assert "node-version=22.0.0" in content - assert "package-manager=pnpm@9.0.0" in content - assert "auto-install-peers=true" in content - assert "strict-peer-dependencies=false" in content - assert "shamefully-hoist=true" in content - - -def test_preserves_unknown_existing_keys(): - with tempfile.TemporaryDirectory() as tmp: - os.environ["USERPROFILE"] = tmp - npmrc_path = os.path.join(tmp, ".npmrc") - - with open(npmrc_path, "w", encoding="utf-8") as f: - f.write("unknown-setting=value\n") - - res = run_plugin( - { - "requestId": "4", - "command": "apply", - "args": {"settings": {"storeDir": "C:/pnpm/store"}}, - "context": {"dryRun": False}, - } - ) - - assert res["success"] - - with open(npmrc_path, "r", encoding="utf-8") as f: - content = f.read() - - assert "unknown-setting=value" in content - assert "store-dir=C:/pnpm/store" in content - - -def test_idempotent_apply(): - with tempfile.TemporaryDirectory() as tmp: - os.environ["USERPROFILE"] = tmp - - payload = { - "requestId": "5", - "command": "apply", - "args": {"settings": {"storeDir": "C:/pnpm/store"}}, - "context": {"dryRun": False}, - } - - first = run_plugin(payload) - second = run_plugin(payload) - - assert first["success"] - assert first["changed"] - assert second["success"] - assert second["changed"] is False - - -def test_invalid_settings_returns_json_error(): - res = run_plugin( - { - "requestId": "6", - "command": "apply", - "args": {"settings": None}, - "context": {}, - } - ) - - assert res["requestId"] == "6" - assert not res["success"] - assert res["changed"] is False - assert "error" in res - assert "data" in res - - -def test_empty_stdin_returns_json_error(): - result = subprocess.run([sys.executable, PLUGIN], input="", capture_output=True, text=True) - - res = json.loads(result.stdout.strip()) - - assert res["requestId"] == "unknown" - assert not res["success"] - assert res["changed"] is False - assert "error" in res - assert "data" in res - - -if __name__ == "__main__": - test_check_installed_response_format() - test_apply_config_dry_run() - test_apply_config_writes_supported_settings() - test_preserves_unknown_existing_keys() - test_idempotent_apply() - test_invalid_settings_returns_json_error() - test_empty_stdin_returns_json_error() - - print("\nAll tests passed.") +import json +import os +import subprocess +import sys +import tempfile + +PLUGIN = os.path.abspath( + os.path.join(os.path.dirname(__file__), "..", "src", "plugin.py") +) + + +def run_plugin(payload: dict) -> dict: + result = subprocess.run( + [sys.executable, PLUGIN], + input=json.dumps(payload), + capture_output=True, + text=True, + ) + + return json.loads(result.stdout.strip()) + + +def test_check_installed_response_format(): + res = run_plugin( + {"requestId": "1", "command": "check_installed", "args": {}, "context": {}} + ) + + assert res["requestId"] == "1" + assert res["success"] + assert res["changed"] is False + assert "data" in res + assert isinstance(res["data"], bool) + + +def test_apply_config_dry_run(): + with tempfile.TemporaryDirectory() as tmp: + os.environ["USERPROFILE"] = tmp + + res = run_plugin( + { + "requestId": "2", + "command": "apply", + "args": { + "settings": {"storeDir": "C:/pnpm/store", "autoInstallPeers": True} + }, + "context": {"dryRun": True}, + } + ) + + npmrc_path = os.path.join(tmp, ".npmrc") + + assert res["requestId"] == "2" + assert res["success"] + assert res["changed"] + assert "data" in res + assert not os.path.exists(npmrc_path) + + +def test_apply_config_writes_supported_settings(): + with tempfile.TemporaryDirectory() as tmp: + os.environ["USERPROFILE"] = tmp + + res = run_plugin( + { + "requestId": "3", + "command": "apply", + "args": { + "settings": { + "storeDir": "C:/pnpm/store", + "globalDir": "C:/pnpm/global", + "globalBinDir": "C:/pnpm/bin", + "nodeVersion": "22.0.0", + "packageManager": "pnpm@9.0.0", + "autoInstallPeers": True, + "strictPeerDependencies": False, + "shamefullyHoist": True, + } + }, + "context": {"dryRun": False}, + } + ) + + npmrc_path = os.path.join(tmp, ".npmrc") + + assert res["requestId"] == "3" + assert res["success"] + assert res["changed"] + assert os.path.exists(npmrc_path) + + with open(npmrc_path, "r", encoding="utf-8") as f: + content = f.read() + + assert "store-dir=C:/pnpm/store" in content + assert "global-dir=C:/pnpm/global" in content + assert "global-bin-dir=C:/pnpm/bin" in content + assert "node-version=22.0.0" in content + assert "package-manager=pnpm@9.0.0" in content + assert "auto-install-peers=true" in content + assert "strict-peer-dependencies=false" in content + assert "shamefully-hoist=true" in content + + +def test_preserves_unknown_existing_keys(): + with tempfile.TemporaryDirectory() as tmp: + os.environ["USERPROFILE"] = tmp + npmrc_path = os.path.join(tmp, ".npmrc") + + with open(npmrc_path, "w", encoding="utf-8") as f: + f.write("unknown-setting=value\n") + + res = run_plugin( + { + "requestId": "4", + "command": "apply", + "args": {"settings": {"storeDir": "C:/pnpm/store"}}, + "context": {"dryRun": False}, + } + ) + + assert res["success"] + + with open(npmrc_path, "r", encoding="utf-8") as f: + content = f.read() + + assert "unknown-setting=value" in content + assert "store-dir=C:/pnpm/store" in content + + +def test_idempotent_apply(): + with tempfile.TemporaryDirectory() as tmp: + os.environ["USERPROFILE"] = tmp + + payload = { + "requestId": "5", + "command": "apply", + "args": {"settings": {"storeDir": "C:/pnpm/store"}}, + "context": {"dryRun": False}, + } + + first = run_plugin(payload) + second = run_plugin(payload) + + assert first["success"] + assert first["changed"] + assert second["success"] + assert second["changed"] is False + + +def test_invalid_settings_returns_json_error(): + res = run_plugin( + { + "requestId": "6", + "command": "apply", + "args": {"settings": None}, + "context": {}, + } + ) + + assert res["requestId"] == "6" + assert not res["success"] + assert res["changed"] is False + assert "error" in res + assert "data" in res + + +def test_empty_stdin_returns_json_error(): + result = subprocess.run( + [sys.executable, PLUGIN], input="", capture_output=True, text=True + ) + + res = json.loads(result.stdout.strip()) + + assert res["requestId"] == "unknown" + assert not res["success"] + assert res["changed"] is False + assert "error" in res + assert "data" in res + + +if __name__ == "__main__": + test_check_installed_response_format() + test_apply_config_dry_run() + test_apply_config_writes_supported_settings() + test_preserves_unknown_existing_keys() + test_idempotent_apply() + test_invalid_settings_returns_json_error() + test_empty_stdin_returns_json_error() + + print("\nAll tests passed.") diff --git a/plugins/powershell/src/plugin.py b/plugins/powershell/src/plugin.py index 61f6f72a..c57f5d44 100644 --- a/plugins/powershell/src/plugin.py +++ b/plugins/powershell/src/plugin.py @@ -1,243 +1,253 @@ -import json -import os -import shutil -import sys -from pathlib import Path - -MARKER_START = "# --- WinHome managed start ---" -MARKER_END = "# --- WinHome managed end ---" - - -def log(msg): - sys.stderr.write(f"[powershell-plugin] {msg}\n") - sys.stderr.flush() - - -def get_profile_paths(): - paths = [] - - user_profile = os.getenv("USERPROFILE") - if user_profile: - ps7_dir = os.path.join(user_profile, "Documents", "PowerShell") - ps5_dir = os.path.join(user_profile, "Documents", "WindowsPowerShell") - - if os.path.exists(ps7_dir): - paths.append(os.path.join(ps7_dir, "Microsoft.PowerShell_profile.ps1")) - if os.path.exists(ps5_dir): - paths.append(os.path.join(ps5_dir, "Microsoft.PowerShell_profile.ps1")) - - if paths: - return paths - - # Fallback via Path.home() - if os.name == "nt": - fallback_dir = os.path.join(str(Path.home()), "Documents", "PowerShell") - os.makedirs(fallback_dir, exist_ok=True) - paths.append(os.path.join(fallback_dir, "Microsoft.PowerShell_profile.ps1")) - else: - fallback_dir = os.path.join(str(Path.home()), ".config", "powershell") - os.makedirs(fallback_dir, exist_ok=True) - paths.append(os.path.join(fallback_dir, "profile.ps1")) - - return paths - - -def read_profile(file_path: str): - if not os.path.exists(file_path): - return "", "" - try: - with open(file_path, "r", encoding="utf-8") as f: - content = f.read() - - # Split by markers - if MARKER_START in content and MARKER_END in content: - parts = content.split(MARKER_START) - before = parts[0] - after = parts[1].split(MARKER_END)[1] if len(parts[1].split(MARKER_END)) > 1 else "" - return before, after - return content, "" - except Exception as e: - log(f"Warning: could not read {file_path}: {e}") - return "", "" - - -def generate_script(settings: dict) -> str: - lines = [MARKER_START] - - aliases = settings.get("aliases", {}) - if aliases: - for k, v in aliases.items(): - k_esc = k.replace("'", "''") - v_esc = v.replace("'", "''") - if " " in v: - lines.append(f"function {k} {{ {v} @args }}") - else: - lines.append(f"Set-Alias -Name '{k_esc}' -Value '{v_esc}' -Force") - - modules = settings.get("modules", {}) - if modules: - for k, v in modules.items(): - lines.append(f"Import-Module -Name '{k}' -ErrorAction SilentlyContinue") - if "init" in v: - cmd = v["init"].get("cmd", "") - hook = v["init"].get("hook", "") - if cmd and hook: - lines.append(f"Invoke-Expression (& {k} init powershell --cmd {cmd} --hook {hook} | Out-String)") - else: - lines.append(f"Invoke-Expression (& {k} init powershell | Out-String)") - - prompt = settings.get("prompt", {}) - if prompt: - p_type = prompt.get("type") - if p_type == "oh-my-posh": - theme = prompt.get("theme", "") - if theme: - lines.append(f"oh-my-posh init powershell --config '{theme}' | Invoke-Expression") - else: - lines.append("oh-my-posh init powershell | Invoke-Expression") - - psreadline = settings.get("psreadline", {}) - if psreadline: - lines.append("Import-Module -Name PSReadLine -ErrorAction SilentlyContinue") - for k, v in psreadline.items(): - k_camel = "".join(word.capitalize() for word in k.split("_")) - v_esc = str(v).replace("'", "''") - lines.append(f"Set-PSReadLineOption -{k_camel} '{v_esc}'") - - functions = settings.get("functions", {}) - if functions: - for k, v in functions.items(): - lines.append(f"function {k} {{\n {v}\n}}") - - lines.append(MARKER_END) - return "\n".join(lines) - - -def write_profile(file_path: str, content: str) -> None: - os.makedirs(os.path.dirname(file_path), exist_ok=True) - tmp = file_path + ".tmp" - with open(tmp, "w", encoding="utf-8") as f: - f.write(content) - os.replace(tmp, file_path) - - -def check_installed(args: dict, request_id: str) -> dict: - installed = ( - shutil.which("pwsh.exe") is not None - or shutil.which("powershell.exe") is not None - or shutil.which("pwsh") is not None - ) - return { - "requestId": request_id, - "success": True, - "changed": False, - "data": {"installed": installed}, - } - - -def apply_config(args: dict, context: dict, request_id: str) -> dict: - dry_run = context.get("dryRun", False) - settings = args.get("settings", {}) - - # Handle the specific apply input format from issue desc if necessary - if "aliases" in args and "settings" not in args: - settings = args - - try: - paths = get_profile_paths() - new_script = generate_script(settings) - - changed_any = False - - for p in paths: - before, after = read_profile(p) - new_content = before + new_script + after - - # Simple check to see if we'd actually change anything - current_content = "" - if os.path.exists(p): - with open(p, "r", encoding="utf-8") as f: - current_content = f.read() - - if current_content != new_content: - changed_any = True - if not dry_run: - write_profile(p, new_content) - log(f"Updated powershell profile: {p}") - else: - log(f"Would update {p} with new script block") - - if not changed_any: - return { - "requestId": request_id, - "success": True, - "changed": False, - } - - return { - "requestId": request_id, - "success": True, - "changed": changed_any, - } - - except Exception as e: - log(f"Failed to apply config: {e}") - return { - "requestId": request_id, - "success": False, - "changed": False, - "error": str(e), - } - - -def main(): - input_data = sys.stdin.read() - if not input_data: - return - - request_id = "unknown" - try: - request = json.loads(input_data) - request_id = request.get("requestId", "unknown") - except Exception as e: - log(f"Failed to parse request: {e}") - sys.stdout.write( - json.dumps( - { - "requestId": request_id, - "success": False, - "changed": False, - "error": f"Failed to parse request: {e}", - } - ) - + "\n" - ) - sys.stdout.flush() - return - - command = request.get("command") - args = request.get("args", {}) - context = request.get("context", {}) - - response = { - "requestId": request_id, - "success": False, - "changed": False, - } - - try: - if command == "check_installed": - response = check_installed(args, request_id) - elif command == "apply": - response = apply_config(args, context, request_id) - else: - response["error"] = f"Unknown command: {command}" - except Exception as fatal_err: - response["error"] = f"Internal Script Error: {str(fatal_err)}" - - sys.stdout.write(json.dumps(response) + "\n") - sys.stdout.flush() - - -if __name__ == "__main__": - main() +import json +import os +import shutil +import sys +from pathlib import Path + +MARKER_START = "# --- WinHome managed start ---" +MARKER_END = "# --- WinHome managed end ---" + + +def log(msg): + sys.stderr.write(f"[powershell-plugin] {msg}\n") + sys.stderr.flush() + + +def get_profile_paths(): + paths = [] + + user_profile = os.getenv("USERPROFILE") + if user_profile: + ps7_dir = os.path.join(user_profile, "Documents", "PowerShell") + ps5_dir = os.path.join(user_profile, "Documents", "WindowsPowerShell") + + if os.path.exists(ps7_dir): + paths.append(os.path.join(ps7_dir, "Microsoft.PowerShell_profile.ps1")) + if os.path.exists(ps5_dir): + paths.append(os.path.join(ps5_dir, "Microsoft.PowerShell_profile.ps1")) + + if paths: + return paths + + # Fallback via Path.home() + if os.name == "nt": + fallback_dir = os.path.join(str(Path.home()), "Documents", "PowerShell") + os.makedirs(fallback_dir, exist_ok=True) + paths.append(os.path.join(fallback_dir, "Microsoft.PowerShell_profile.ps1")) + else: + fallback_dir = os.path.join(str(Path.home()), ".config", "powershell") + os.makedirs(fallback_dir, exist_ok=True) + paths.append(os.path.join(fallback_dir, "profile.ps1")) + + return paths + + +def read_profile(file_path: str): + if not os.path.exists(file_path): + return "", "" + try: + with open(file_path, "r", encoding="utf-8") as f: + content = f.read() + + # Split by markers + if MARKER_START in content and MARKER_END in content: + parts = content.split(MARKER_START) + before = parts[0] + after = ( + parts[1].split(MARKER_END)[1] + if len(parts[1].split(MARKER_END)) > 1 + else "" + ) + return before, after + return content, "" + except Exception as e: + log(f"Warning: could not read {file_path}: {e}") + return "", "" + + +def generate_script(settings: dict) -> str: + lines = [MARKER_START] + + aliases = settings.get("aliases", {}) + if aliases: + for k, v in aliases.items(): + k_esc = k.replace("'", "''") + v_esc = v.replace("'", "''") + if " " in v: + lines.append(f"function {k} {{ {v} @args }}") + else: + lines.append(f"Set-Alias -Name '{k_esc}' -Value '{v_esc}' -Force") + + modules = settings.get("modules", {}) + if modules: + for k, v in modules.items(): + lines.append(f"Import-Module -Name '{k}' -ErrorAction SilentlyContinue") + if "init" in v: + cmd = v["init"].get("cmd", "") + hook = v["init"].get("hook", "") + if cmd and hook: + lines.append( + f"Invoke-Expression (& {k} init powershell --cmd {cmd} --hook {hook} | Out-String)" + ) + else: + lines.append( + f"Invoke-Expression (& {k} init powershell | Out-String)" + ) + + prompt = settings.get("prompt", {}) + if prompt: + p_type = prompt.get("type") + if p_type == "oh-my-posh": + theme = prompt.get("theme", "") + if theme: + lines.append( + f"oh-my-posh init powershell --config '{theme}' | Invoke-Expression" + ) + else: + lines.append("oh-my-posh init powershell | Invoke-Expression") + + psreadline = settings.get("psreadline", {}) + if psreadline: + lines.append("Import-Module -Name PSReadLine -ErrorAction SilentlyContinue") + for k, v in psreadline.items(): + k_camel = "".join(word.capitalize() for word in k.split("_")) + v_esc = str(v).replace("'", "''") + lines.append(f"Set-PSReadLineOption -{k_camel} '{v_esc}'") + + functions = settings.get("functions", {}) + if functions: + for k, v in functions.items(): + lines.append(f"function {k} {{\n {v}\n}}") + + lines.append(MARKER_END) + return "\n".join(lines) + + +def write_profile(file_path: str, content: str) -> None: + os.makedirs(os.path.dirname(file_path), exist_ok=True) + tmp = file_path + ".tmp" + with open(tmp, "w", encoding="utf-8") as f: + f.write(content) + os.replace(tmp, file_path) + + +def check_installed(args: dict, request_id: str) -> dict: + installed = ( + shutil.which("pwsh.exe") is not None + or shutil.which("powershell.exe") is not None + or shutil.which("pwsh") is not None + ) + return { + "requestId": request_id, + "success": True, + "changed": False, + "data": {"installed": installed}, + } + + +def apply_config(args: dict, context: dict, request_id: str) -> dict: + dry_run = context.get("dryRun", False) + settings = args.get("settings", {}) + + # Handle the specific apply input format from issue desc if necessary + if "aliases" in args and "settings" not in args: + settings = args + + try: + paths = get_profile_paths() + new_script = generate_script(settings) + + changed_any = False + + for p in paths: + before, after = read_profile(p) + new_content = before + new_script + after + + # Simple check to see if we'd actually change anything + current_content = "" + if os.path.exists(p): + with open(p, "r", encoding="utf-8") as f: + current_content = f.read() + + if current_content != new_content: + changed_any = True + if not dry_run: + write_profile(p, new_content) + log(f"Updated powershell profile: {p}") + else: + log(f"Would update {p} with new script block") + + if not changed_any: + return { + "requestId": request_id, + "success": True, + "changed": False, + } + + return { + "requestId": request_id, + "success": True, + "changed": changed_any, + } + + except Exception as e: + log(f"Failed to apply config: {e}") + return { + "requestId": request_id, + "success": False, + "changed": False, + "error": str(e), + } + + +def main(): + input_data = sys.stdin.read() + if not input_data: + return + + request_id = "unknown" + try: + request = json.loads(input_data) + request_id = request.get("requestId", "unknown") + except Exception as e: + log(f"Failed to parse request: {e}") + sys.stdout.write( + json.dumps( + { + "requestId": request_id, + "success": False, + "changed": False, + "error": f"Failed to parse request: {e}", + } + ) + + "\n" + ) + sys.stdout.flush() + return + + command = request.get("command") + args = request.get("args", {}) + context = request.get("context", {}) + + response = { + "requestId": request_id, + "success": False, + "changed": False, + } + + try: + if command == "check_installed": + response = check_installed(args, request_id) + elif command == "apply": + response = apply_config(args, context, request_id) + else: + response["error"] = f"Unknown command: {command}" + except Exception as fatal_err: + response["error"] = f"Internal Script Error: {str(fatal_err)}" + + sys.stdout.write(json.dumps(response) + "\n") + sys.stdout.flush() + + +if __name__ == "__main__": + main() diff --git a/plugins/powertoys/src/main.py b/plugins/powertoys/src/main.py index bbd7d957..07929491 100644 --- a/plugins/powertoys/src/main.py +++ b/plugins/powertoys/src/main.py @@ -159,7 +159,9 @@ def apply_config(args, context, request_id): if first_error is None: first_error = error else: - success, general_changed, error = apply_general_config(general_config, current_general) + success, general_changed, error = apply_general_config( + general_config, current_general + ) if not success: log(error) overall_success = False diff --git a/plugins/powertoys/test/test_powertoys.py b/plugins/powertoys/test/test_powertoys.py index 856cb2c7..61bee669 100644 --- a/plugins/powertoys/test/test_powertoys.py +++ b/plugins/powertoys/test/test_powertoys.py @@ -5,7 +5,9 @@ import tempfile # Resolve the main plugin path relative to the test file location -PLUGIN = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "src", "main.py")) +PLUGIN = os.path.abspath( + os.path.join(os.path.dirname(__file__), "..", "src", "main.py") +) def run_plugin(payload: dict, env: dict) -> dict: diff --git a/plugins/rainmeter/src/plugin.py b/plugins/rainmeter/src/plugin.py index f93799ea..e25ef6cc 100644 --- a/plugins/rainmeter/src/plugin.py +++ b/plugins/rainmeter/src/plugin.py @@ -78,7 +78,10 @@ def apply_config(args: dict, context: dict, request_id: str) -> dict: for key, value in keys.items(): str_val = str(value) - if not parser.has_option(section, key) or parser.get(section, key) != str_val: + if ( + not parser.has_option(section, key) + or parser.get(section, key) != str_val + ): parser.set(section, key, str_val) changed = True @@ -110,7 +113,9 @@ def apply_config(args: dict, context: dict, request_id: str) -> dict: def main(): input_data = sys.stdin.read() if not input_data: - sys.stdout.write(json.dumps({"requestId": "unknown", "error": "Empty input"}) + "\n") + sys.stdout.write( + json.dumps({"requestId": "unknown", "error": "Empty input"}) + "\n" + ) sys.stdout.flush() return diff --git a/plugins/rainmeter/test/test_rainmeter.py b/plugins/rainmeter/test/test_rainmeter.py index c9d69ea0..e980333a 100644 --- a/plugins/rainmeter/test/test_rainmeter.py +++ b/plugins/rainmeter/test/test_rainmeter.py @@ -19,7 +19,9 @@ def setUp(self): self.temp_dir = tempfile.mkdtemp() self.appdata = os.path.join(self.temp_dir, "AppData") os.makedirs(self.appdata) - self.old_appdata = (os.environ.get("APPDATA") or os.path.join(os.path.expanduser("~"), "AppData", "Roaming")) + self.old_appdata = os.environ.get("APPDATA") or os.path.join( + os.path.expanduser("~"), "AppData", "Roaming" + ) os.environ["APPDATA"] = self.appdata self.config_dir = os.path.join(self.appdata, "Rainmeter") self.config_file = os.path.join(self.config_dir, "Rainmeter.ini") @@ -74,11 +76,16 @@ def test_apply_config_creates_new_file(self): def test_apply_config_merges_with_existing(self): os.makedirs(self.config_dir) with open(self.config_file, "w") as f: - f.write("[Rainmeter]\nConfigEditor=old.exe\nSkinPath=C:\\Skins\n\n[ExistingSkin]\nActive=1\n") + f.write( + "[Rainmeter]\nConfigEditor=old.exe\nSkinPath=C:\\Skins\n\n[ExistingSkin]\nActive=1\n" + ) args = { "settings": { - "Rainmeter": {"ConfigEditor": "notepad.exe", "DesktopWorkArea": "0,0,1920,1080"}, + "Rainmeter": { + "ConfigEditor": "notepad.exe", + "DesktopWorkArea": "0,0,1920,1080", + }, "NewSkin": {"Draggable": 1}, } } @@ -104,7 +111,10 @@ def test_apply_config_no_changes(self): self.assertFalse(res["changed"]) def test_apply_config_dry_run(self): - args = {"settings": {"Rainmeter": {"ConfigEditor": "notepad.exe"}}, "dryRun": True} + args = { + "settings": {"Rainmeter": {"ConfigEditor": "notepad.exe"}}, + "dryRun": True, + } res = plugin.apply_config(args, {}, "req-7") self.assertTrue(res["changed"]) self.assertFalse(os.path.exists(self.config_file)) @@ -127,7 +137,9 @@ def test_apply_config_corrupted_ini_creates_backup(self): backups = [f for f in os.listdir(self.config_dir) if f.endswith(".bak")] self.assertEqual(len(backups), 1) with open(os.path.join(self.config_dir, backups[0]), "r") as f: - self.assertEqual(f.read(), "invalid ini content without section header\nkey=value\n") + self.assertEqual( + f.read(), "invalid ini content without section header\nkey=value\n" + ) def test_apply_config_invalid_settings_type(self): args = {"settings": ["not", "a", "dict"]} @@ -178,7 +190,9 @@ def test_main_empty_input(self, mock_stdin, mock_stdout): @patch("sys.stdout") @patch("sys.stdin") def test_main_unknown_command(self, mock_stdin, mock_stdout): - mock_stdin.read.return_value = '{"command": "invalid_cmd", "requestId": "req-10"}' + mock_stdin.read.return_value = ( + '{"command": "invalid_cmd", "requestId": "req-10"}' + ) output = [] mock_stdout.write.side_effect = output.append diff --git a/plugins/rclone/src/plugin.py b/plugins/rclone/src/plugin.py index 983d95de..de1ea66f 100644 --- a/plugins/rclone/src/plugin.py +++ b/plugins/rclone/src/plugin.py @@ -1,272 +1,278 @@ -import json -import os -import re -import shutil -import sys - - -def log(msg): - sys.stderr.write(f"[rclone-plugin] {msg}\n") - sys.stderr.flush() - - -def get_config_path(): - userprofile = os.getenv("USERPROFILE") - if not userprofile: - raise Exception("USERPROFILE environment variable not found") - return os.path.join(userprofile, ".config", "rclone", "rclone.conf") - - -def read_text(file_path: str) -> str: - if not os.path.exists(file_path): - return "" - try: - with open(file_path, "r", encoding="utf-8") as f: - return f.read() - except OSError as e: - raise OSError(f"Could not read {file_path}: {e}") from e - - -def write_text(file_path: str, data: str) -> None: - os.makedirs(os.path.dirname(file_path), mode=0o700, exist_ok=True) - tmp_path = file_path + ".tmp" - with open(tmp_path, "w", encoding="utf-8") as f: - f.write(data) - os.replace(tmp_path, file_path) - - -def parse_ini(text: str) -> tuple: - blocks = [] - current_block = {"name": None, "lines": []} - blocks.append(current_block) - has_trailing_newline = text.endswith("\n") - is_crlf = "\r\n" in text - - for line in text.splitlines(): - stripped = line.strip() - if not stripped: - current_block["lines"].append({"type": "empty", "raw": line}) - continue - if stripped.startswith("#") or stripped.startswith(";"): - current_block["lines"].append({"type": "comment", "raw": line}) - continue - - # Section header - match_section = re.match(r"^\[(.*)\]$", stripped) - if match_section: - section_name = match_section.group(1).strip() - current_block = {"name": section_name, "lines": []} - blocks.append(current_block) - current_block["lines"].append({"type": "section", "raw": line}) - continue - - # Key = Value - match_kv = re.match(r"^([^=]+)=(.*)$", stripped) - if match_kv: - key = match_kv.group(1).strip() - val = match_kv.group(2).strip() - current_block["lines"].append({"type": "kv", "raw": line, "key": key, "val": val}) - else: - current_block["lines"].append({"type": "unknown", "raw": line}) - - return blocks, has_trailing_newline, is_crlf - - -def serialize_ini(blocks: list, has_trailing_newline: bool, is_crlf: bool) -> str: - lines = [] - for b in blocks: - for line in b["lines"]: - lines.append(line["raw"]) - newline = "\r\n" if is_crlf else "\n" - res = newline.join(lines) - if has_trailing_newline and res and not res.endswith(newline): - res += newline - return res - - -def merge_kv(block: dict, key: str, val: str) -> bool: - lower_key = key.lower() - - for line in block["lines"]: - if line["type"] == "kv" and line["key"].lower() == lower_key: - if str(line["val"]) != str(val): - indent_match = re.match(r"^(\s*)", line["raw"]) - indent = indent_match.group(1) if indent_match else "" - - eq_match = re.search(r"(\s*=\s*)", line["raw"]) - eq_str = eq_match.group(1) if eq_match else " = " - - str_val = str(val) - line["val"] = str_val - original_key = line.get("key", key) - line["raw"] = f"{indent}{original_key}{eq_str}{str_val}" - return True - return False - - # Key not found, append it - insert_idx = len(block["lines"]) - while insert_idx > 0 and block["lines"][insert_idx - 1]["type"] == "empty": - insert_idx -= 1 - - block["lines"].insert( - insert_idx, - {"type": "kv", "raw": f"{key} = {val}", "key": key, "val": str(val)}, - ) - return True - - -def merge_settings(blocks: list, args: dict) -> bool: - changed = False - - settings = args.get("settings", {}) - remotes = args.get("remotes", {}) - - if settings: - global_block = next((b for b in blocks if b["name"] is None), None) - if not global_block: - global_block = {"name": None, "lines": []} - blocks.insert(0, global_block) - for k, v in settings.items(): - if merge_kv(global_block, k, v): - changed = True - - for remote_name, remote_settings in remotes.items(): - block = next((b for b in blocks if b["name"] == remote_name), None) - - if not block: - block = {"name": remote_name, "lines": []} - - if blocks and blocks[-1]["lines"] and blocks[-1]["lines"][-1]["type"] != "empty": - blocks[-1]["lines"].append({"type": "empty", "raw": ""}) - - block["lines"].append({"type": "section", "raw": f"[{remote_name}]"}) - blocks.append(block) - changed = True - - for k, v in remote_settings.items(): - if merge_kv(block, k, v): - changed = True - - return changed - - -def check_installed(args: dict, request_id: str) -> dict: - installed = False - if shutil.which("rclone.exe") or shutil.which("rclone"): - installed = True - else: - program_files = os.getenv("PROGRAMFILES", "C:\\Program Files") - if os.path.exists(os.path.join(program_files, "rclone", "rclone.exe")): - installed = True - - return { - "requestId": request_id, - "success": True, - "changed": False, - "data": installed, - } - - -def apply_config(args: dict, context: dict, request_id: str) -> dict: - dry_run = context.get("dryRun", False) - - try: - config_path = get_config_path() - current_text = read_text(config_path) - - blocks, has_trailing_newline, is_crlf = parse_ini(current_text) - changed = merge_settings(blocks, args) - - if not changed: - return { - "requestId": request_id, - "success": True, - "changed": False, - } - - new_text = serialize_ini(blocks, has_trailing_newline, is_crlf) - - if dry_run: - log(f"Would update {config_path} with new settings") - return { - "requestId": request_id, - "success": True, - "changed": True, - } - - write_text(config_path, new_text) - log(f"Updated Rclone config: {config_path}") - - return { - "requestId": request_id, - "success": True, - "changed": True, - } - - except Exception as e: - log(f"Failed to apply config: {e}") - return { - "requestId": request_id, - "success": False, - "changed": False, - "error": str(e), - } - - -def main(): - input_data = sys.stdin.read() - if not input_data: - return - - try: - request = json.loads(input_data) - except json.JSONDecodeError as e: - log(f"Failed to parse request: {e}") - response = { - "requestId": "unknown", - "success": False, - "changed": False, - "error": f"Failed to parse JSON request: {str(e)}", - } - sys.stdout.write(json.dumps(response) + "\n") - sys.stdout.flush() - return - except Exception as e: - log(f"Failed to parse request: {e}") - response = { - "requestId": "unknown", - "success": False, - "changed": False, - "error": f"Failed to parse request: {str(e)}", - } - sys.stdout.write(json.dumps(response) + "\n") - sys.stdout.flush() - return - - request_id = request.get("requestId", "unknown") - command = request.get("command") - args = request.get("args", {}) - context = request.get("context", {}) - - response = { - "requestId": request_id, - "success": False, - "changed": False, - } - - try: - if command == "check_installed": - response = check_installed(args, request_id) - elif command == "apply": - response = apply_config(args, context, request_id) - else: - response["error"] = f"Unknown command: {command}" - except Exception as fatal_err: - response["error"] = f"Internal Script Error: {str(fatal_err)}" - - sys.stdout.write(json.dumps(response) + "\n") - sys.stdout.flush() - - -if __name__ == "__main__": - main() +import json +import os +import re +import shutil +import sys + + +def log(msg): + sys.stderr.write(f"[rclone-plugin] {msg}\n") + sys.stderr.flush() + + +def get_config_path(): + userprofile = os.getenv("USERPROFILE") + if not userprofile: + raise Exception("USERPROFILE environment variable not found") + return os.path.join(userprofile, ".config", "rclone", "rclone.conf") + + +def read_text(file_path: str) -> str: + if not os.path.exists(file_path): + return "" + try: + with open(file_path, "r", encoding="utf-8") as f: + return f.read() + except OSError as e: + raise OSError(f"Could not read {file_path}: {e}") from e + + +def write_text(file_path: str, data: str) -> None: + os.makedirs(os.path.dirname(file_path), mode=0o700, exist_ok=True) + tmp_path = file_path + ".tmp" + with open(tmp_path, "w", encoding="utf-8") as f: + f.write(data) + os.replace(tmp_path, file_path) + + +def parse_ini(text: str) -> tuple: + blocks = [] + current_block = {"name": None, "lines": []} + blocks.append(current_block) + has_trailing_newline = text.endswith("\n") + is_crlf = "\r\n" in text + + for line in text.splitlines(): + stripped = line.strip() + if not stripped: + current_block["lines"].append({"type": "empty", "raw": line}) + continue + if stripped.startswith("#") or stripped.startswith(";"): + current_block["lines"].append({"type": "comment", "raw": line}) + continue + + # Section header + match_section = re.match(r"^\[(.*)\]$", stripped) + if match_section: + section_name = match_section.group(1).strip() + current_block = {"name": section_name, "lines": []} + blocks.append(current_block) + current_block["lines"].append({"type": "section", "raw": line}) + continue + + # Key = Value + match_kv = re.match(r"^([^=]+)=(.*)$", stripped) + if match_kv: + key = match_kv.group(1).strip() + val = match_kv.group(2).strip() + current_block["lines"].append( + {"type": "kv", "raw": line, "key": key, "val": val} + ) + else: + current_block["lines"].append({"type": "unknown", "raw": line}) + + return blocks, has_trailing_newline, is_crlf + + +def serialize_ini(blocks: list, has_trailing_newline: bool, is_crlf: bool) -> str: + lines = [] + for b in blocks: + for line in b["lines"]: + lines.append(line["raw"]) + newline = "\r\n" if is_crlf else "\n" + res = newline.join(lines) + if has_trailing_newline and res and not res.endswith(newline): + res += newline + return res + + +def merge_kv(block: dict, key: str, val: str) -> bool: + lower_key = key.lower() + + for line in block["lines"]: + if line["type"] == "kv" and line["key"].lower() == lower_key: + if str(line["val"]) != str(val): + indent_match = re.match(r"^(\s*)", line["raw"]) + indent = indent_match.group(1) if indent_match else "" + + eq_match = re.search(r"(\s*=\s*)", line["raw"]) + eq_str = eq_match.group(1) if eq_match else " = " + + str_val = str(val) + line["val"] = str_val + original_key = line.get("key", key) + line["raw"] = f"{indent}{original_key}{eq_str}{str_val}" + return True + return False + + # Key not found, append it + insert_idx = len(block["lines"]) + while insert_idx > 0 and block["lines"][insert_idx - 1]["type"] == "empty": + insert_idx -= 1 + + block["lines"].insert( + insert_idx, + {"type": "kv", "raw": f"{key} = {val}", "key": key, "val": str(val)}, + ) + return True + + +def merge_settings(blocks: list, args: dict) -> bool: + changed = False + + settings = args.get("settings", {}) + remotes = args.get("remotes", {}) + + if settings: + global_block = next((b for b in blocks if b["name"] is None), None) + if not global_block: + global_block = {"name": None, "lines": []} + blocks.insert(0, global_block) + for k, v in settings.items(): + if merge_kv(global_block, k, v): + changed = True + + for remote_name, remote_settings in remotes.items(): + block = next((b for b in blocks if b["name"] == remote_name), None) + + if not block: + block = {"name": remote_name, "lines": []} + + if ( + blocks + and blocks[-1]["lines"] + and blocks[-1]["lines"][-1]["type"] != "empty" + ): + blocks[-1]["lines"].append({"type": "empty", "raw": ""}) + + block["lines"].append({"type": "section", "raw": f"[{remote_name}]"}) + blocks.append(block) + changed = True + + for k, v in remote_settings.items(): + if merge_kv(block, k, v): + changed = True + + return changed + + +def check_installed(args: dict, request_id: str) -> dict: + installed = False + if shutil.which("rclone.exe") or shutil.which("rclone"): + installed = True + else: + program_files = os.getenv("PROGRAMFILES", "C:\\Program Files") + if os.path.exists(os.path.join(program_files, "rclone", "rclone.exe")): + installed = True + + return { + "requestId": request_id, + "success": True, + "changed": False, + "data": installed, + } + + +def apply_config(args: dict, context: dict, request_id: str) -> dict: + dry_run = context.get("dryRun", False) + + try: + config_path = get_config_path() + current_text = read_text(config_path) + + blocks, has_trailing_newline, is_crlf = parse_ini(current_text) + changed = merge_settings(blocks, args) + + if not changed: + return { + "requestId": request_id, + "success": True, + "changed": False, + } + + new_text = serialize_ini(blocks, has_trailing_newline, is_crlf) + + if dry_run: + log(f"Would update {config_path} with new settings") + return { + "requestId": request_id, + "success": True, + "changed": True, + } + + write_text(config_path, new_text) + log(f"Updated Rclone config: {config_path}") + + return { + "requestId": request_id, + "success": True, + "changed": True, + } + + except Exception as e: + log(f"Failed to apply config: {e}") + return { + "requestId": request_id, + "success": False, + "changed": False, + "error": str(e), + } + + +def main(): + input_data = sys.stdin.read() + if not input_data: + return + + try: + request = json.loads(input_data) + except json.JSONDecodeError as e: + log(f"Failed to parse request: {e}") + response = { + "requestId": "unknown", + "success": False, + "changed": False, + "error": f"Failed to parse JSON request: {str(e)}", + } + sys.stdout.write(json.dumps(response) + "\n") + sys.stdout.flush() + return + except Exception as e: + log(f"Failed to parse request: {e}") + response = { + "requestId": "unknown", + "success": False, + "changed": False, + "error": f"Failed to parse request: {str(e)}", + } + sys.stdout.write(json.dumps(response) + "\n") + sys.stdout.flush() + return + + request_id = request.get("requestId", "unknown") + command = request.get("command") + args = request.get("args", {}) + context = request.get("context", {}) + + response = { + "requestId": request_id, + "success": False, + "changed": False, + } + + try: + if command == "check_installed": + response = check_installed(args, request_id) + elif command == "apply": + response = apply_config(args, context, request_id) + else: + response["error"] = f"Unknown command: {command}" + except Exception as fatal_err: + response["error"] = f"Internal Script Error: {str(fatal_err)}" + + sys.stdout.write(json.dumps(response) + "\n") + sys.stdout.flush() + + +if __name__ == "__main__": + main() diff --git a/plugins/rclone/test/test_rclone.py b/plugins/rclone/test/test_rclone.py index e468bfcb..0a658f19 100644 --- a/plugins/rclone/test/test_rclone.py +++ b/plugins/rclone/test/test_rclone.py @@ -1,125 +1,127 @@ -import os -import sys -import unittest -from unittest.mock import mock_open, patch - -_src_path = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "src")) -sys.path.append(_src_path) -import plugin - -sys.path.remove(_src_path) - - -class TestRclonePlugin(unittest.TestCase): - def test_parse_and_serialize_ini(self): - text = "tpslimit = 10\n\n[drive]\ntype = drive\n# comment\nscope = drive.file\n" - blocks, has_newline, is_crlf = plugin.parse_ini(text) - self.assertEqual(len(blocks), 2) - self.assertIsNone(blocks[0]["name"]) - self.assertEqual(blocks[1]["name"], "drive") - - output = plugin.serialize_ini(blocks, has_newline, is_crlf) - self.assertEqual(output, text) - - def test_merge_settings(self): - text = "[drive]\ntype = drive\n" - blocks, has_newline, is_crlf = plugin.parse_ini(text) - - args = { - "remotes": { - "drive": { - "scope": "drive.file", - "type": "drive", # unchanged - }, - "s3": {"type": "s3"}, - }, - "settings": {"tpslimit": 20}, - } - - changed = plugin.merge_settings(blocks, args) - self.assertTrue(changed) - - output = plugin.serialize_ini(blocks, has_newline, is_crlf) - - # Expect settings at the top, existing drive remote updated, and new s3 remote added. - expected = "tpslimit = 20\n[drive]\ntype = drive\nscope = drive.file\n\n[s3]\ntype = s3\n" - self.assertEqual(output, expected) - - def test_merge_no_change(self): - text = "tpslimit = 10\n\n[drive]\ntype = drive\n" - blocks, has_newline, is_crlf = plugin.parse_ini(text) - - args = { - "remotes": {"drive": {"type": "drive"}}, - "settings": {"tpslimit": 10}, - } - - changed = plugin.merge_settings(blocks, args) - self.assertFalse(changed) - - @patch("plugin.shutil.which") - @patch("plugin.os.path.exists") - def test_check_installed_via_path(self, mock_exists, mock_which): - mock_which.return_value = "C:\\path\\rclone.exe" - res = plugin.check_installed({}, "req-1") - self.assertTrue(res["success"]) - self.assertTrue(res["data"]) - - @patch("plugin.shutil.which") - @patch("plugin.os.path.exists") - def test_check_installed_via_program_files(self, mock_exists, mock_which): - mock_which.return_value = None - mock_exists.return_value = True - res = plugin.check_installed({}, "req-2") - self.assertTrue(res["success"]) - self.assertTrue(res["data"]) - - @patch("plugin.get_config_path") - @patch("plugin.read_text") - @patch("plugin.write_text") - def test_apply_config_real_run(self, mock_write, mock_read, mock_get_path): - mock_get_path.return_value = "dummy.conf" - mock_read.return_value = "[drive]\ntype = drive\n" - - args = {"settings": {"tpslimit": 10}} - context = {"dryRun": False} - - res = plugin.apply_config(args, context, "req-3") - self.assertTrue(res["success"]) - self.assertTrue(res["changed"]) - self.assertEqual(res["requestId"], "req-3") - mock_write.assert_called_once_with("dummy.conf", "tpslimit = 10\n[drive]\ntype = drive\n") - - @patch("plugin.get_config_path") - @patch("plugin.read_text") - @patch("plugin.write_text") - def test_apply_config_dry_run(self, mock_write, mock_read, mock_get_path): - mock_get_path.return_value = "dummy.conf" - mock_read.return_value = "[drive]\ntype = drive\n" - - args = {"settings": {"tpslimit": 10}} - context = {"dryRun": True} - - res = plugin.apply_config(args, context, "req-4") - self.assertTrue(res["success"]) - self.assertTrue(res["changed"]) - mock_write.assert_not_called() - - @patch("plugin.os.makedirs") - @patch("plugin.os.replace") - def test_write_text(self, mock_replace, mock_makedirs): - file_path = "dummy/path.conf" - data = "tpslimit = 10\n" - m_open = mock_open() - with patch("plugin.open", m_open): - plugin.write_text(file_path, data) - - mock_makedirs.assert_called_once_with("dummy", mode=0o700, exist_ok=True) - m_open.assert_called_once_with(file_path + ".tmp", "w", encoding="utf-8") - handle = m_open() - handle.write.assert_called_with(data) - mock_replace.assert_called_once_with(file_path + ".tmp", file_path) - - -if __name__ == "__main__": - unittest.main() +import os +import sys +import unittest +from unittest.mock import mock_open, patch + +_src_path = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "src")) +sys.path.append(_src_path) +import plugin + +sys.path.remove(_src_path) + + +class TestRclonePlugin(unittest.TestCase): + def test_parse_and_serialize_ini(self): + text = "tpslimit = 10\n\n[drive]\ntype = drive\n# comment\nscope = drive.file\n" + blocks, has_newline, is_crlf = plugin.parse_ini(text) + self.assertEqual(len(blocks), 2) + self.assertIsNone(blocks[0]["name"]) + self.assertEqual(blocks[1]["name"], "drive") + + output = plugin.serialize_ini(blocks, has_newline, is_crlf) + self.assertEqual(output, text) + + def test_merge_settings(self): + text = "[drive]\ntype = drive\n" + blocks, has_newline, is_crlf = plugin.parse_ini(text) + + args = { + "remotes": { + "drive": { + "scope": "drive.file", + "type": "drive", # unchanged + }, + "s3": {"type": "s3"}, + }, + "settings": {"tpslimit": 20}, + } + + changed = plugin.merge_settings(blocks, args) + self.assertTrue(changed) + + output = plugin.serialize_ini(blocks, has_newline, is_crlf) + + # Expect settings at the top, existing drive remote updated, and new s3 remote added. + expected = "tpslimit = 20\n[drive]\ntype = drive\nscope = drive.file\n\n[s3]\ntype = s3\n" + self.assertEqual(output, expected) + + def test_merge_no_change(self): + text = "tpslimit = 10\n\n[drive]\ntype = drive\n" + blocks, has_newline, is_crlf = plugin.parse_ini(text) + + args = { + "remotes": {"drive": {"type": "drive"}}, + "settings": {"tpslimit": 10}, + } + + changed = plugin.merge_settings(blocks, args) + self.assertFalse(changed) + + @patch("plugin.shutil.which") + @patch("plugin.os.path.exists") + def test_check_installed_via_path(self, mock_exists, mock_which): + mock_which.return_value = "C:\\path\\rclone.exe" + res = plugin.check_installed({}, "req-1") + self.assertTrue(res["success"]) + self.assertTrue(res["data"]) + + @patch("plugin.shutil.which") + @patch("plugin.os.path.exists") + def test_check_installed_via_program_files(self, mock_exists, mock_which): + mock_which.return_value = None + mock_exists.return_value = True + res = plugin.check_installed({}, "req-2") + self.assertTrue(res["success"]) + self.assertTrue(res["data"]) + + @patch("plugin.get_config_path") + @patch("plugin.read_text") + @patch("plugin.write_text") + def test_apply_config_real_run(self, mock_write, mock_read, mock_get_path): + mock_get_path.return_value = "dummy.conf" + mock_read.return_value = "[drive]\ntype = drive\n" + + args = {"settings": {"tpslimit": 10}} + context = {"dryRun": False} + + res = plugin.apply_config(args, context, "req-3") + self.assertTrue(res["success"]) + self.assertTrue(res["changed"]) + self.assertEqual(res["requestId"], "req-3") + mock_write.assert_called_once_with( + "dummy.conf", "tpslimit = 10\n[drive]\ntype = drive\n" + ) + + @patch("plugin.get_config_path") + @patch("plugin.read_text") + @patch("plugin.write_text") + def test_apply_config_dry_run(self, mock_write, mock_read, mock_get_path): + mock_get_path.return_value = "dummy.conf" + mock_read.return_value = "[drive]\ntype = drive\n" + + args = {"settings": {"tpslimit": 10}} + context = {"dryRun": True} + + res = plugin.apply_config(args, context, "req-4") + self.assertTrue(res["success"]) + self.assertTrue(res["changed"]) + mock_write.assert_not_called() + + @patch("plugin.os.makedirs") + @patch("plugin.os.replace") + def test_write_text(self, mock_replace, mock_makedirs): + file_path = "dummy/path.conf" + data = "tpslimit = 10\n" + m_open = mock_open() + with patch("plugin.open", m_open): + plugin.write_text(file_path, data) + + mock_makedirs.assert_called_once_with("dummy", mode=0o700, exist_ok=True) + m_open.assert_called_once_with(file_path + ".tmp", "w", encoding="utf-8") + handle = m_open() + handle.write.assert_called_with(data) + mock_replace.assert_called_once_with(file_path + ".tmp", file_path) + + +if __name__ == "__main__": + unittest.main() diff --git a/plugins/ripgrep/src/plugin.py b/plugins/ripgrep/src/plugin.py index ca1c8391..42ea6e7e 100644 --- a/plugins/ripgrep/src/plugin.py +++ b/plugins/ripgrep/src/plugin.py @@ -1,271 +1,273 @@ -import datetime -import json -import os -import shutil -import sys -import tempfile -import uuid - - -def log(msg): - sys.stderr.write(f"[ripgrep-plugin] {msg}\n") - sys.stderr.flush() - - -def get_config_path(): - env_path = os.getenv("RIPGREP_CONFIG_PATH") - if env_path: - return env_path - - if os.name == "nt": - user_profile = os.getenv("USERPROFILE") or os.path.expanduser("~") - return os.path.join(user_profile, ".ripgreprc") - - return os.path.expanduser("~/.ripgreprc") - - -def _backup_corrupt_config(file_path: str, reason: str): - timestamp = datetime.datetime.now(datetime.timezone.utc).strftime("%Y%m%d%H%M%S") - suffix = uuid.uuid4().hex[:8] - backup_path = f"{file_path}.corrupted.{timestamp}.{suffix}" - log(f"Config read failed ({reason}). Backing up to {backup_path} and starting fresh.") - - try: - shutil.move(file_path, backup_path) - except Exception as backup_e: - log(f"Failed to backup corrupted config: {backup_e}") - - -def parse_ripgreprc(lines: list) -> dict: - config = {} - - for line in lines: - line = line.strip() - - if not line or line.startswith("#"): - continue - - if not line.startswith("--"): - raise ValueError(f"Invalid ripgrep config line: {line}") - - flag = line[2:] - - if "=" in flag: - key, value = flag.split("=", 1) - config[key.strip()] = value.strip() - else: - config[flag.strip()] = True - - return config - - -def read_ripgreprc(file_path: str) -> dict: - if not os.path.exists(file_path): - return {} - - try: - with open(file_path, "r", encoding="utf-8") as f: - return parse_ripgreprc(f.readlines()) - except (UnicodeDecodeError, ValueError, OSError) as e: - _backup_corrupt_config(file_path, f"{type(e).__name__}: {e}") - return {} - - -def _format_value(value): - if isinstance(value, bool): - return None if value else False - - if value is None: - return None - - return str(value) - - -def build_ripgreprc_content(config: dict) -> str: - lines = [] - - for key, value in config.items(): - formatted_value = _format_value(value) - - if formatted_value is False: - continue - - if formatted_value is None: - lines.append(f"--{key}") - else: - lines.append(f"--{key}={formatted_value}") - - return "\n".join(lines) + "\n" - - -def write_ripgreprc(file_path: str, config: dict) -> None: - dir_path = os.path.dirname(file_path) - - if dir_path: - os.makedirs(dir_path, exist_ok=True) - - fd, temp_path = tempfile.mkstemp(prefix="ripgrep-", dir=dir_path or ".") - - try: - with os.fdopen(fd, "w", encoding="utf-8") as f: - f.write(build_ripgreprc_content(config)) - - os.replace(temp_path, file_path) - except Exception: - try: - os.unlink(temp_path) - except OSError: - pass - - raise - - -def merge_settings(target: dict, source: dict) -> bool: - changed = False - - for key, value in source.items(): - normalized_value = _format_value(value) - - if normalized_value is False: - if key in target: - del target[key] - changed = True - continue - - if key not in target or _format_value(target[key]) != normalized_value: - target[key] = value - changed = True - - return changed - - -def check_installed(args: dict, request_id: str) -> dict: - installed = shutil.which("rg.exe") is not None or shutil.which("rg") is not None - - return { - "requestId": request_id, - "success": True, - "changed": False, - "data": installed, - } - - -def apply_config(args: dict, context: dict, request_id: str) -> dict: - dry_run = context.get("dryRun", False) - settings = args.get("settings", {}) - - if not isinstance(settings, dict): - return { - "requestId": request_id, - "success": False, - "changed": False, - "error": "settings must be an object", - "data": None, - } - - try: - config_path = get_config_path() - current_config = read_ripgreprc(config_path) - changed = merge_settings(current_config, settings) - - if not changed: - return { - "requestId": request_id, - "success": True, - "changed": False, - "data": None, - } - - if dry_run: - log(f"Would update {config_path} with new settings") - return { - "requestId": request_id, - "success": True, - "changed": True, - "data": { - "path": config_path, - "settings": settings, - }, - } - - write_ripgreprc(config_path, current_config) - log(f"Updated ripgrep config: {config_path}") - - return { - "requestId": request_id, - "success": True, - "changed": True, - "data": { - "path": config_path, - }, - } - - except Exception as e: - log(f"Failed to apply config: {e}") - return { - "requestId": request_id, - "success": False, - "changed": False, - "error": str(e), - "data": None, - } - - -def main(): - input_data = sys.stdin.read() - - if not input_data: - response = { - "requestId": "unknown", - "success": False, - "changed": False, - "error": "No input provided on stdin", - "data": None, - } - sys.stdout.write(json.dumps(response) + "\n") - sys.stdout.flush() - return - - try: - request = json.loads(input_data) - except Exception as e: - log(f"Failed to parse request: {e}") - response = { - "requestId": "unknown", - "success": False, - "changed": False, - "error": f"Failed to parse JSON request: {str(e)}", - "data": None, - } - sys.stdout.write(json.dumps(response) + "\n") - sys.stdout.flush() - return - - request_id = request.get("requestId", "unknown") - command = request.get("command") - args = request.get("args", {}) - context = request.get("context", {}) - - response = { - "requestId": request_id, - "success": False, - "changed": False, - "data": None, - } - - try: - if command == "check_installed": - response = check_installed(args, request_id) - elif command == "apply": - response = apply_config(args, context, request_id) - else: - response["error"] = f"Unknown command: {command}" - except Exception as fatal_err: - response["error"] = f"Internal Script Error: {str(fatal_err)}" - - sys.stdout.write(json.dumps(response) + "\n") - sys.stdout.flush() - - -if __name__ == "__main__": - main() +import datetime +import json +import os +import shutil +import sys +import tempfile +import uuid + + +def log(msg): + sys.stderr.write(f"[ripgrep-plugin] {msg}\n") + sys.stderr.flush() + + +def get_config_path(): + env_path = os.getenv("RIPGREP_CONFIG_PATH") + if env_path: + return env_path + + if os.name == "nt": + user_profile = os.getenv("USERPROFILE") or os.path.expanduser("~") + return os.path.join(user_profile, ".ripgreprc") + + return os.path.expanduser("~/.ripgreprc") + + +def _backup_corrupt_config(file_path: str, reason: str): + timestamp = datetime.datetime.now(datetime.timezone.utc).strftime("%Y%m%d%H%M%S") + suffix = uuid.uuid4().hex[:8] + backup_path = f"{file_path}.corrupted.{timestamp}.{suffix}" + log( + f"Config read failed ({reason}). Backing up to {backup_path} and starting fresh." + ) + + try: + shutil.move(file_path, backup_path) + except Exception as backup_e: + log(f"Failed to backup corrupted config: {backup_e}") + + +def parse_ripgreprc(lines: list) -> dict: + config = {} + + for line in lines: + line = line.strip() + + if not line or line.startswith("#"): + continue + + if not line.startswith("--"): + raise ValueError(f"Invalid ripgrep config line: {line}") + + flag = line[2:] + + if "=" in flag: + key, value = flag.split("=", 1) + config[key.strip()] = value.strip() + else: + config[flag.strip()] = True + + return config + + +def read_ripgreprc(file_path: str) -> dict: + if not os.path.exists(file_path): + return {} + + try: + with open(file_path, "r", encoding="utf-8") as f: + return parse_ripgreprc(f.readlines()) + except (UnicodeDecodeError, ValueError, OSError) as e: + _backup_corrupt_config(file_path, f"{type(e).__name__}: {e}") + return {} + + +def _format_value(value): + if isinstance(value, bool): + return None if value else False + + if value is None: + return None + + return str(value) + + +def build_ripgreprc_content(config: dict) -> str: + lines = [] + + for key, value in config.items(): + formatted_value = _format_value(value) + + if formatted_value is False: + continue + + if formatted_value is None: + lines.append(f"--{key}") + else: + lines.append(f"--{key}={formatted_value}") + + return "\n".join(lines) + "\n" + + +def write_ripgreprc(file_path: str, config: dict) -> None: + dir_path = os.path.dirname(file_path) + + if dir_path: + os.makedirs(dir_path, exist_ok=True) + + fd, temp_path = tempfile.mkstemp(prefix="ripgrep-", dir=dir_path or ".") + + try: + with os.fdopen(fd, "w", encoding="utf-8") as f: + f.write(build_ripgreprc_content(config)) + + os.replace(temp_path, file_path) + except Exception: + try: + os.unlink(temp_path) + except OSError: + pass + + raise + + +def merge_settings(target: dict, source: dict) -> bool: + changed = False + + for key, value in source.items(): + normalized_value = _format_value(value) + + if normalized_value is False: + if key in target: + del target[key] + changed = True + continue + + if key not in target or _format_value(target[key]) != normalized_value: + target[key] = value + changed = True + + return changed + + +def check_installed(args: dict, request_id: str) -> dict: + installed = shutil.which("rg.exe") is not None or shutil.which("rg") is not None + + return { + "requestId": request_id, + "success": True, + "changed": False, + "data": installed, + } + + +def apply_config(args: dict, context: dict, request_id: str) -> dict: + dry_run = context.get("dryRun", False) + settings = args.get("settings", {}) + + if not isinstance(settings, dict): + return { + "requestId": request_id, + "success": False, + "changed": False, + "error": "settings must be an object", + "data": None, + } + + try: + config_path = get_config_path() + current_config = read_ripgreprc(config_path) + changed = merge_settings(current_config, settings) + + if not changed: + return { + "requestId": request_id, + "success": True, + "changed": False, + "data": None, + } + + if dry_run: + log(f"Would update {config_path} with new settings") + return { + "requestId": request_id, + "success": True, + "changed": True, + "data": { + "path": config_path, + "settings": settings, + }, + } + + write_ripgreprc(config_path, current_config) + log(f"Updated ripgrep config: {config_path}") + + return { + "requestId": request_id, + "success": True, + "changed": True, + "data": { + "path": config_path, + }, + } + + except Exception as e: + log(f"Failed to apply config: {e}") + return { + "requestId": request_id, + "success": False, + "changed": False, + "error": str(e), + "data": None, + } + + +def main(): + input_data = sys.stdin.read() + + if not input_data: + response = { + "requestId": "unknown", + "success": False, + "changed": False, + "error": "No input provided on stdin", + "data": None, + } + sys.stdout.write(json.dumps(response) + "\n") + sys.stdout.flush() + return + + try: + request = json.loads(input_data) + except Exception as e: + log(f"Failed to parse request: {e}") + response = { + "requestId": "unknown", + "success": False, + "changed": False, + "error": f"Failed to parse JSON request: {str(e)}", + "data": None, + } + sys.stdout.write(json.dumps(response) + "\n") + sys.stdout.flush() + return + + request_id = request.get("requestId", "unknown") + command = request.get("command") + args = request.get("args", {}) + context = request.get("context", {}) + + response = { + "requestId": request_id, + "success": False, + "changed": False, + "data": None, + } + + try: + if command == "check_installed": + response = check_installed(args, request_id) + elif command == "apply": + response = apply_config(args, context, request_id) + else: + response["error"] = f"Unknown command: {command}" + except Exception as fatal_err: + response["error"] = f"Internal Script Error: {str(fatal_err)}" + + sys.stdout.write(json.dumps(response) + "\n") + sys.stdout.flush() + + +if __name__ == "__main__": + main() diff --git a/plugins/ripgrep/test/test_plugin.py b/plugins/ripgrep/test/test_plugin.py index 53011386..c91f427e 100644 --- a/plugins/ripgrep/test/test_plugin.py +++ b/plugins/ripgrep/test/test_plugin.py @@ -1,107 +1,122 @@ -import json -import os -import subprocess -import sys -import tempfile - -PLUGIN = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "src", "plugin.py")) - - -def run_plugin(payload: dict) -> dict: - result = subprocess.run([sys.executable, PLUGIN], input=json.dumps(payload), capture_output=True, text=True) - - return json.loads(result.stdout.strip()) - - -def test_check_installed(): - res = run_plugin({"requestId": "1", "command": "check_installed", "args": {}, "context": {}}) - - assert res["requestId"] == "1" - assert res["success"] - assert "data" in res - assert isinstance(res["data"], bool) - - -def test_apply_config_dry_run(): - with tempfile.TemporaryDirectory() as tmp: - config_path = os.path.join(tmp, ".ripgreprc") - os.environ["RIPGREP_CONFIG_PATH"] = config_path - - res = run_plugin( - { - "requestId": "2", - "command": "apply", - "args": {"settings": {"smart-case": True, "hidden": True, "max-columns": 150}}, - "context": {"dryRun": True}, - } - ) - - assert res["requestId"] == "2" - assert res["success"] - assert res["changed"] - assert "data" in res - assert not os.path.exists(config_path) - - -def test_apply_config_write(): - with tempfile.TemporaryDirectory() as tmp: - config_path = os.path.join(tmp, ".ripgreprc") - os.environ["RIPGREP_CONFIG_PATH"] = config_path - - res = run_plugin( - { - "requestId": "3", - "command": "apply", - "args": {"settings": {"smart-case": True, "hidden": True, "max-columns": 150}}, - "context": {"dryRun": False}, - } - ) - - assert res["requestId"] == "3" - assert res["success"] - assert res["changed"] - assert os.path.exists(config_path) - - with open(config_path, "r", encoding="utf-8") as f: - content = f.read() - - assert "--smart-case" in content - assert "--hidden" in content - assert "--max-columns=150" in content - - -def test_preserves_existing_unknown_flags(): - with tempfile.TemporaryDirectory() as tmp: - config_path = os.path.join(tmp, ".ripgreprc") - os.environ["RIPGREP_CONFIG_PATH"] = config_path - - with open(config_path, "w", encoding="utf-8") as f: - f.write("--unknown-flag\n") - - res = run_plugin( - { - "requestId": "4", - "command": "apply", - "args": {"settings": {"smart-case": True}}, - "context": {"dryRun": False}, - } - ) - - assert res["success"] - - with open(config_path, "r", encoding="utf-8") as f: - content = f.read() - - assert "--unknown-flag" in content - assert "--smart-case" in content - - -def test_empty_stdin_returns_json_error(): - result = subprocess.run([sys.executable, PLUGIN], input="", capture_output=True, text=True) - - res = json.loads(result.stdout.strip()) - - assert res["requestId"] == "unknown" - assert not res["success"] - assert "data" in res - assert "error" in res +import json +import os +import subprocess +import sys +import tempfile + +PLUGIN = os.path.abspath( + os.path.join(os.path.dirname(__file__), "..", "src", "plugin.py") +) + + +def run_plugin(payload: dict) -> dict: + result = subprocess.run( + [sys.executable, PLUGIN], + input=json.dumps(payload), + capture_output=True, + text=True, + ) + + return json.loads(result.stdout.strip()) + + +def test_check_installed(): + res = run_plugin( + {"requestId": "1", "command": "check_installed", "args": {}, "context": {}} + ) + + assert res["requestId"] == "1" + assert res["success"] + assert "data" in res + assert isinstance(res["data"], bool) + + +def test_apply_config_dry_run(): + with tempfile.TemporaryDirectory() as tmp: + config_path = os.path.join(tmp, ".ripgreprc") + os.environ["RIPGREP_CONFIG_PATH"] = config_path + + res = run_plugin( + { + "requestId": "2", + "command": "apply", + "args": { + "settings": {"smart-case": True, "hidden": True, "max-columns": 150} + }, + "context": {"dryRun": True}, + } + ) + + assert res["requestId"] == "2" + assert res["success"] + assert res["changed"] + assert "data" in res + assert not os.path.exists(config_path) + + +def test_apply_config_write(): + with tempfile.TemporaryDirectory() as tmp: + config_path = os.path.join(tmp, ".ripgreprc") + os.environ["RIPGREP_CONFIG_PATH"] = config_path + + res = run_plugin( + { + "requestId": "3", + "command": "apply", + "args": { + "settings": {"smart-case": True, "hidden": True, "max-columns": 150} + }, + "context": {"dryRun": False}, + } + ) + + assert res["requestId"] == "3" + assert res["success"] + assert res["changed"] + assert os.path.exists(config_path) + + with open(config_path, "r", encoding="utf-8") as f: + content = f.read() + + assert "--smart-case" in content + assert "--hidden" in content + assert "--max-columns=150" in content + + +def test_preserves_existing_unknown_flags(): + with tempfile.TemporaryDirectory() as tmp: + config_path = os.path.join(tmp, ".ripgreprc") + os.environ["RIPGREP_CONFIG_PATH"] = config_path + + with open(config_path, "w", encoding="utf-8") as f: + f.write("--unknown-flag\n") + + res = run_plugin( + { + "requestId": "4", + "command": "apply", + "args": {"settings": {"smart-case": True}}, + "context": {"dryRun": False}, + } + ) + + assert res["success"] + + with open(config_path, "r", encoding="utf-8") as f: + content = f.read() + + assert "--unknown-flag" in content + assert "--smart-case" in content + + +def test_empty_stdin_returns_json_error(): + result = subprocess.run( + [sys.executable, PLUGIN], input="", capture_output=True, text=True + ) + + res = json.loads(result.stdout.strip()) + + assert res["requestId"] == "unknown" + assert not res["success"] + assert "data" in res + assert "error" in res diff --git a/plugins/rustup/src/plugin.py b/plugins/rustup/src/plugin.py index 498f45ae..f66fb165 100644 --- a/plugins/rustup/src/plugin.py +++ b/plugins/rustup/src/plugin.py @@ -34,13 +34,27 @@ def deep_merge(source, destination): def process_request(): raw_input = sys.stdin.read().strip() if not raw_input: - print(json.dumps({"requestId": "unknown", "error": "Empty stdin configuration request received"})) + print( + json.dumps( + { + "requestId": "unknown", + "error": "Empty stdin configuration request received", + } + ) + ) return try: request_payload = json.loads(raw_input) except json.JSONDecodeError: - print(json.dumps({"requestId": "unknown", "error": "Invalid JSON formatting in configuration payload"})) + print( + json.dumps( + { + "requestId": "unknown", + "error": "Invalid JSON formatting in configuration payload", + } + ) + ) return request_id = request_payload.get("requestId") @@ -105,7 +119,12 @@ def process_request(): os.replace(temp_path, config_path) except Exception as err: print( - json.dumps({"requestId": request_id, "error": f"Failed executing atomic write operations: {str(err)}"}) + json.dumps( + { + "requestId": request_id, + "error": f"Failed executing atomic write operations: {str(err)}", + } + ) ) return diff --git a/plugins/scoop/src/plugin.py b/plugins/scoop/src/plugin.py index 14aa25ce..37f27c0a 100644 --- a/plugins/scoop/src/plugin.py +++ b/plugins/scoop/src/plugin.py @@ -31,7 +31,9 @@ def _backup_corrupt_config(file_path: str, reason: str): timestamp = datetime.datetime.now(datetime.timezone.utc).strftime("%Y%m%d%H%M%S") suffix = uuid.uuid4().hex[:8] backup_path = f"{file_path}.corrupted.{timestamp}.{suffix}" - log(f"Config read failed ({reason}). Backing up to {backup_path} and starting fresh.") + log( + f"Config read failed ({reason}). Backing up to {backup_path} and starting fresh." + ) try: shutil.move(file_path, backup_path) except Exception as backup_e: diff --git a/plugins/scoop/test/test_scoop.py b/plugins/scoop/test/test_scoop.py index 290e6a26..7816f682 100644 --- a/plugins/scoop/test/test_scoop.py +++ b/plugins/scoop/test/test_scoop.py @@ -1,195 +1,199 @@ -import os -import sys -import unittest -from unittest.mock import patch - -_src_path = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "src")) -try: - sys.path.append(_src_path) - import plugin -finally: - sys.path.remove(_src_path) - - -class TestScoopPlugin(unittest.TestCase): - def test_merge_settings_creates_new(self): - target = {} - source = {"root_path": "D:\\scoop"} - changed = plugin.merge_settings(target, source) - self.assertTrue(changed) - self.assertEqual(target["root_path"], "D:\\scoop") - - def test_merge_settings_updates_existing(self): - target = {"root_path": "C:\\scoop", "aria2-enabled": False} - source = {"root_path": "D:\\scoop", "debug": True} - changed = plugin.merge_settings(target, source) - self.assertTrue(changed) - self.assertEqual(target["root_path"], "D:\\scoop") - self.assertEqual(target["aria2-enabled"], False) - self.assertEqual(target["debug"], True) - - def test_merge_settings_no_change(self): - target = {"root_path": "D:\\scoop"} - source = {"root_path": "D:\\scoop"} - changed = plugin.merge_settings(target, source) - self.assertFalse(changed) - self.assertEqual(target["root_path"], "D:\\scoop") - - @patch("shutil.which") - def test_check_installed_true(self, mock_which): - mock_which.return_value = "C:\\scoop\\shims\\scoop.cmd" - res = plugin.check_installed({}, "req-1") - self.assertTrue(res["success"]) - self.assertTrue(res["data"]) - - @patch("shutil.which") - def test_check_installed_false(self, mock_which): - mock_which.return_value = None - res = plugin.check_installed({}, "req-2") - self.assertTrue(res["success"]) - self.assertFalse(res["data"]) - - @patch("os.getenv") - def test_get_config_path_with_xdg(self, mock_getenv): - def side_effect(key): - if key == "XDG_CONFIG_HOME": - return "C:\\xdg" - if key == "USERPROFILE": - return "C:\\Users\\test" - return None - - mock_getenv.side_effect = side_effect - - path = plugin.get_config_path() - self.assertEqual(path, os.path.join("C:\\xdg", "scoop", "config.json")) - - @patch("os.getenv") - def test_get_config_path_fallback_userprofile(self, mock_getenv): - def side_effect(key): - if key == "XDG_CONFIG_HOME": - return None - if key == "USERPROFILE": - return "C:\\Users\\test" - return None - - mock_getenv.side_effect = side_effect - - path = plugin.get_config_path() - self.assertEqual( - path, - os.path.join("C:\\Users\\test", ".config", "scoop", "config.json"), - ) - - @patch("plugin.read_json") - @patch("plugin.write_json") - @patch("plugin.get_config_path") - def test_apply_config_success(self, mock_path, mock_write, mock_read): - mock_path.return_value = "dummy.json" - mock_read.return_value = {"aria2-enabled": False} - - args = {"settings": {"aria2-enabled": True}} - res = plugin.apply_config(args, {}, "req-3") - - self.assertTrue(res["success"]) - self.assertTrue(res["changed"]) - mock_write.assert_called_once_with("dummy.json", {"aria2-enabled": True}) - - @patch("plugin.read_json") - @patch("plugin.write_json") - @patch("plugin.get_config_path") - def test_apply_config_dry_run(self, mock_path, mock_write, mock_read): - mock_path.return_value = "dummy.json" - mock_read.return_value = {} - - args = {"settings": {"root_path": "D:\\scoop"}} - context = {"dryRun": True} - res = plugin.apply_config(args, context, "req-4") - - self.assertTrue(res["success"]) - self.assertTrue(res["changed"]) - mock_write.assert_not_called() - - @patch("plugin.read_json") - @patch("plugin.write_json") - @patch("plugin.get_config_path") - def test_apply_config_no_changes(self, mock_path, mock_write, mock_read): - mock_path.return_value = "dummy.json" - mock_read.return_value = {"root_path": "D:\\scoop"} - - args = {"settings": {"root_path": "D:\\scoop"}} - res = plugin.apply_config(args, {}, "req-5") - - self.assertTrue(res["success"]) - self.assertFalse(res["changed"]) - mock_write.assert_not_called() - - @patch("os.path.exists") - @patch( - "builtins.open", - new_callable=unittest.mock.mock_open, - read_data="{invalid_json", - ) - @patch("shutil.move") - def test_read_json_corrupted_backup(self, mock_move, mock_file, mock_exists): - mock_exists.return_value = True - - # Test JSONDecodeError path - data = plugin.read_json("dummy.json") - self.assertEqual(data, {}) - mock_move.assert_called_once() - args, _ = mock_move.call_args - self.assertEqual(args[0], "dummy.json") - self.assertIn("dummy.json.corrupted.", args[1]) - - @patch("os.path.exists") - @patch("builtins.open") - @patch("shutil.move") - def test_read_json_oserror_backup(self, mock_move, mock_file, mock_exists): - mock_exists.return_value = True - mock_file.side_effect = OSError("Access Denied") - - # Test OSError path - data = plugin.read_json("dummy.json") - self.assertEqual(data, {}) - mock_move.assert_called_once() - args, _ = mock_move.call_args - self.assertEqual(args[0], "dummy.json") - self.assertIn("dummy.json.corrupted.", args[1]) - - @patch("plugin.tempfile.mkstemp") - @patch("plugin.os.replace") - @patch("plugin.os.makedirs") - @patch("plugin.os.fdopen") - def test_write_json_uses_mkstemp(self, mock_fdopen, mock_makedirs, mock_replace, mock_mkstemp): - mock_mkstemp.return_value = (5, "/tmp/scoop-abc123") - mock_fdopen.return_value.__enter__ = lambda s: s - mock_fdopen.return_value.__exit__ = lambda s, *a: False - mock_fdopen.return_value.write = lambda x: None - - plugin.write_json("/fake/path/config.json", {"debug": True}) - - mock_mkstemp.assert_called_once() - call_kwargs = mock_mkstemp.call_args - self.assertEqual( - call_kwargs.kwargs.get("prefix") or call_kwargs[1].get("prefix"), - "scoop-", - ) - mock_replace.assert_called_once_with("/tmp/scoop-abc123", "/fake/path/config.json") - - def test_write_json_atomic_real(self): - import json as js - import tempfile as tf - - with tf.TemporaryDirectory() as tmpdir: - config_path = os.path.join(tmpdir, "config.json") - plugin.write_json(config_path, {"root_path": "D:\\scoop"}) - with open(config_path, "r") as f: - data = js.load(f) - self.assertEqual(data["root_path"], "D:\\scoop") - # Ensure no leftover temp files - leftover = [f for f in os.listdir(tmpdir) if f.startswith("scoop-")] - self.assertEqual(leftover, []) - - -if __name__ == "__main__": - unittest.main() +import os +import sys +import unittest +from unittest.mock import patch + +_src_path = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "src")) +try: + sys.path.append(_src_path) + import plugin +finally: + sys.path.remove(_src_path) + + +class TestScoopPlugin(unittest.TestCase): + def test_merge_settings_creates_new(self): + target = {} + source = {"root_path": "D:\\scoop"} + changed = plugin.merge_settings(target, source) + self.assertTrue(changed) + self.assertEqual(target["root_path"], "D:\\scoop") + + def test_merge_settings_updates_existing(self): + target = {"root_path": "C:\\scoop", "aria2-enabled": False} + source = {"root_path": "D:\\scoop", "debug": True} + changed = plugin.merge_settings(target, source) + self.assertTrue(changed) + self.assertEqual(target["root_path"], "D:\\scoop") + self.assertEqual(target["aria2-enabled"], False) + self.assertEqual(target["debug"], True) + + def test_merge_settings_no_change(self): + target = {"root_path": "D:\\scoop"} + source = {"root_path": "D:\\scoop"} + changed = plugin.merge_settings(target, source) + self.assertFalse(changed) + self.assertEqual(target["root_path"], "D:\\scoop") + + @patch("shutil.which") + def test_check_installed_true(self, mock_which): + mock_which.return_value = "C:\\scoop\\shims\\scoop.cmd" + res = plugin.check_installed({}, "req-1") + self.assertTrue(res["success"]) + self.assertTrue(res["data"]) + + @patch("shutil.which") + def test_check_installed_false(self, mock_which): + mock_which.return_value = None + res = plugin.check_installed({}, "req-2") + self.assertTrue(res["success"]) + self.assertFalse(res["data"]) + + @patch("os.getenv") + def test_get_config_path_with_xdg(self, mock_getenv): + def side_effect(key): + if key == "XDG_CONFIG_HOME": + return "C:\\xdg" + if key == "USERPROFILE": + return "C:\\Users\\test" + return None + + mock_getenv.side_effect = side_effect + + path = plugin.get_config_path() + self.assertEqual(path, os.path.join("C:\\xdg", "scoop", "config.json")) + + @patch("os.getenv") + def test_get_config_path_fallback_userprofile(self, mock_getenv): + def side_effect(key): + if key == "XDG_CONFIG_HOME": + return None + if key == "USERPROFILE": + return "C:\\Users\\test" + return None + + mock_getenv.side_effect = side_effect + + path = plugin.get_config_path() + self.assertEqual( + path, + os.path.join("C:\\Users\\test", ".config", "scoop", "config.json"), + ) + + @patch("plugin.read_json") + @patch("plugin.write_json") + @patch("plugin.get_config_path") + def test_apply_config_success(self, mock_path, mock_write, mock_read): + mock_path.return_value = "dummy.json" + mock_read.return_value = {"aria2-enabled": False} + + args = {"settings": {"aria2-enabled": True}} + res = plugin.apply_config(args, {}, "req-3") + + self.assertTrue(res["success"]) + self.assertTrue(res["changed"]) + mock_write.assert_called_once_with("dummy.json", {"aria2-enabled": True}) + + @patch("plugin.read_json") + @patch("plugin.write_json") + @patch("plugin.get_config_path") + def test_apply_config_dry_run(self, mock_path, mock_write, mock_read): + mock_path.return_value = "dummy.json" + mock_read.return_value = {} + + args = {"settings": {"root_path": "D:\\scoop"}} + context = {"dryRun": True} + res = plugin.apply_config(args, context, "req-4") + + self.assertTrue(res["success"]) + self.assertTrue(res["changed"]) + mock_write.assert_not_called() + + @patch("plugin.read_json") + @patch("plugin.write_json") + @patch("plugin.get_config_path") + def test_apply_config_no_changes(self, mock_path, mock_write, mock_read): + mock_path.return_value = "dummy.json" + mock_read.return_value = {"root_path": "D:\\scoop"} + + args = {"settings": {"root_path": "D:\\scoop"}} + res = plugin.apply_config(args, {}, "req-5") + + self.assertTrue(res["success"]) + self.assertFalse(res["changed"]) + mock_write.assert_not_called() + + @patch("os.path.exists") + @patch( + "builtins.open", + new_callable=unittest.mock.mock_open, + read_data="{invalid_json", + ) + @patch("shutil.move") + def test_read_json_corrupted_backup(self, mock_move, mock_file, mock_exists): + mock_exists.return_value = True + + # Test JSONDecodeError path + data = plugin.read_json("dummy.json") + self.assertEqual(data, {}) + mock_move.assert_called_once() + args, _ = mock_move.call_args + self.assertEqual(args[0], "dummy.json") + self.assertIn("dummy.json.corrupted.", args[1]) + + @patch("os.path.exists") + @patch("builtins.open") + @patch("shutil.move") + def test_read_json_oserror_backup(self, mock_move, mock_file, mock_exists): + mock_exists.return_value = True + mock_file.side_effect = OSError("Access Denied") + + # Test OSError path + data = plugin.read_json("dummy.json") + self.assertEqual(data, {}) + mock_move.assert_called_once() + args, _ = mock_move.call_args + self.assertEqual(args[0], "dummy.json") + self.assertIn("dummy.json.corrupted.", args[1]) + + @patch("plugin.tempfile.mkstemp") + @patch("plugin.os.replace") + @patch("plugin.os.makedirs") + @patch("plugin.os.fdopen") + def test_write_json_uses_mkstemp( + self, mock_fdopen, mock_makedirs, mock_replace, mock_mkstemp + ): + mock_mkstemp.return_value = (5, "/tmp/scoop-abc123") + mock_fdopen.return_value.__enter__ = lambda s: s + mock_fdopen.return_value.__exit__ = lambda s, *a: False + mock_fdopen.return_value.write = lambda x: None + + plugin.write_json("/fake/path/config.json", {"debug": True}) + + mock_mkstemp.assert_called_once() + call_kwargs = mock_mkstemp.call_args + self.assertEqual( + call_kwargs.kwargs.get("prefix") or call_kwargs[1].get("prefix"), + "scoop-", + ) + mock_replace.assert_called_once_with( + "/tmp/scoop-abc123", "/fake/path/config.json" + ) + + def test_write_json_atomic_real(self): + import json as js + import tempfile as tf + + with tf.TemporaryDirectory() as tmpdir: + config_path = os.path.join(tmpdir, "config.json") + plugin.write_json(config_path, {"root_path": "D:\\scoop"}) + with open(config_path, "r") as f: + data = js.load(f) + self.assertEqual(data["root_path"], "D:\\scoop") + # Ensure no leftover temp files + leftover = [f for f in os.listdir(tmpdir) if f.startswith("scoop-")] + self.assertEqual(leftover, []) + + +if __name__ == "__main__": + unittest.main() diff --git a/plugins/sdkman/src/plugin.py b/plugins/sdkman/src/plugin.py index 8623bc88..00067ab6 100644 --- a/plugins/sdkman/src/plugin.py +++ b/plugins/sdkman/src/plugin.py @@ -1,204 +1,206 @@ -# /// script -# dependencies = [] -# /// - -import json -import os -import sys -import tempfile - - -def log(msg): - sys.stderr.write(f"[sdkman-plugin] {msg}\n") - sys.stderr.flush() - - -def get_sdkman_dir(): - userprofile = os.environ.get("USERPROFILE") - - if userprofile: - return os.path.join(userprofile, ".sdkman") - - return os.path.expanduser("~/.sdkman") - - -def get_config_path(): - return os.path.join( - get_sdkman_dir(), - "etc", - "config", - ) - - -def read_config(file_path: str) -> dict: - config = {} - - if not os.path.exists(file_path): - return config - - with open(file_path, "r", encoding="utf-8") as f: - for line in f: - line = line.strip() - - if not line or "=" not in line: - continue - - key, value = line.split("=", 1) - - config[key.strip()] = value.strip() - - return config - - -def sdkman_value(value): - if value is None: - return None - - if isinstance(value, bool): - return "true" if value else "false" - - return str(value) - - -def write_config(file_path: str, data: dict): - os.makedirs(os.path.dirname(file_path), exist_ok=True) - - fd, temp_path = tempfile.mkstemp(dir=os.path.dirname(file_path)) - - try: - with os.fdopen(fd, "w", encoding="utf-8") as f: - for key, value in data.items(): - f.write(f"{key}={sdkman_value(value)}\n") - - os.replace(temp_path, file_path) - - finally: - if os.path.exists(temp_path): - try: - os.remove(temp_path) - except OSError: - pass - - -def merge_settings(target: dict, source: dict) -> bool: - changed = False - - for key, value in source.items(): - converted = sdkman_value(value) - - if converted is None: - continue - - if key not in target or target[key] != converted: - target[key] = converted - changed = True - - return changed - - -def check_installed(args: dict, request_id: str) -> dict: - installed = os.path.isdir(get_sdkman_dir()) - - return { - "requestId": request_id, - "success": True, - "changed": False, - "data": installed, - } - - -def apply_config(args: dict, context: dict, request_id: str) -> dict: - dry_run = bool(context.get("dryRun", False)) - settings = args.get("settings", {}) - - if not isinstance(settings, dict): - raise ValueError("settings must be an object") - - try: - config_path = get_config_path() - current_config = read_config(config_path) - changed = merge_settings(current_config, settings) - - if not changed: - return { - "requestId": request_id, - "success": True, - "changed": False, - } - - if dry_run: - log(f"dry_run: would update {config_path}") - return { - "requestId": request_id, - "success": True, - "changed": changed, - } - - write_config(config_path, current_config) - log(f"Updated SDKMAN config: {config_path}") - - return { - "requestId": request_id, - "success": True, - "changed": True, - } - - except Exception as e: - log(f"Failed to apply config: {e}") - return { - "requestId": request_id, - "success": False, - "changed": False, - "error": str(e), - } - - -def handle(request: dict) -> dict: - request_id = request.get("requestId", "unknown") - command = request.get("command") - args = request.get("args", {}) - context = request.get("context", {}) - - if command == "check_installed": - if not isinstance(args, dict): - raise ValueError("args must be an object") - return check_installed(args, request_id) - if command == "apply": - if not isinstance(args, dict): - raise ValueError("args must be an object") - if not isinstance(context, dict): - raise ValueError("context must be an object") - return apply_config(args, context, request_id) - - return { - "requestId": request_id, - "success": False, - "changed": False, - "error": f"Unknown command: {command}", - } - - -def main() -> None: - raw = sys.stdin.read() - if not raw: - return - - try: - request = json.loads(raw) - result = handle(request) - except Exception as error: - result = { - "requestId": request.get("requestId", "unknown") - if "request" in locals() and isinstance(request, dict) - else "unknown", - "success": False, - "changed": False, - "error": str(error), - } - - sys.stdout.write(json.dumps(result) + "\n") - sys.stdout.flush() - - -if __name__ == "__main__": - main() +# /// script +# dependencies = [] +# /// + +import json +import os +import sys +import tempfile + + +def log(msg): + sys.stderr.write(f"[sdkman-plugin] {msg}\n") + sys.stderr.flush() + + +def get_sdkman_dir(): + userprofile = os.environ.get("USERPROFILE") + + if userprofile: + return os.path.join(userprofile, ".sdkman") + + return os.path.expanduser("~/.sdkman") + + +def get_config_path(): + return os.path.join( + get_sdkman_dir(), + "etc", + "config", + ) + + +def read_config(file_path: str) -> dict: + config = {} + + if not os.path.exists(file_path): + return config + + with open(file_path, "r", encoding="utf-8") as f: + for line in f: + line = line.strip() + + if not line or "=" not in line: + continue + + key, value = line.split("=", 1) + + config[key.strip()] = value.strip() + + return config + + +def sdkman_value(value): + if value is None: + return None + + if isinstance(value, bool): + return "true" if value else "false" + + return str(value) + + +def write_config(file_path: str, data: dict): + os.makedirs(os.path.dirname(file_path), exist_ok=True) + + fd, temp_path = tempfile.mkstemp(dir=os.path.dirname(file_path)) + + try: + with os.fdopen(fd, "w", encoding="utf-8") as f: + for key, value in data.items(): + f.write(f"{key}={sdkman_value(value)}\n") + + os.replace(temp_path, file_path) + + finally: + if os.path.exists(temp_path): + try: + os.remove(temp_path) + except OSError: + pass + + +def merge_settings(target: dict, source: dict) -> bool: + changed = False + + for key, value in source.items(): + converted = sdkman_value(value) + + if converted is None: + continue + + if key not in target or target[key] != converted: + target[key] = converted + changed = True + + return changed + + +def check_installed(args: dict, request_id: str) -> dict: + installed = os.path.isdir(get_sdkman_dir()) + + return { + "requestId": request_id, + "success": True, + "changed": False, + "data": installed, + } + + +def apply_config(args: dict, context: dict, request_id: str) -> dict: + dry_run = bool(context.get("dryRun", False)) + settings = args.get("settings", {}) + + if not isinstance(settings, dict): + raise ValueError("settings must be an object") + + try: + config_path = get_config_path() + current_config = read_config(config_path) + changed = merge_settings(current_config, settings) + + if not changed: + return { + "requestId": request_id, + "success": True, + "changed": False, + } + + if dry_run: + log(f"dry_run: would update {config_path}") + return { + "requestId": request_id, + "success": True, + "changed": changed, + } + + write_config(config_path, current_config) + log(f"Updated SDKMAN config: {config_path}") + + return { + "requestId": request_id, + "success": True, + "changed": True, + } + + except Exception as e: + log(f"Failed to apply config: {e}") + return { + "requestId": request_id, + "success": False, + "changed": False, + "error": str(e), + } + + +def handle(request: dict) -> dict: + request_id = request.get("requestId", "unknown") + command = request.get("command") + args = request.get("args", {}) + context = request.get("context", {}) + + if command == "check_installed": + if not isinstance(args, dict): + raise ValueError("args must be an object") + return check_installed(args, request_id) + if command == "apply": + if not isinstance(args, dict): + raise ValueError("args must be an object") + if not isinstance(context, dict): + raise ValueError("context must be an object") + return apply_config(args, context, request_id) + + return { + "requestId": request_id, + "success": False, + "changed": False, + "error": f"Unknown command: {command}", + } + + +def main() -> None: + raw = sys.stdin.read() + if not raw: + return + + try: + request = json.loads(raw) + result = handle(request) + except Exception as error: + result = { + "requestId": ( + request.get("requestId", "unknown") + if "request" in locals() and isinstance(request, dict) + else "unknown" + ), + "success": False, + "changed": False, + "error": str(error), + } + + sys.stdout.write(json.dumps(result) + "\n") + sys.stdout.flush() + + +if __name__ == "__main__": + main() diff --git a/plugins/sharex/src/plugin.py b/plugins/sharex/src/plugin.py index 677dbdaf..59467c71 100644 --- a/plugins/sharex/src/plugin.py +++ b/plugins/sharex/src/plugin.py @@ -1,169 +1,171 @@ -import datetime -import json -import os -import shutil -import sys -import uuid - - -def log(msg): - sys.stderr.write(f"[sharex-plugin] {msg}\n") - sys.stderr.flush() - - -def get_config_path(): - appdata = os.getenv("APPDATA") - if not appdata: - raise Exception("APPDATA environment variable not found") - return os.path.join(appdata, "ShareX", "ShareX.json") - - -def read_json(file_path: str) -> dict: - if not os.path.exists(file_path): - return {} - try: - with open(file_path, "r", encoding="utf-8") as f: - return json.load(f) - except json.JSONDecodeError: - timestamp = datetime.datetime.now(datetime.timezone.utc).strftime("%Y%m%d%H%M%S") - suffix = uuid.uuid4().hex[:8] - backup_path = f"{file_path}.corrupted.{timestamp}.{suffix}" - try: - shutil.move(file_path, backup_path) - log(f"Config corrupted. Backing up to {backup_path} and starting fresh.") - except Exception as backup_e: - log(f"Failed to backup corrupted config: {backup_e}") - return {} - except OSError as e: - raise OSError(f"Could not read {file_path}: {e}") from e - - -def write_json(file_path: str, data: dict) -> None: - os.makedirs(os.path.dirname(file_path), mode=0o700, exist_ok=True) - tmp_path = file_path + ".tmp" - with open(tmp_path, "w", encoding="utf-8") as f: - json.dump(data, f, indent=2) - f.write("\n") - os.replace(tmp_path, file_path) - - -def deep_merge(target: dict, source: dict) -> bool: - changed = False - for k, v in source.items(): - if k in target and isinstance(target[k], dict) and isinstance(v, dict): - if deep_merge(target[k], v): - changed = True - else: - if k not in target or target[k] != v: - target[k] = v - changed = True - return changed - - -def check_installed(args: dict, request_id: str) -> dict: - installed = False - if shutil.which("ShareX.exe") or shutil.which("ShareX"): - installed = True - else: - program_files = os.getenv("PROGRAMFILES", "C:\\Program Files") - if os.path.exists(os.path.join(program_files, "ShareX", "ShareX.exe")): - installed = True - - return { - "requestId": request_id, - "success": True, - "changed": False, - "data": installed, - } - - -def apply_config(args: dict, context: dict, request_id: str) -> dict: - dry_run = context.get("dryRun", False) - settings = args.get("settings", {}) - - try: - config_path = get_config_path() - current_config = read_json(config_path) - - changed = deep_merge(current_config, settings) - - if not changed: - return { - "requestId": request_id, - "success": True, - "changed": False, - } - - if dry_run: - log(f"Would update {config_path} with new settings") - return { - "requestId": request_id, - "success": True, - "changed": True, - } - - write_json(config_path, current_config) - log(f"Updated ShareX config: {config_path}") - - return { - "requestId": request_id, - "success": True, - "changed": True, - } - - except Exception as e: - log(f"Failed to apply config: {e}") - return { - "requestId": request_id, - "success": False, - "changed": False, - "error": str(e), - } - - -def main(): - input_data = sys.stdin.read() - if not input_data: - return - - try: - request = json.loads(input_data) - except Exception as e: - log(f"Failed to parse request: {e}") - response = { - "requestId": "unknown", - "success": False, - "changed": False, - "error": f"Failed to parse request: {str(e)}", - } - sys.stdout.write(json.dumps(response) + "\n") - sys.stdout.flush() - return - - request_id = request.get("requestId", "unknown") - command = request.get("command") - args = request.get("args", {}) - context = request.get("context", {}) - - response = { - "requestId": request_id, - "success": False, - "changed": False, - } - - try: - if command == "check_installed": - response = check_installed(args, request_id) - elif command == "apply": - response = apply_config(args, context, request_id) - else: - response["error"] = f"Unknown command: {command}" - except Exception as fatal_err: - response["error"] = f"Internal Script Error: {str(fatal_err)}" - - sys.stdout.write(json.dumps(response) + "\n") - sys.stdout.flush() - - -if __name__ == "__main__": - main() +import datetime +import json +import os +import shutil +import sys +import uuid + + +def log(msg): + sys.stderr.write(f"[sharex-plugin] {msg}\n") + sys.stderr.flush() + + +def get_config_path(): + appdata = os.getenv("APPDATA") + if not appdata: + raise Exception("APPDATA environment variable not found") + return os.path.join(appdata, "ShareX", "ShareX.json") + + +def read_json(file_path: str) -> dict: + if not os.path.exists(file_path): + return {} + try: + with open(file_path, "r", encoding="utf-8") as f: + return json.load(f) + except json.JSONDecodeError: + timestamp = datetime.datetime.now(datetime.timezone.utc).strftime( + "%Y%m%d%H%M%S" + ) + suffix = uuid.uuid4().hex[:8] + backup_path = f"{file_path}.corrupted.{timestamp}.{suffix}" + try: + shutil.move(file_path, backup_path) + log(f"Config corrupted. Backing up to {backup_path} and starting fresh.") + except Exception as backup_e: + log(f"Failed to backup corrupted config: {backup_e}") + return {} + except OSError as e: + raise OSError(f"Could not read {file_path}: {e}") from e + + +def write_json(file_path: str, data: dict) -> None: + os.makedirs(os.path.dirname(file_path), mode=0o700, exist_ok=True) + tmp_path = file_path + ".tmp" + with open(tmp_path, "w", encoding="utf-8") as f: + json.dump(data, f, indent=2) + f.write("\n") + os.replace(tmp_path, file_path) + + +def deep_merge(target: dict, source: dict) -> bool: + changed = False + for k, v in source.items(): + if k in target and isinstance(target[k], dict) and isinstance(v, dict): + if deep_merge(target[k], v): + changed = True + else: + if k not in target or target[k] != v: + target[k] = v + changed = True + return changed + + +def check_installed(args: dict, request_id: str) -> dict: + installed = False + if shutil.which("ShareX.exe") or shutil.which("ShareX"): + installed = True + else: + program_files = os.getenv("PROGRAMFILES", "C:\\Program Files") + if os.path.exists(os.path.join(program_files, "ShareX", "ShareX.exe")): + installed = True + + return { + "requestId": request_id, + "success": True, + "changed": False, + "data": installed, + } + + +def apply_config(args: dict, context: dict, request_id: str) -> dict: + dry_run = context.get("dryRun", False) + settings = args.get("settings", {}) + + try: + config_path = get_config_path() + current_config = read_json(config_path) + + changed = deep_merge(current_config, settings) + + if not changed: + return { + "requestId": request_id, + "success": True, + "changed": False, + } + + if dry_run: + log(f"Would update {config_path} with new settings") + return { + "requestId": request_id, + "success": True, + "changed": True, + } + + write_json(config_path, current_config) + log(f"Updated ShareX config: {config_path}") + + return { + "requestId": request_id, + "success": True, + "changed": True, + } + + except Exception as e: + log(f"Failed to apply config: {e}") + return { + "requestId": request_id, + "success": False, + "changed": False, + "error": str(e), + } + + +def main(): + input_data = sys.stdin.read() + if not input_data: + return + + try: + request = json.loads(input_data) + except Exception as e: + log(f"Failed to parse request: {e}") + response = { + "requestId": "unknown", + "success": False, + "changed": False, + "error": f"Failed to parse request: {str(e)}", + } + sys.stdout.write(json.dumps(response) + "\n") + sys.stdout.flush() + return + + request_id = request.get("requestId", "unknown") + command = request.get("command") + args = request.get("args", {}) + context = request.get("context", {}) + + response = { + "requestId": request_id, + "success": False, + "changed": False, + } + + try: + if command == "check_installed": + response = check_installed(args, request_id) + elif command == "apply": + response = apply_config(args, context, request_id) + else: + response["error"] = f"Unknown command: {command}" + except Exception as fatal_err: + response["error"] = f"Internal Script Error: {str(fatal_err)}" + + sys.stdout.write(json.dumps(response) + "\n") + sys.stdout.flush() + + +if __name__ == "__main__": + main() diff --git a/plugins/sharex/test/test_sharex.py b/plugins/sharex/test/test_sharex.py index bafdad37..94a046b8 100644 --- a/plugins/sharex/test/test_sharex.py +++ b/plugins/sharex/test/test_sharex.py @@ -1,154 +1,162 @@ -import os -import sys -import unittest -from unittest.mock import mock_open, patch - -# Append src to sys.path and remove it after import to avoid side effects -_src_path = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "src")) -sys.path.append(_src_path) -import plugin - -sys.path.remove(_src_path) - - -class TestShareXPlugin(unittest.TestCase): - def test_deep_merge(self): - target = { - "ApplicationSettings": { - "ShowTray": False, - "CaptureSettings": {"Screenshot": {"CaptureTransparency": True}}, - }, - "ImageSettings": {"ImageFormat": "JPEG"}, - } - source = { - "ApplicationSettings": { - "ShowTray": True, - "CaptureSettings": {"Screenshot": {"CaptureTransparency": False}}, - }, - "UploadSettings": {"DestinationType": "Imgur"}, - } - changed = plugin.deep_merge(target, source) - self.assertTrue(changed) - self.assertTrue(target["ApplicationSettings"]["ShowTray"]) - self.assertFalse(target["ApplicationSettings"]["CaptureSettings"]["Screenshot"]["CaptureTransparency"]) - self.assertEqual(target["ImageSettings"]["ImageFormat"], "JPEG") - self.assertEqual(target["UploadSettings"]["DestinationType"], "Imgur") - - def test_deep_merge_no_change(self): - target = {"key": "val"} - source = {"key": "val"} - changed = plugin.deep_merge(target, source) - self.assertFalse(changed) - self.assertEqual(target, {"key": "val"}) - - @patch("plugin.shutil.which") - @patch("plugin.os.path.exists") - def test_check_installed_via_path(self, mock_exists, mock_which): - mock_which.return_value = "C:\\path\\ShareX.exe" - res = plugin.check_installed({}, "req-1") - self.assertTrue(res["success"]) - self.assertTrue(res["data"]) - - @patch("plugin.shutil.which") - @patch("plugin.os.path.exists") - def test_check_installed_via_program_files(self, mock_exists, mock_which): - mock_which.return_value = None - mock_exists.return_value = True - res = plugin.check_installed({}, "req-2") - self.assertTrue(res["success"]) - self.assertTrue(res["data"]) - - @patch("plugin.get_config_path") - @patch("plugin.read_json") - @patch("plugin.write_json") - def test_apply_config_real_run(self, mock_write, mock_read, mock_get_path): - mock_get_path.return_value = "dummy.json" - mock_read.return_value = {"a": 1} - - args = {"settings": {"a": 2, "b": 3}} - context = {"dryRun": False} - - res = plugin.apply_config(args, context, "req-3") - self.assertTrue(res["success"]) - self.assertTrue(res["changed"]) - self.assertEqual(res["requestId"], "req-3") - mock_write.assert_called_once_with("dummy.json", {"a": 2, "b": 3}) - - @patch("plugin.get_config_path") - @patch("plugin.read_json") - @patch("plugin.write_json") - def test_apply_config_dry_run(self, mock_write, mock_read, mock_get_path): - mock_get_path.return_value = "dummy.json" - mock_read.return_value = {"a": 1} - - args = {"settings": {"a": 2}} - context = {"dryRun": True} - - res = plugin.apply_config(args, context, "req-4") - self.assertTrue(res["success"]) - self.assertTrue(res["changed"]) - mock_write.assert_not_called() - - @patch("plugin.get_config_path") - @patch("plugin.read_json") - @patch("plugin.write_json") - def test_apply_config_no_change(self, mock_write, mock_read, mock_get_path): - mock_get_path.return_value = "dummy.json" - mock_read.return_value = {"a": 1} - - args = {"settings": {"a": 1}} - context = {"dryRun": False} - - res = plugin.apply_config(args, context, "req-5") - self.assertTrue(res["success"]) - self.assertFalse(res["changed"]) - mock_write.assert_not_called() - - @patch("plugin.os.makedirs") - @patch("plugin.os.replace") - def test_write_json(self, mock_replace, mock_makedirs): - file_path = "dummy/path.json" - data = {"a": 1} - m_open = mock_open() - with patch("plugin.open", m_open): - plugin.write_json(file_path, data) - - mock_makedirs.assert_called_once_with("dummy", mode=0o700, exist_ok=True) - m_open.assert_called_once_with(file_path + ".tmp", "w", encoding="utf-8") - handle = m_open() - handle.write.assert_called_with("\n") - mock_replace.assert_called_once_with(file_path + ".tmp", file_path) - - @patch("plugin.get_config_path") - def test_read_corrupted_config(self, mock_get_path): - import json - import tempfile - - with tempfile.TemporaryDirectory() as temp_dir: - config_path = os.path.join(temp_dir, "ShareX.json") - mock_get_path.return_value = config_path - - # Write corrupted config - with open(config_path, "w", encoding="utf-8") as f: - f.write("{ invalid json") - - args = {"settings": {"a": 2}} - context = {"dryRun": False} - - # Should back up corrupted and apply new - res = plugin.apply_config(args, context, "req-5") - self.assertTrue(res["success"]) - self.assertTrue(res["changed"]) - - # Verify file WAS reset and written - with open(config_path, "r", encoding="utf-8") as f: - content = json.load(f) - self.assertEqual(content["a"], 2) - - # Verify backup was created - backups = [f for f in os.listdir(temp_dir) if f.startswith("ShareX.json.corrupted.")] - self.assertEqual(len(backups), 1) - - -if __name__ == "__main__": - unittest.main() +import os +import sys +import unittest +from unittest.mock import mock_open, patch + +# Append src to sys.path and remove it after import to avoid side effects +_src_path = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "src")) +sys.path.append(_src_path) +import plugin + +sys.path.remove(_src_path) + + +class TestShareXPlugin(unittest.TestCase): + def test_deep_merge(self): + target = { + "ApplicationSettings": { + "ShowTray": False, + "CaptureSettings": {"Screenshot": {"CaptureTransparency": True}}, + }, + "ImageSettings": {"ImageFormat": "JPEG"}, + } + source = { + "ApplicationSettings": { + "ShowTray": True, + "CaptureSettings": {"Screenshot": {"CaptureTransparency": False}}, + }, + "UploadSettings": {"DestinationType": "Imgur"}, + } + changed = plugin.deep_merge(target, source) + self.assertTrue(changed) + self.assertTrue(target["ApplicationSettings"]["ShowTray"]) + self.assertFalse( + target["ApplicationSettings"]["CaptureSettings"]["Screenshot"][ + "CaptureTransparency" + ] + ) + self.assertEqual(target["ImageSettings"]["ImageFormat"], "JPEG") + self.assertEqual(target["UploadSettings"]["DestinationType"], "Imgur") + + def test_deep_merge_no_change(self): + target = {"key": "val"} + source = {"key": "val"} + changed = plugin.deep_merge(target, source) + self.assertFalse(changed) + self.assertEqual(target, {"key": "val"}) + + @patch("plugin.shutil.which") + @patch("plugin.os.path.exists") + def test_check_installed_via_path(self, mock_exists, mock_which): + mock_which.return_value = "C:\\path\\ShareX.exe" + res = plugin.check_installed({}, "req-1") + self.assertTrue(res["success"]) + self.assertTrue(res["data"]) + + @patch("plugin.shutil.which") + @patch("plugin.os.path.exists") + def test_check_installed_via_program_files(self, mock_exists, mock_which): + mock_which.return_value = None + mock_exists.return_value = True + res = plugin.check_installed({}, "req-2") + self.assertTrue(res["success"]) + self.assertTrue(res["data"]) + + @patch("plugin.get_config_path") + @patch("plugin.read_json") + @patch("plugin.write_json") + def test_apply_config_real_run(self, mock_write, mock_read, mock_get_path): + mock_get_path.return_value = "dummy.json" + mock_read.return_value = {"a": 1} + + args = {"settings": {"a": 2, "b": 3}} + context = {"dryRun": False} + + res = plugin.apply_config(args, context, "req-3") + self.assertTrue(res["success"]) + self.assertTrue(res["changed"]) + self.assertEqual(res["requestId"], "req-3") + mock_write.assert_called_once_with("dummy.json", {"a": 2, "b": 3}) + + @patch("plugin.get_config_path") + @patch("plugin.read_json") + @patch("plugin.write_json") + def test_apply_config_dry_run(self, mock_write, mock_read, mock_get_path): + mock_get_path.return_value = "dummy.json" + mock_read.return_value = {"a": 1} + + args = {"settings": {"a": 2}} + context = {"dryRun": True} + + res = plugin.apply_config(args, context, "req-4") + self.assertTrue(res["success"]) + self.assertTrue(res["changed"]) + mock_write.assert_not_called() + + @patch("plugin.get_config_path") + @patch("plugin.read_json") + @patch("plugin.write_json") + def test_apply_config_no_change(self, mock_write, mock_read, mock_get_path): + mock_get_path.return_value = "dummy.json" + mock_read.return_value = {"a": 1} + + args = {"settings": {"a": 1}} + context = {"dryRun": False} + + res = plugin.apply_config(args, context, "req-5") + self.assertTrue(res["success"]) + self.assertFalse(res["changed"]) + mock_write.assert_not_called() + + @patch("plugin.os.makedirs") + @patch("plugin.os.replace") + def test_write_json(self, mock_replace, mock_makedirs): + file_path = "dummy/path.json" + data = {"a": 1} + m_open = mock_open() + with patch("plugin.open", m_open): + plugin.write_json(file_path, data) + + mock_makedirs.assert_called_once_with("dummy", mode=0o700, exist_ok=True) + m_open.assert_called_once_with(file_path + ".tmp", "w", encoding="utf-8") + handle = m_open() + handle.write.assert_called_with("\n") + mock_replace.assert_called_once_with(file_path + ".tmp", file_path) + + @patch("plugin.get_config_path") + def test_read_corrupted_config(self, mock_get_path): + import json + import tempfile + + with tempfile.TemporaryDirectory() as temp_dir: + config_path = os.path.join(temp_dir, "ShareX.json") + mock_get_path.return_value = config_path + + # Write corrupted config + with open(config_path, "w", encoding="utf-8") as f: + f.write("{ invalid json") + + args = {"settings": {"a": 2}} + context = {"dryRun": False} + + # Should back up corrupted and apply new + res = plugin.apply_config(args, context, "req-5") + self.assertTrue(res["success"]) + self.assertTrue(res["changed"]) + + # Verify file WAS reset and written + with open(config_path, "r", encoding="utf-8") as f: + content = json.load(f) + self.assertEqual(content["a"], 2) + + # Verify backup was created + backups = [ + f + for f in os.listdir(temp_dir) + if f.startswith("ShareX.json.corrupted.") + ] + self.assertEqual(len(backups), 1) + + +if __name__ == "__main__": + unittest.main() diff --git a/plugins/spicetify/src/plugin.py b/plugins/spicetify/src/plugin.py index 340c5f75..d02609af 100644 --- a/plugins/spicetify/src/plugin.py +++ b/plugins/spicetify/src/plugin.py @@ -1,243 +1,246 @@ -import configparser -import json -import os -import shutil -import sys -import tempfile -import uuid - -CONFIG_DIR = ".spicetify" -CONFIG_FILE = "config.ini" - - -def log(msg): - sys.stderr.write(f"[spicetify-plugin] {msg}\n") - sys.stderr.flush() - - -def backup_corrupt_config(file_path: str): - if not os.path.exists(file_path): - return - - backup_path = f"{file_path}.corrupt.{uuid.uuid4()}" - - try: - shutil.copy2(file_path, backup_path) - log(f"Backed up corrupt config to {backup_path}") - except Exception as e: - log(f"Failed to backup corrupt config: {e}") - - -def get_config_path(): - user_profile = os.getenv("USERPROFILE") or os.path.expanduser("~") - return os.path.join(user_profile, CONFIG_DIR, CONFIG_FILE) - - -def read_ini(file_path: str) -> configparser.ConfigParser: - config = configparser.ConfigParser() - - if not os.path.exists(file_path): - return config - - try: - with open(file_path, "r", encoding="utf-8") as f: - config.read_file(f) - - return config - - except Exception: - backup_corrupt_config(file_path) - return configparser.ConfigParser() - - -def normalize_value(value): - if isinstance(value, bool): - return "1" if value else "0" - - if isinstance(value, (list, tuple)): - return ",".join(str(item) for item in value) - - if value is None: - return "" - - return str(value) - - -def merge_settings(config: configparser.ConfigParser, settings: dict) -> bool: - changed = False - - for section, values in settings.items(): - if not isinstance(values, dict): - continue - - if not config.has_section(section): - config.add_section(section) - changed = True - - for key, value in values.items(): - normalized = normalize_value(value) - - if not config.has_option(section, key) or config.get(section, key) != normalized: - config.set(section, key, normalized) - changed = True - - return changed - - -def write_ini(file_path: str, config: configparser.ConfigParser) -> None: - dir_path = os.path.dirname(file_path) - os.makedirs(dir_path, exist_ok=True) - - fd, temp_path = tempfile.mkstemp(prefix="spicetify-", dir=dir_path) - - try: - with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as f: - config.write(f) - - os.replace(temp_path, file_path) - except Exception: - try: - os.unlink(temp_path) - except OSError: - pass - - raise - - -def check_installed(args: dict, request_id: str) -> dict: - user_profile = os.getenv("USERPROFILE") or os.path.expanduser("~") - spicetify_dir = os.path.join(user_profile, CONFIG_DIR) - - installed = ( - os.path.isdir(spicetify_dir) - or shutil.which("spicetify.exe") is not None - or shutil.which("spicetify") is not None - ) - - return { - "requestId": request_id, - "success": True, - "changed": False, - "data": installed, - } - - -def apply_config(args: dict, context: dict, request_id: str) -> dict: - dry_run = context.get("dryRun", False) - settings = args.get("settings", {}) - - if not isinstance(settings, dict): - return { - "requestId": request_id, - "success": False, - "changed": False, - "error": "settings must be an object", - "data": None, - } - - try: - config_path = get_config_path() - config = read_ini(config_path) - changed = merge_settings(config, settings) - - if not changed: - return { - "requestId": request_id, - "success": True, - "changed": False, - "data": None, - } - - if dry_run: - return { - "requestId": request_id, - "success": True, - "changed": True, - "data": { - "path": config_path, - "settings": settings, - }, - } - - write_ini(config_path, config) - - return { - "requestId": request_id, - "success": True, - "changed": True, - "data": { - "path": config_path, - }, - } - - except Exception as e: - log(f"Failed to apply config: {e}") - - return { - "requestId": request_id, - "success": False, - "changed": False, - "error": str(e), - "data": None, - } - - -def main(): - input_data = sys.stdin.read() - - if not input_data: - response = { - "requestId": "unknown", - "success": False, - "changed": False, - "error": "No input provided on stdin", - "data": None, - } - - sys.stdout.write(json.dumps(response) + "\n") - sys.stdout.flush() - return - - try: - request = json.loads(input_data) - except Exception as e: - response = { - "requestId": "unknown", - "success": False, - "changed": False, - "error": f"Failed to parse JSON request: {str(e)}", - "data": None, - } - - sys.stdout.write(json.dumps(response) + "\n") - sys.stdout.flush() - return - - request_id = request.get("requestId", "unknown") - command = request.get("command") - args = request.get("args", {}) - context = request.get("context", {}) - - response = { - "requestId": request_id, - "success": False, - "changed": False, - "data": None, - } - - try: - if command == "check_installed": - response = check_installed(args, request_id) - elif command == "apply": - response = apply_config(args, context, request_id) - else: - response["error"] = f"Unknown command: {command}" - except Exception as fatal_err: - response["error"] = f"Internal Script Error: {str(fatal_err)}" - - sys.stdout.write(json.dumps(response) + "\n") - sys.stdout.flush() - - -if __name__ == "__main__": - main() +import configparser +import json +import os +import shutil +import sys +import tempfile +import uuid + +CONFIG_DIR = ".spicetify" +CONFIG_FILE = "config.ini" + + +def log(msg): + sys.stderr.write(f"[spicetify-plugin] {msg}\n") + sys.stderr.flush() + + +def backup_corrupt_config(file_path: str): + if not os.path.exists(file_path): + return + + backup_path = f"{file_path}.corrupt.{uuid.uuid4()}" + + try: + shutil.copy2(file_path, backup_path) + log(f"Backed up corrupt config to {backup_path}") + except Exception as e: + log(f"Failed to backup corrupt config: {e}") + + +def get_config_path(): + user_profile = os.getenv("USERPROFILE") or os.path.expanduser("~") + return os.path.join(user_profile, CONFIG_DIR, CONFIG_FILE) + + +def read_ini(file_path: str) -> configparser.ConfigParser: + config = configparser.ConfigParser() + + if not os.path.exists(file_path): + return config + + try: + with open(file_path, "r", encoding="utf-8") as f: + config.read_file(f) + + return config + + except Exception: + backup_corrupt_config(file_path) + return configparser.ConfigParser() + + +def normalize_value(value): + if isinstance(value, bool): + return "1" if value else "0" + + if isinstance(value, (list, tuple)): + return ",".join(str(item) for item in value) + + if value is None: + return "" + + return str(value) + + +def merge_settings(config: configparser.ConfigParser, settings: dict) -> bool: + changed = False + + for section, values in settings.items(): + if not isinstance(values, dict): + continue + + if not config.has_section(section): + config.add_section(section) + changed = True + + for key, value in values.items(): + normalized = normalize_value(value) + + if ( + not config.has_option(section, key) + or config.get(section, key) != normalized + ): + config.set(section, key, normalized) + changed = True + + return changed + + +def write_ini(file_path: str, config: configparser.ConfigParser) -> None: + dir_path = os.path.dirname(file_path) + os.makedirs(dir_path, exist_ok=True) + + fd, temp_path = tempfile.mkstemp(prefix="spicetify-", dir=dir_path) + + try: + with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as f: + config.write(f) + + os.replace(temp_path, file_path) + except Exception: + try: + os.unlink(temp_path) + except OSError: + pass + + raise + + +def check_installed(args: dict, request_id: str) -> dict: + user_profile = os.getenv("USERPROFILE") or os.path.expanduser("~") + spicetify_dir = os.path.join(user_profile, CONFIG_DIR) + + installed = ( + os.path.isdir(spicetify_dir) + or shutil.which("spicetify.exe") is not None + or shutil.which("spicetify") is not None + ) + + return { + "requestId": request_id, + "success": True, + "changed": False, + "data": installed, + } + + +def apply_config(args: dict, context: dict, request_id: str) -> dict: + dry_run = context.get("dryRun", False) + settings = args.get("settings", {}) + + if not isinstance(settings, dict): + return { + "requestId": request_id, + "success": False, + "changed": False, + "error": "settings must be an object", + "data": None, + } + + try: + config_path = get_config_path() + config = read_ini(config_path) + changed = merge_settings(config, settings) + + if not changed: + return { + "requestId": request_id, + "success": True, + "changed": False, + "data": None, + } + + if dry_run: + return { + "requestId": request_id, + "success": True, + "changed": True, + "data": { + "path": config_path, + "settings": settings, + }, + } + + write_ini(config_path, config) + + return { + "requestId": request_id, + "success": True, + "changed": True, + "data": { + "path": config_path, + }, + } + + except Exception as e: + log(f"Failed to apply config: {e}") + + return { + "requestId": request_id, + "success": False, + "changed": False, + "error": str(e), + "data": None, + } + + +def main(): + input_data = sys.stdin.read() + + if not input_data: + response = { + "requestId": "unknown", + "success": False, + "changed": False, + "error": "No input provided on stdin", + "data": None, + } + + sys.stdout.write(json.dumps(response) + "\n") + sys.stdout.flush() + return + + try: + request = json.loads(input_data) + except Exception as e: + response = { + "requestId": "unknown", + "success": False, + "changed": False, + "error": f"Failed to parse JSON request: {str(e)}", + "data": None, + } + + sys.stdout.write(json.dumps(response) + "\n") + sys.stdout.flush() + return + + request_id = request.get("requestId", "unknown") + command = request.get("command") + args = request.get("args", {}) + context = request.get("context", {}) + + response = { + "requestId": request_id, + "success": False, + "changed": False, + "data": None, + } + + try: + if command == "check_installed": + response = check_installed(args, request_id) + elif command == "apply": + response = apply_config(args, context, request_id) + else: + response["error"] = f"Unknown command: {command}" + except Exception as fatal_err: + response["error"] = f"Internal Script Error: {str(fatal_err)}" + + sys.stdout.write(json.dumps(response) + "\n") + sys.stdout.flush() + + +if __name__ == "__main__": + main() diff --git a/plugins/spicetify/test/test_spicetify.py b/plugins/spicetify/test/test_spicetify.py index 042970a8..19744ecf 100644 --- a/plugins/spicetify/test/test_spicetify.py +++ b/plugins/spicetify/test/test_spicetify.py @@ -4,11 +4,18 @@ import sys import tempfile -PLUGIN = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "src", "plugin.py")) +PLUGIN = os.path.abspath( + os.path.join(os.path.dirname(__file__), "..", "src", "plugin.py") +) def run_plugin(payload: dict) -> dict: - result = subprocess.run([sys.executable, PLUGIN], input=json.dumps(payload), capture_output=True, text=True) + result = subprocess.run( + [sys.executable, PLUGIN], + input=json.dumps(payload), + capture_output=True, + text=True, + ) return json.loads(result.stdout.strip()) @@ -17,7 +24,9 @@ def test_check_installed_absent(): with tempfile.TemporaryDirectory() as tmp: os.environ["USERPROFILE"] = tmp - res = run_plugin({"requestId": "1", "command": "check_installed", "args": {}, "context": {}}) + res = run_plugin( + {"requestId": "1", "command": "check_installed", "args": {}, "context": {}} + ) assert res["requestId"] == "1" assert res["success"] @@ -32,7 +41,15 @@ def test_apply_config_dry_run(): { "requestId": "2", "command": "apply", - "args": {"settings": {"Setting": {"theme": "Catppuccin", "color_scheme": "mocha", "inject_css": True}}}, + "args": { + "settings": { + "Setting": { + "theme": "Catppuccin", + "color_scheme": "mocha", + "inject_css": True, + } + } + }, "context": {"dryRun": True}, } ) @@ -55,7 +72,11 @@ def test_apply_config_creates_file(): "command": "apply", "args": { "settings": { - "Setting": {"theme": "Catppuccin", "color_scheme": "mocha", "inject_css": True}, + "Setting": { + "theme": "Catppuccin", + "color_scheme": "mocha", + "inject_css": True, + }, "AdditionalOptions": {"sidebar_config": "1"}, } }, @@ -131,7 +152,14 @@ def test_idempotent_apply(): def test_invalid_settings_returns_error(): - res = run_plugin({"requestId": "6", "command": "apply", "args": {"settings": None}, "context": {}}) + res = run_plugin( + { + "requestId": "6", + "command": "apply", + "args": {"settings": None}, + "context": {}, + } + ) assert res["requestId"] == "6" assert not res["success"] diff --git a/plugins/spotify/test/test_spotify.py b/plugins/spotify/test/test_spotify.py index a9e59704..c5b3be67 100644 --- a/plugins/spotify/test/test_spotify.py +++ b/plugins/spotify/test/test_spotify.py @@ -4,7 +4,9 @@ import sys import tempfile -PLUGIN = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "src", "plugin.py")) +PLUGIN = os.path.abspath( + os.path.join(os.path.dirname(__file__), "..", "src", "plugin.py") +) def run_plugin(payload: dict) -> dict: @@ -63,7 +65,9 @@ def test_merge_existing_preferences(): prefs_path = os.path.join(spotify_dir, "prefs") with open(prefs_path, "w", encoding="utf-8") as f: - f.write("audio.play_bitrate_enumeration=4\nui.track_notifications_enabled=false\n") + f.write( + "audio.play_bitrate_enumeration=4\nui.track_notifications_enabled=false\n" + ) res = run_plugin( { diff --git a/plugins/starship/src/plugin.py b/plugins/starship/src/plugin.py index 49a9572c..1c5b2e14 100644 --- a/plugins/starship/src/plugin.py +++ b/plugins/starship/src/plugin.py @@ -40,7 +40,9 @@ def read_toml(file_path: str) -> dict: log(f"Warning: could not parse {file_path} using tomllib: {e}") return {} else: - log("Warning: tomllib not available (requires Python 3.11+). Starting with empty config.") + log( + "Warning: tomllib not available (requires Python 3.11+). Starting with empty config." + ) return {} @@ -104,7 +106,9 @@ def merge_settings(target: dict, source: dict) -> bool: def check_installed(args: dict, request_id: str) -> dict: - installed = shutil.which("starship.exe") is not None or shutil.which("starship") is not None + installed = ( + shutil.which("starship.exe") is not None or shutil.which("starship") is not None + ) return { "requestId": request_id, "success": True, diff --git a/plugins/starship/test/test_starship.py b/plugins/starship/test/test_starship.py index e888d8f8..8ad243ac 100644 --- a/plugins/starship/test/test_starship.py +++ b/plugins/starship/test/test_starship.py @@ -1,130 +1,134 @@ -import json -import os -import subprocess -import sys -import tempfile - -PLUGIN = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "src", "plugin.py")) - - -def run_plugin(payload: dict) -> dict: - result = subprocess.run( - [sys.executable, PLUGIN], - input=json.dumps(payload), - capture_output=True, - text=True, - ) - if result.returncode != 0: - print(f"Error output: {result.stderr}") - return json.loads(result.stdout.strip()) - - -def test_check_installed(): - res = run_plugin( - { - "requestId": "1", - "command": "check_installed", - "args": {}, - "context": {}, - } - ) - assert "success" in res - assert res["success"] is True - print("OK: check_installed") - - -def test_apply_config_dry_run(): - with tempfile.TemporaryDirectory() as tmp: - os.environ["USERPROFILE"] = tmp - - res = run_plugin( - { - "requestId": "2", - "command": "apply", - "args": {"format": "$all", "nodejs": {"disabled": True}}, - "context": {"dryRun": True}, - } - ) - - assert res["success"] - assert res["changed"] - - config_path = os.path.join(tmp, ".config", "starship.toml") - assert not os.path.exists(config_path) - - print("OK: apply_config_dry_run") - - -def test_apply_config(): - with tempfile.TemporaryDirectory() as tmp: - os.environ["USERPROFILE"] = tmp - - res = run_plugin( - { - "requestId": "3", - "command": "apply", - "args": { - "format": "$all", - "scan_timeout": 30, - "character": {"success_symbol": "[➜](bold green)"}, - }, - "context": {"dryRun": False}, - } - ) - - assert res["success"] - assert res["changed"] - - config_path = os.path.join(tmp, ".config", "starship.toml") - assert os.path.exists(config_path) - - with open(config_path, "r", encoding="utf-8") as f: - content = f.read() - - assert 'format = "$all"' in content - assert "scan_timeout = 30" in content - assert "[character]" in content - assert 'success_symbol = "[➜](bold green)"' in content - - print("OK: apply_config") - - -def test_idempotent_apply(): - with tempfile.TemporaryDirectory() as tmp: - os.environ["USERPROFILE"] = tmp - - payload = { - "requestId": "4", - "command": "apply", - "args": {"add_newline": True}, - "context": {"dryRun": False}, - } - - # First run should create/modify and return changed: true - res1 = run_plugin(payload) - assert res1["success"] - assert res1["changed"] - - # Second run should return changed: false - res2 = run_plugin(payload) - assert res2["success"] - assert not res2["changed"] - - print("OK: idempotent_apply") - - -def test_unknown_command(): - res = run_plugin({"requestId": "5", "command": "explode", "args": {}, "context": {}}) - - assert not res["success"] - assert "error" in res - print("OK: unknown_command") - - -if __name__ == "__main__": - test_check_installed() - test_apply_config_dry_run() - test_apply_config() - test_idempotent_apply() - test_unknown_command() - print("\nAll tests passed.") +import json +import os +import subprocess +import sys +import tempfile + +PLUGIN = os.path.abspath( + os.path.join(os.path.dirname(__file__), "..", "src", "plugin.py") +) + + +def run_plugin(payload: dict) -> dict: + result = subprocess.run( + [sys.executable, PLUGIN], + input=json.dumps(payload), + capture_output=True, + text=True, + ) + if result.returncode != 0: + print(f"Error output: {result.stderr}") + return json.loads(result.stdout.strip()) + + +def test_check_installed(): + res = run_plugin( + { + "requestId": "1", + "command": "check_installed", + "args": {}, + "context": {}, + } + ) + assert "success" in res + assert res["success"] is True + print("OK: check_installed") + + +def test_apply_config_dry_run(): + with tempfile.TemporaryDirectory() as tmp: + os.environ["USERPROFILE"] = tmp + + res = run_plugin( + { + "requestId": "2", + "command": "apply", + "args": {"format": "$all", "nodejs": {"disabled": True}}, + "context": {"dryRun": True}, + } + ) + + assert res["success"] + assert res["changed"] + + config_path = os.path.join(tmp, ".config", "starship.toml") + assert not os.path.exists(config_path) + + print("OK: apply_config_dry_run") + + +def test_apply_config(): + with tempfile.TemporaryDirectory() as tmp: + os.environ["USERPROFILE"] = tmp + + res = run_plugin( + { + "requestId": "3", + "command": "apply", + "args": { + "format": "$all", + "scan_timeout": 30, + "character": {"success_symbol": "[➜](bold green)"}, + }, + "context": {"dryRun": False}, + } + ) + + assert res["success"] + assert res["changed"] + + config_path = os.path.join(tmp, ".config", "starship.toml") + assert os.path.exists(config_path) + + with open(config_path, "r", encoding="utf-8") as f: + content = f.read() + + assert 'format = "$all"' in content + assert "scan_timeout = 30" in content + assert "[character]" in content + assert 'success_symbol = "[➜](bold green)"' in content + + print("OK: apply_config") + + +def test_idempotent_apply(): + with tempfile.TemporaryDirectory() as tmp: + os.environ["USERPROFILE"] = tmp + + payload = { + "requestId": "4", + "command": "apply", + "args": {"add_newline": True}, + "context": {"dryRun": False}, + } + + # First run should create/modify and return changed: true + res1 = run_plugin(payload) + assert res1["success"] + assert res1["changed"] + + # Second run should return changed: false + res2 = run_plugin(payload) + assert res2["success"] + assert not res2["changed"] + + print("OK: idempotent_apply") + + +def test_unknown_command(): + res = run_plugin( + {"requestId": "5", "command": "explode", "args": {}, "context": {}} + ) + + assert not res["success"] + assert "error" in res + print("OK: unknown_command") + + +if __name__ == "__main__": + test_check_installed() + test_apply_config_dry_run() + test_apply_config() + test_idempotent_apply() + test_unknown_command() + print("\nAll tests passed.") diff --git a/plugins/sublime-text/src/plugin.py b/plugins/sublime-text/src/plugin.py index b3025ebc..0ea3be62 100644 --- a/plugins/sublime-text/src/plugin.py +++ b/plugins/sublime-text/src/plugin.py @@ -76,7 +76,10 @@ def merge_settings(target: dict, source: dict) -> bool: def check_installed() -> bool: - return shutil.which("subl.exe") is not None or shutil.which("sublime_text.exe") is not None + return ( + shutil.which("subl.exe") is not None + or shutil.which("sublime_text.exe") is not None + ) def apply_config(args: dict, context: dict, request_id: str) -> dict: diff --git a/plugins/sublime-text/test/test_sublime_text.py b/plugins/sublime-text/test/test_sublime_text.py index 5a7456d9..57f9ec2c 100644 --- a/plugins/sublime-text/test/test_sublime_text.py +++ b/plugins/sublime-text/test/test_sublime_text.py @@ -4,7 +4,9 @@ import sys import tempfile -PLUGIN = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "src", "plugin.py")) +PLUGIN = os.path.abspath( + os.path.join(os.path.dirname(__file__), "..", "src", "plugin.py") +) def run_plugin(payload: dict) -> dict: diff --git a/plugins/syncthing/src/plugin.py b/plugins/syncthing/src/plugin.py index 20d18bc7..33b9aea5 100644 --- a/plugins/syncthing/src/plugin.py +++ b/plugins/syncthing/src/plugin.py @@ -70,7 +70,9 @@ def process_command(request): if "gui" in settings and update_element(root, "gui", settings["gui"]): changed = True - if "options" in settings and update_element(root, "options", settings["options"]): + if "options" in settings and update_element( + root, "options", settings["options"] + ): changed = True if changed and not dry_run: diff --git a/plugins/syncthing/test/test_syncthing.py b/plugins/syncthing/test/test_syncthing.py index 09148804..fda9fcbe 100644 --- a/plugins/syncthing/test/test_syncthing.py +++ b/plugins/syncthing/test/test_syncthing.py @@ -51,7 +51,11 @@ def test_check_installed_response_format(): def test_apply_config_dry_run(): - request = {"requestId": "789", "command": "apply", "args": {"dryRun": True, "settings": {"gui": {"enabled": True}}}} + request = { + "requestId": "789", + "command": "apply", + "args": {"dryRun": True, "settings": {"gui": {"enabled": True}}}, + } stdout = run_plugin(json.dumps(request)) response = json.loads(stdout) assert response["requestId"] == "789" @@ -67,7 +71,10 @@ def test_apply_config(): request = { "requestId": "101", "command": "apply", - "args": {"dryRun": False, "settings": {"gui": {"enabled": True, "user": "admin"}}}, + "args": { + "dryRun": False, + "settings": {"gui": {"enabled": True, "user": "admin"}}, + }, } stdout = run_plugin(json.dumps(request), env=env) @@ -93,7 +100,10 @@ def test_idempotent_apply(): request = { "requestId": "202", "command": "apply", - "args": {"dryRun": False, "settings": {"options": {"listenAddress": "default"}}}, + "args": { + "dryRun": False, + "settings": {"options": {"listenAddress": "default"}}, + }, } run_plugin(json.dumps(request), env=env) diff --git a/plugins/topgrade/src/plugin.py b/plugins/topgrade/src/plugin.py index f9ef9857..0c187242 100644 --- a/plugins/topgrade/src/plugin.py +++ b/plugins/topgrade/src/plugin.py @@ -19,7 +19,9 @@ def log(msg): def get_topgrade_config_path(): - appdata = (os.environ.get("APPDATA") or os.path.join(os.path.expanduser("~"), "AppData", "Roaming")) + appdata = os.environ.get("APPDATA") or os.path.join( + os.path.expanduser("~"), "AppData", "Roaming" + ) if not appdata: return None @@ -112,7 +114,10 @@ def main(): try: request = json.loads(input_data) except Exception as e: - response = {"requestId": "unknown", "error": f"Failed to parse JSON request: {str(e)}"} + response = { + "requestId": "unknown", + "error": f"Failed to parse JSON request: {str(e)}", + } sys.stdout.write(json.dumps(response) + "\n") sys.stdout.flush() return diff --git a/plugins/topgrade/test/test_topgrade.py b/plugins/topgrade/test/test_topgrade.py index 619f4e78..dc3699cf 100644 --- a/plugins/topgrade/test/test_topgrade.py +++ b/plugins/topgrade/test/test_topgrade.py @@ -12,7 +12,9 @@ def run_plugin(request_dict): input_str = json.dumps(request_dict) # Use standard python to run since uv might not be perfectly nested here, # but the script uses inline dependencies so we must use 'uv run' - result = subprocess.run(["uv", "run", PLUGIN_SCRIPT], input=input_str, text=True, capture_output=True) + result = subprocess.run( + ["uv", "run", PLUGIN_SCRIPT], input=input_str, text=True, capture_output=True + ) return json.loads(result.stdout) if result.stdout else None, result.stderr @@ -28,7 +30,11 @@ def test_apply_new_file(monkeypatch, tmp_path): # Set APPDATA to a temporary directory monkeypatch.setenv("APPDATA", str(tmp_path)) - settings = {"disable": ["pip", "npm"], "set_title": True, "git_repos": {"~/Projects/dotfiles": "main"}} + settings = { + "disable": ["pip", "npm"], + "set_title": True, + "git_repos": {"~/Projects/dotfiles": "main"}, + } req = {"requestId": "456", "command": "apply", "args": {"settings": settings}} diff --git a/plugins/vim/src/main.py b/plugins/vim/src/main.py index 74815774..7082dc16 100644 --- a/plugins/vim/src/main.py +++ b/plugins/vim/src/main.py @@ -104,7 +104,9 @@ def apply_config(config, context): for key, value in settings.items(): if key == "theme": lines.append(f"vim.cmd('colorscheme {value}')") - elif isinstance(value, bool) or (isinstance(value, str) and value.lower() in ("true", "false")): + elif isinstance(value, bool) or ( + isinstance(value, str) and value.lower() in ("true", "false") + ): if isinstance(value, bool): val_str = "true" if value else "false" else: diff --git a/plugins/vim/tests/test_vim.py b/plugins/vim/tests/test_vim.py index be09d70f..b00a97c2 100644 --- a/plugins/vim/tests/test_vim.py +++ b/plugins/vim/tests/test_vim.py @@ -24,7 +24,9 @@ def test_check_installed_returns_true_if_dir_exists(self, mock_isdir): @patch("os.path.exists") @patch("os.makedirs") @patch("builtins.open", new_callable=mock_open) - def test_apply_config_generates_correct_lua(self, mock_file, mock_makedirs, mock_exists): + def test_apply_config_generates_correct_lua( + self, mock_file, mock_makedirs, mock_exists + ): mock_exists.return_value = False config = {"settings": {"number": True, "theme": "gruvbox", "shiftwidth": 4}} context = {"dryRun": False} @@ -35,7 +37,9 @@ def test_apply_config_generates_correct_lua(self, mock_file, mock_makedirs, mock self.assertTrue(result["changed"]) # Verify content - written_content = "".join(call.args[0] for call in mock_file().write.call_args_list) + written_content = "".join( + call.args[0] for call in mock_file().write.call_args_list + ) self.assertIn("vim.opt.number = true", written_content) self.assertIn("vim.cmd('colorscheme gruvbox')", written_content) self.assertIn("vim.opt.shiftwidth = 4", written_content) diff --git a/plugins/vlc/src/plugin.py b/plugins/vlc/src/plugin.py index 865e220c..5d4aa369 100644 --- a/plugins/vlc/src/plugin.py +++ b/plugins/vlc/src/plugin.py @@ -149,14 +149,19 @@ def main() -> None: input_data = sys.stdin.read() if not input_data: - sys.stdout.write(json.dumps({"requestId": "unknown", "error": "Empty stdin"}) + "\n") + sys.stdout.write( + json.dumps({"requestId": "unknown", "error": "Empty stdin"}) + "\n" + ) sys.stdout.flush() return try: request = json.loads(input_data) except Exception as exc: - sys.stdout.write(json.dumps({"requestId": "unknown", "error": f"JSON parse error: {exc}"}) + "\n") + sys.stdout.write( + json.dumps({"requestId": "unknown", "error": f"JSON parse error: {exc}"}) + + "\n" + ) sys.stdout.flush() return diff --git a/plugins/vlc/test/test_vlc.py b/plugins/vlc/test/test_vlc.py index 19c6a871..c05e1630 100644 --- a/plugins/vlc/test/test_vlc.py +++ b/plugins/vlc/test/test_vlc.py @@ -14,9 +14,12 @@ import tempfile import textwrap -PLUGIN = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "src", "plugin.py")) +PLUGIN = os.path.abspath( + os.path.join(os.path.dirname(__file__), "..", "src", "plugin.py") +) -SAMPLE_VLCRC = textwrap.dedent("""\ +SAMPLE_VLCRC = textwrap.dedent( + """\ # VLC media player preferences [core] volume=256 @@ -29,7 +32,8 @@ qt-max-volume=125 [sout] enable-lua-sd= -""") +""" +) def run_plugin(payload: dict, env: dict | None = None) -> dict: @@ -104,7 +108,12 @@ def test_check_installed_absent(): def test_check_installed_no_success_field(): with tempfile.TemporaryDirectory() as tmp: res = run_plugin( - {"requestId": "ci3", "command": "check_installed", "args": {}, "context": {}}, + { + "requestId": "ci3", + "command": "check_installed", + "args": {}, + "context": {}, + }, env={"APPDATA": tmp}, ) assert "success" not in res @@ -326,19 +335,30 @@ def test_apply_settings_not_dict(): def test_unknown_command(): - res = run_plugin({"requestId": "12", "command": "explode", "args": {}, "context": {}}) + res = run_plugin( + {"requestId": "12", "command": "explode", "args": {}, "context": {}} + ) assert "error" in res print("✓ unknown_command") def test_request_id_echoed(): - res = run_plugin({"requestId": "my-custom-id", "command": "check_installed", "args": {}, "context": {}}) + res = run_plugin( + { + "requestId": "my-custom-id", + "command": "check_installed", + "args": {}, + "context": {}, + } + ) assert res["requestId"] == "my-custom-id" print("✓ request_id_echoed") def test_request_id_null_defaults_to_unknown(): - res = run_plugin({"requestId": None, "command": "check_installed", "args": {}, "context": {}}) + res = run_plugin( + {"requestId": None, "command": "check_installed", "args": {}, "context": {}} + ) assert res["requestId"] == "unknown" print("✓ request_id_null_defaults_to_unknown") diff --git a/plugins/wallpaper-engine/src/plugin.py b/plugins/wallpaper-engine/src/plugin.py index 73a75e59..fa184c56 100644 --- a/plugins/wallpaper-engine/src/plugin.py +++ b/plugins/wallpaper-engine/src/plugin.py @@ -9,8 +9,24 @@ def get_config_paths(): program_files_x86 = os.environ.get("ProgramFiles(x86)", r"C:\Program Files (x86)") program_files_x64 = os.environ.get("ProgramFiles", r"C:\Program Files") return [ - os.path.join(program_files_x86, "Steam", "steamapps", "common", "wallpaper_engine", "config", "config.json"), - os.path.join(program_files_x64, "Steam", "steamapps", "common", "wallpaper_engine", "config", "config.json"), + os.path.join( + program_files_x86, + "Steam", + "steamapps", + "common", + "wallpaper_engine", + "config", + "config.json", + ), + os.path.join( + program_files_x64, + "Steam", + "steamapps", + "common", + "wallpaper_engine", + "config", + "config.json", + ), ] @@ -46,9 +62,17 @@ def apply(request_id, args): target_path = path break if not target_path: - program_files_x86 = os.environ.get("ProgramFiles(x86)", r"C:\Program Files (x86)") + program_files_x86 = os.environ.get( + "ProgramFiles(x86)", r"C:\Program Files (x86)" + ) target_path = os.path.join( - program_files_x86, "Steam", "steamapps", "common", "wallpaper_engine", "config", "config.json" + program_files_x86, + "Steam", + "steamapps", + "common", + "wallpaper_engine", + "config", + "config.json", ) config_dir = os.path.dirname(target_path) @@ -63,7 +87,12 @@ def apply(request_id, args): has_changes = deep_merge(existing_config, settings) if dry_run: - return {"requestId": request_id, "dryRun": True, "changed": has_changes, "path": target_path} + return { + "requestId": request_id, + "dryRun": True, + "changed": has_changes, + "path": target_path, + } if not has_changes: return {"requestId": request_id, "changed": False, "path": target_path} @@ -108,7 +137,11 @@ def main(): result = apply(request_id, args) print(json.dumps(result)) else: - print(json.dumps({"requestId": request_id, "error": f"Unknown command: {command}"})) + print( + json.dumps( + {"requestId": request_id, "error": f"Unknown command: {command}"} + ) + ) if __name__ == "__main__": diff --git a/plugins/wallpaper-engine/test/test_wallpaper_engine.py b/plugins/wallpaper-engine/test/test_wallpaper_engine.py index 249dd559..6cdc4ae3 100644 --- a/plugins/wallpaper-engine/test/test_wallpaper_engine.py +++ b/plugins/wallpaper-engine/test/test_wallpaper_engine.py @@ -11,9 +11,13 @@ class TestWallpaperEnginePluginContract(unittest.TestCase): def setUp(self): self.test_dir = tempfile.mkdtemp() - self.config_dir = os.path.join(self.test_dir, "Steam", "steamapps", "common", "wallpaper_engine", "config") + self.config_dir = os.path.join( + self.test_dir, "Steam", "steamapps", "common", "wallpaper_engine", "config" + ) self.config_file = os.path.join(self.config_dir, "config.json") - self.plugin_script = os.path.abspath(os.path.join(os.path.dirname(__file__), "../src/plugin.py")) + self.plugin_script = os.path.abspath( + os.path.join(os.path.dirname(__file__), "../src/plugin.py") + ) self.orig_p86 = os.environ.get("ProgramFiles(x86)") os.environ["ProgramFiles(x86)"] = self.test_dir @@ -58,7 +62,11 @@ def test_protocol_check_installed(self): self.assertNotIn("status", response) def test_apply_config_dry_run(self): - payload = {"requestId": "req-002", "command": "apply", "args": {"settings": {"volume": 0.8}, "dryRun": True}} + payload = { + "requestId": "req-002", + "command": "apply", + "args": {"settings": {"volume": 0.8}, "dryRun": True}, + } response = self.run_plugin_subprocess(json.dumps(payload)) self.assertEqual(response["requestId"], "req-002") self.assertTrue(response["dryRun"]) @@ -88,7 +96,11 @@ def test_protocol_apply_changes(self): def test_idempotent_apply(self): os.makedirs(self.config_dir, exist_ok=True) - payload = {"requestId": "req-004", "command": "apply", "args": {"settings": {"fps": 60}}} + payload = { + "requestId": "req-004", + "command": "apply", + "args": {"settings": {"fps": 60}}, + } res1 = self.run_plugin_subprocess(json.dumps(payload)) self.assertTrue(res1["changed"]) diff --git a/plugins/windows-explorer/src/plugin.py b/plugins/windows-explorer/src/plugin.py index 0dd98e31..1543c8c0 100644 --- a/plugins/windows-explorer/src/plugin.py +++ b/plugins/windows-explorer/src/plugin.py @@ -13,7 +13,9 @@ def log(msg): def read_registry_values(): values = {} try: - with winreg.OpenKey(winreg.HKEY_CURRENT_USER, REG_PATH, 0, winreg.KEY_READ) as key: + with winreg.OpenKey( + winreg.HKEY_CURRENT_USER, REG_PATH, 0, winreg.KEY_READ + ) as key: try: i = 0 while True: @@ -82,7 +84,9 @@ def apply_config(args): log(f"Dry run: Would update registry key {k} to {v}") elif changed: try: - with winreg.OpenKey(winreg.HKEY_CURRENT_USER, REG_PATH, 0, winreg.KEY_SET_VALUE) as key: + with winreg.OpenKey( + winreg.HKEY_CURRENT_USER, REG_PATH, 0, winreg.KEY_SET_VALUE + ) as key: for k, v in updates.items(): winreg.SetValueEx(key, k, 0, winreg.REG_DWORD, v) log(f"Updated registry key {k} to {v}") @@ -103,7 +107,10 @@ def main(): try: request = json.loads(input_data) except Exception as e: - response = {"requestId": "unknown", "error": f"Failed to parse JSON request: {e}"} + response = { + "requestId": "unknown", + "error": f"Failed to parse JSON request: {e}", + } sys.stdout.write(json.dumps(response) + "\n") sys.stdout.flush() return diff --git a/plugins/windows-explorer/test/test_windows_explorer.py b/plugins/windows-explorer/test/test_windows_explorer.py index b6d696b1..327b6abf 100644 --- a/plugins/windows-explorer/test/test_windows_explorer.py +++ b/plugins/windows-explorer/test/test_windows_explorer.py @@ -45,7 +45,9 @@ def test_apply_config_no_changes_needed(self, mock_winreg, mock_read): @patch("plugin.winreg") def test_apply_config_changes_needed(self, mock_winreg, mock_read): mock_read.return_value = {"HideFileExt": 0, "Hidden": 1, "ShowSuperHidden": 1} - args = {"settings": {"HideFileExt": True, "Hidden": 2, "ShowSuperHidden": False}} + args = { + "settings": {"HideFileExt": True, "Hidden": 2, "ShowSuperHidden": False} + } mock_key = MagicMock() mock_winreg.OpenKey.return_value.__enter__.return_value = mock_key @@ -53,9 +55,15 @@ def test_apply_config_changes_needed(self, mock_winreg, mock_read): self.assertEqual(result, {"changed": True}) self.assertEqual(mock_winreg.SetValueEx.call_count, 3) - mock_winreg.SetValueEx.assert_any_call(mock_key, "HideFileExt", 0, mock_winreg.REG_DWORD, 1) - mock_winreg.SetValueEx.assert_any_call(mock_key, "Hidden", 0, mock_winreg.REG_DWORD, 2) - mock_winreg.SetValueEx.assert_any_call(mock_key, "ShowSuperHidden", 0, mock_winreg.REG_DWORD, 0) + mock_winreg.SetValueEx.assert_any_call( + mock_key, "HideFileExt", 0, mock_winreg.REG_DWORD, 1 + ) + mock_winreg.SetValueEx.assert_any_call( + mock_key, "Hidden", 0, mock_winreg.REG_DWORD, 2 + ) + mock_winreg.SetValueEx.assert_any_call( + mock_key, "ShowSuperHidden", 0, mock_winreg.REG_DWORD, 0 + ) @patch("plugin.read_registry_values") @patch("plugin.winreg") @@ -78,7 +86,9 @@ def test_apply_config_invalid_hidden(self, mock_winreg, mock_read): result = plugin.apply_config(args) - self.assertEqual(result, {"error": "Invalid value for Hidden: 3. Must be 1 or 2."}) + self.assertEqual( + result, {"error": "Invalid value for Hidden: 3. Must be 1 or 2."} + ) @patch("plugin.read_registry_values") @patch("plugin.winreg") diff --git a/plugins/windows-sandbox/src/plugin.py b/plugins/windows-sandbox/src/plugin.py index 4bdf7bab..1e57ef17 100644 --- a/plugins/windows-sandbox/src/plugin.py +++ b/plugins/windows-sandbox/src/plugin.py @@ -120,7 +120,9 @@ def merge_settings(root: ET.Element, settings: dict) -> bool: { "hostFolder": host.text if host is not None else "", "readOnly": ( - readonly.text.lower() == "true" if readonly is not None and readonly.text else False + readonly.text.lower() == "true" + if readonly is not None and readonly.text + else False ), } ) diff --git a/plugins/windows-sandbox/test/test_windows_sandbox.py b/plugins/windows-sandbox/test/test_windows_sandbox.py index cd1be299..4b12df52 100644 --- a/plugins/windows-sandbox/test/test_windows_sandbox.py +++ b/plugins/windows-sandbox/test/test_windows_sandbox.py @@ -4,7 +4,9 @@ import sys import tempfile -PLUGIN = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "src", "plugin.py")) +PLUGIN = os.path.abspath( + os.path.join(os.path.dirname(__file__), "..", "src", "plugin.py") +) def run_plugin(payload: dict) -> dict: diff --git a/plugins/winget/src/plugin.py b/plugins/winget/src/plugin.py index 9e72c6c8..7a2cb5f9 100644 --- a/plugins/winget/src/plugin.py +++ b/plugins/winget/src/plugin.py @@ -70,7 +70,9 @@ def deep_merge(target: dict, source: dict) -> bool: def check_installed(args: dict, request_id: str) -> dict: - installed = shutil.which("winget.exe") is not None or shutil.which("winget") is not None + installed = ( + shutil.which("winget.exe") is not None or shutil.which("winget") is not None + ) return { "requestId": request_id, "success": True, diff --git a/plugins/yarn/src/plugin.py b/plugins/yarn/src/plugin.py index ece642c3..6145a861 100644 --- a/plugins/yarn/src/plugin.py +++ b/plugins/yarn/src/plugin.py @@ -21,7 +21,26 @@ def _yaml_quote_string(value: str) -> str: s == "" or any( ch in s - for ch in [":", "#", "{", "}", "[", "]", "&", "*", "!", "|", ">", "?", "-", "@", ",", "\n", "\r", "\t"] + for ch in [ + ":", + "#", + "{", + "}", + "[", + "]", + "&", + "*", + "!", + "|", + ">", + "?", + "-", + "@", + ",", + "\n", + "\r", + "\t", + ] ) or s.startswith(("{", "[", "*", "&", "!", "|", ">", "-", "?", "@")) ): @@ -135,7 +154,9 @@ def _atomic_write(path: str, content: str): def check_installed(args: dict) -> bool: paths = get_yarnrc_paths() found_yarn = ( - shutil.which("yarn.cmd") is not None or shutil.which("yarn.exe") is not None or shutil.which("yarn") is not None + shutil.which("yarn.cmd") is not None + or shutil.which("yarn.exe") is not None + or shutil.which("yarn") is not None ) cfg_exists = os.path.exists(paths["berry"]) or os.path.exists(paths["classic"]) @@ -195,7 +216,9 @@ def _validate_and_normalize_settings(settings_raw: object): return settings -def _apply_berry(berry_path: str, settings: dict, dry_run: bool, request_id: str) -> dict: +def _apply_berry( + berry_path: str, settings: dict, dry_run: bool, request_id: str +) -> dict: existing = {} if os.path.exists(berry_path): try: @@ -229,7 +252,9 @@ def _apply_berry(berry_path: str, settings: dict, dry_run: bool, request_id: str } -def _apply_classic(classic_path: str, settings: dict, dry_run: bool, request_id: str) -> dict: +def _apply_classic( + classic_path: str, settings: dict, dry_run: bool, request_id: str +) -> dict: existing = {} if os.path.exists(classic_path): try: @@ -278,9 +303,13 @@ def apply_config(args: dict, request_id: str) -> dict: classic_exists = os.path.exists(paths["classic"]) if berry_exists or (not classic_exists): - return _apply_berry(paths["berry"], settings, dry_run=dry_run, request_id=request_id) + return _apply_berry( + paths["berry"], settings, dry_run=dry_run, request_id=request_id + ) - return _apply_classic(paths["classic"], settings, dry_run=dry_run, request_id=request_id) + return _apply_classic( + paths["classic"], settings, dry_run=dry_run, request_id=request_id + ) except Exception as e: log_error(f"Failed to apply config: {e}") diff --git a/plugins/yarn/test/test_yarn.py b/plugins/yarn/test/test_yarn.py index 4596520d..c3e89207 100644 --- a/plugins/yarn/test/test_yarn.py +++ b/plugins/yarn/test/test_yarn.py @@ -107,7 +107,9 @@ def test_apply_classic_prefers_classic_if_present(mock_home, tmp_path): mock_home.return_value = str(tmp_path) # Create classic file - (tmp_path / ".yarnrc").write_text("npmRegistryServer https://example.com\n", encoding="utf-8") + (tmp_path / ".yarnrc").write_text( + "npmRegistryServer https://example.com\n", encoding="utf-8" + ) request = { "requestId": "r4", diff --git a/plugins/yasb/src/plugin.py b/plugins/yasb/src/plugin.py index c61cfd91..4e56d095 100644 --- a/plugins/yasb/src/plugin.py +++ b/plugins/yasb/src/plugin.py @@ -52,9 +52,13 @@ def read_yaml(file_path: str) -> dict: try: shutil.copy2(file_path, backup_path) - log(f"Warning: could not parse {file_path}: {error}. Backed up to {backup_path} and starting fresh.") + log( + f"Warning: could not parse {file_path}: {error}. Backed up to {backup_path} and starting fresh." + ) except Exception as backup_error: - log(f"Warning: could not parse {file_path}: {error}. Failed to back it up: {backup_error}. Starting fresh.") + log( + f"Warning: could not parse {file_path}: {error}. Failed to back it up: {backup_error}. Starting fresh." + ) return {} @@ -62,7 +66,9 @@ def read_yaml(file_path: str) -> dict: def write_yaml(file_path: str, data: dict) -> None: os.makedirs(os.path.dirname(file_path), exist_ok=True) - temp_fd, temp_path = tempfile.mkstemp(prefix="yasb-", dir=os.path.dirname(file_path)) + temp_fd, temp_path = tempfile.mkstemp( + prefix="yasb-", dir=os.path.dirname(file_path) + ) try: with os.fdopen(temp_fd, "w", encoding="utf-8") as file_handle: yaml.safe_dump(data, file_handle, default_flow_style=False, sort_keys=False) @@ -109,7 +115,9 @@ def merge_settings(target: dict, source: dict) -> bool: def check_installed(request_id: str) -> dict: installed = ( - shutil.which("yasb") is not None or shutil.which("yasb.exe") is not None or os.path.isdir(get_config_dir()) + shutil.which("yasb") is not None + or shutil.which("yasb.exe") is not None + or os.path.isdir(get_config_dir()) ) return { @@ -139,7 +147,11 @@ def apply_config(request_id: str, args: dict, context: dict) -> dict: changed = merge_settings(updated_config, settings) if dry_run: - log(f"Would update {config_path}" if changed else f"No changes for {config_path}") + log( + f"Would update {config_path}" + if changed + else f"No changes for {config_path}" + ) return { "requestId": request_id, "success": True, diff --git a/plugins/yasb/test/test_yasb.py b/plugins/yasb/test/test_yasb.py index 8955df66..ad64d128 100644 --- a/plugins/yasb/test/test_yasb.py +++ b/plugins/yasb/test/test_yasb.py @@ -1,253 +1,307 @@ -#!/usr/bin/env python3 -# /// script -# dependencies = [ -# "pyyaml", -# ] -# /// - -import json -import os -import sys -import tempfile -import unittest -from io import StringIO -from unittest.mock import patch - -import yaml - -_SRC_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "src")) -sys.path.append(_SRC_DIR) -try: - import plugin -finally: - sys.path.remove(_SRC_DIR) - - -class TestYasbPlugin(unittest.TestCase): - def run_main(self, payload: dict) -> dict: - stdin = StringIO(json.dumps(payload) + "\n") - stdout = StringIO() - - with patch("sys.stdin", stdin), patch("sys.stdout", stdout): - plugin.main() - - return json.loads(stdout.getvalue().strip()) - - def test_check_installed_true_via_path(self): - with patch("plugin.shutil.which", side_effect=lambda name: "C:/Tools/yasb.exe" if name == "yasb" else None): - response = self.run_main({"requestId": "req-1", "command": "check_installed", "args": {}, "context": {}}) - - self.assertTrue(response["success"]) - self.assertFalse(response["changed"]) - self.assertTrue(response["data"]) - - def test_check_installed_true_via_config_dir(self): - with tempfile.TemporaryDirectory() as tmp_dir: - config_dir = os.path.join(tmp_dir, ".config", "yasb") - os.makedirs(config_dir, exist_ok=True) - - with patch.dict(os.environ, {"USERPROFILE": tmp_dir}), patch("plugin.shutil.which", return_value=None): - response = self.run_main( - {"requestId": "req-2", "command": "check_installed", "args": {}, "context": {}} - ) - - self.assertTrue(response["success"]) - self.assertTrue(response["data"]) - - def test_apply_merges_bars_without_overwriting_existing_config(self): - with tempfile.TemporaryDirectory() as tmp_dir: - config_path = os.path.join(tmp_dir, ".config", "yasb", "config.yaml") - initial = { - "watch_stylesheet": False, - "bars": { - "status-bar": { - "enabled": False, - "screens": ["*"], - "alignment": {"position": "bottom", "center": True}, - "window_flags": {"always_on_top": True, "windows_app_bar": False}, - "dimensions": {"width": "100%", "height": 30}, - "padding": {"top": 2, "left": 8, "bottom": 2, "right": 8}, - "widgets": { - "left": ["launcher"], - "center": ["date"], - "right": ["network"], - }, - }, - "secondary-bar": { - "enabled": True, - "widgets": {"left": ["custom"]}, - }, - }, - } - - os.makedirs(os.path.dirname(config_path), exist_ok=True) - - with open(config_path, "w", encoding="utf-8") as file_handle: - yaml.safe_dump(initial, file_handle, default_flow_style=False, sort_keys=False) - - payload = { - "requestId": "req-3", - "command": "apply", - "args": { - "settings": { - "bars": { - "status-bar": { - "enabled": True, - "alignment": {"position": "top", "center": False}, - "widgets": { - "left": ["workspaces", "active_window"], - "right": ["cpu", "memory", "volume", "battery"], - }, - }, - "music-bar": { - "enabled": True, - "screens": ["primary"], - "widgets": {"center": ["now_playing"]}, - }, - }, - "watch_stylesheet": True, - "watch_config": True, - "debug": False, - }, - }, - "context": {"dryRun": False}, - } - - with patch.dict(os.environ, {"USERPROFILE": tmp_dir}): - response = self.run_main(payload) - - self.assertTrue(response["success"]) - self.assertTrue(response["changed"]) - - with open(config_path, "r", encoding="utf-8") as file_handle: - content = yaml.safe_load(file_handle) - - self.assertTrue(content["watch_stylesheet"]) - self.assertTrue(content["watch_config"]) - self.assertFalse(content["debug"]) - self.assertTrue(content["bars"]["status-bar"]["enabled"]) - self.assertEqual(content["bars"]["status-bar"]["alignment"]["position"], "top") - self.assertFalse(content["bars"]["status-bar"]["alignment"]["center"]) - self.assertEqual(content["bars"]["status-bar"]["widgets"]["left"], ["workspaces", "active_window"]) - self.assertEqual(content["bars"]["status-bar"]["widgets"]["center"], ["date"]) - self.assertEqual(content["bars"]["status-bar"]["widgets"]["right"], ["cpu", "memory", "volume", "battery"]) - self.assertIn("secondary-bar", content["bars"]) - self.assertIn("music-bar", content["bars"]) - - def test_apply_dry_run_does_not_write(self): - with tempfile.TemporaryDirectory() as tmp_dir: - payload = { - "requestId": "req-4", - "command": "apply", - "args": {"settings": {"bars": {"status-bar": {"enabled": True, "widgets": {"left": ["workspaces"]}}}}}, - "context": {"dryRun": True}, - } - - with patch.dict(os.environ, {"USERPROFILE": tmp_dir}): - response = self.run_main(payload) - - config_path = os.path.join(tmp_dir, ".config", "yasb", "config.yaml") - self.assertTrue(response["success"]) - self.assertTrue(response["changed"]) - self.assertTrue(response["data"]["wouldChange"]) - self.assertFalse(os.path.exists(config_path)) - - def test_apply_returns_null_data_on_success(self): - with tempfile.TemporaryDirectory() as tmp_dir: - payload = { - "requestId": "req-4b", - "command": "apply", - "args": {"settings": {"watch_config": True}}, - "context": {"dryRun": False}, - } - - with patch.dict(os.environ, {"USERPROFILE": tmp_dir}): - response = self.run_main(payload) - - self.assertTrue(response["success"]) - self.assertIn("data", response) - self.assertIsNone(response["data"]) - - def test_apply_creates_missing_config_directory(self): - with tempfile.TemporaryDirectory() as tmp_dir: - payload = { - "requestId": "req-5", - "command": "apply", - "args": {"settings": {"bars": {"status-bar": {"enabled": True, "widgets": {"left": ["workspaces"]}}}}}, - "context": {"dryRun": False}, - } - - with patch.dict(os.environ, {"USERPROFILE": tmp_dir}): - response = self.run_main(payload) - - config_dir = os.path.join(tmp_dir, ".config", "yasb") - config_path = os.path.join(config_dir, "config.yaml") - - self.assertTrue(response["success"]) - self.assertTrue(response["changed"]) - self.assertTrue(os.path.isdir(config_dir)) - self.assertTrue(os.path.exists(config_path)) - - def test_apply_backups_corrupted_yaml_before_replacing(self): - with tempfile.TemporaryDirectory() as tmp_dir: - config_path = os.path.join(tmp_dir, ".config", "yasb", "config.yaml") - os.makedirs(os.path.dirname(config_path), exist_ok=True) - - with open(config_path, "w", encoding="utf-8") as file_handle: - file_handle.write("bars: [\n") - - payload = { - "requestId": "req-7", - "command": "apply", - "args": {"settings": {"watch_config": True}}, - "context": {"dryRun": False}, - } - - with patch.dict(os.environ, {"USERPROFILE": tmp_dir}): - response = self.run_main(payload) - - backup_dir = os.path.dirname(config_path) - backups = [ - name for name in os.listdir(backup_dir) if name.startswith("config.yaml.") and name.endswith(".bak") - ] - - self.assertTrue(response["success"]) - self.assertTrue(response["changed"]) - self.assertEqual(len(backups), 1) - - def test_main_returns_json_error_on_bad_input(self): - stdin = StringIO("{") - stdout = StringIO() - - with patch("sys.stdin", stdin), patch("sys.stdout", stdout): - plugin.main() - - response = json.loads(stdout.getvalue().strip()) - - self.assertFalse(response["success"]) - self.assertEqual(response["requestId"], "unknown") - self.assertIn("Failed to parse request", response["error"]) - - def test_main_returns_json_error_on_empty_input(self): - stdin = StringIO("") - stdout = StringIO() - - with patch("sys.stdin", stdin), patch("sys.stdout", stdout): - plugin.main() - - response = json.loads(stdout.getvalue().strip()) - - self.assertFalse(response["success"]) - self.assertEqual(response["requestId"], "unknown") - self.assertIn("empty stdin", response["error"]) - - def test_unknown_command(self): - response = self.run_main({"requestId": "req-6", "command": "explode", "args": {}, "context": {}}) - - self.assertFalse(response["success"]) - self.assertIn("error", response) - self.assertIsNone(response["data"]) - - -if __name__ == "__main__": - unittest.main() +#!/usr/bin/env python3 +# /// script +# dependencies = [ +# "pyyaml", +# ] +# /// + +import json +import os +import sys +import tempfile +import unittest +from io import StringIO +from unittest.mock import patch + +import yaml + +_SRC_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "src")) +sys.path.append(_SRC_DIR) +try: + import plugin +finally: + sys.path.remove(_SRC_DIR) + + +class TestYasbPlugin(unittest.TestCase): + def run_main(self, payload: dict) -> dict: + stdin = StringIO(json.dumps(payload) + "\n") + stdout = StringIO() + + with patch("sys.stdin", stdin), patch("sys.stdout", stdout): + plugin.main() + + return json.loads(stdout.getvalue().strip()) + + def test_check_installed_true_via_path(self): + with patch( + "plugin.shutil.which", + side_effect=lambda name: "C:/Tools/yasb.exe" if name == "yasb" else None, + ): + response = self.run_main( + { + "requestId": "req-1", + "command": "check_installed", + "args": {}, + "context": {}, + } + ) + + self.assertTrue(response["success"]) + self.assertFalse(response["changed"]) + self.assertTrue(response["data"]) + + def test_check_installed_true_via_config_dir(self): + with tempfile.TemporaryDirectory() as tmp_dir: + config_dir = os.path.join(tmp_dir, ".config", "yasb") + os.makedirs(config_dir, exist_ok=True) + + with patch.dict(os.environ, {"USERPROFILE": tmp_dir}), patch( + "plugin.shutil.which", return_value=None + ): + response = self.run_main( + { + "requestId": "req-2", + "command": "check_installed", + "args": {}, + "context": {}, + } + ) + + self.assertTrue(response["success"]) + self.assertTrue(response["data"]) + + def test_apply_merges_bars_without_overwriting_existing_config(self): + with tempfile.TemporaryDirectory() as tmp_dir: + config_path = os.path.join(tmp_dir, ".config", "yasb", "config.yaml") + initial = { + "watch_stylesheet": False, + "bars": { + "status-bar": { + "enabled": False, + "screens": ["*"], + "alignment": {"position": "bottom", "center": True}, + "window_flags": { + "always_on_top": True, + "windows_app_bar": False, + }, + "dimensions": {"width": "100%", "height": 30}, + "padding": {"top": 2, "left": 8, "bottom": 2, "right": 8}, + "widgets": { + "left": ["launcher"], + "center": ["date"], + "right": ["network"], + }, + }, + "secondary-bar": { + "enabled": True, + "widgets": {"left": ["custom"]}, + }, + }, + } + + os.makedirs(os.path.dirname(config_path), exist_ok=True) + + with open(config_path, "w", encoding="utf-8") as file_handle: + yaml.safe_dump( + initial, file_handle, default_flow_style=False, sort_keys=False + ) + + payload = { + "requestId": "req-3", + "command": "apply", + "args": { + "settings": { + "bars": { + "status-bar": { + "enabled": True, + "alignment": {"position": "top", "center": False}, + "widgets": { + "left": ["workspaces", "active_window"], + "right": ["cpu", "memory", "volume", "battery"], + }, + }, + "music-bar": { + "enabled": True, + "screens": ["primary"], + "widgets": {"center": ["now_playing"]}, + }, + }, + "watch_stylesheet": True, + "watch_config": True, + "debug": False, + }, + }, + "context": {"dryRun": False}, + } + + with patch.dict(os.environ, {"USERPROFILE": tmp_dir}): + response = self.run_main(payload) + + self.assertTrue(response["success"]) + self.assertTrue(response["changed"]) + + with open(config_path, "r", encoding="utf-8") as file_handle: + content = yaml.safe_load(file_handle) + + self.assertTrue(content["watch_stylesheet"]) + self.assertTrue(content["watch_config"]) + self.assertFalse(content["debug"]) + self.assertTrue(content["bars"]["status-bar"]["enabled"]) + self.assertEqual( + content["bars"]["status-bar"]["alignment"]["position"], "top" + ) + self.assertFalse(content["bars"]["status-bar"]["alignment"]["center"]) + self.assertEqual( + content["bars"]["status-bar"]["widgets"]["left"], + ["workspaces", "active_window"], + ) + self.assertEqual( + content["bars"]["status-bar"]["widgets"]["center"], ["date"] + ) + self.assertEqual( + content["bars"]["status-bar"]["widgets"]["right"], + ["cpu", "memory", "volume", "battery"], + ) + self.assertIn("secondary-bar", content["bars"]) + self.assertIn("music-bar", content["bars"]) + + def test_apply_dry_run_does_not_write(self): + with tempfile.TemporaryDirectory() as tmp_dir: + payload = { + "requestId": "req-4", + "command": "apply", + "args": { + "settings": { + "bars": { + "status-bar": { + "enabled": True, + "widgets": {"left": ["workspaces"]}, + } + } + } + }, + "context": {"dryRun": True}, + } + + with patch.dict(os.environ, {"USERPROFILE": tmp_dir}): + response = self.run_main(payload) + + config_path = os.path.join(tmp_dir, ".config", "yasb", "config.yaml") + self.assertTrue(response["success"]) + self.assertTrue(response["changed"]) + self.assertTrue(response["data"]["wouldChange"]) + self.assertFalse(os.path.exists(config_path)) + + def test_apply_returns_null_data_on_success(self): + with tempfile.TemporaryDirectory() as tmp_dir: + payload = { + "requestId": "req-4b", + "command": "apply", + "args": {"settings": {"watch_config": True}}, + "context": {"dryRun": False}, + } + + with patch.dict(os.environ, {"USERPROFILE": tmp_dir}): + response = self.run_main(payload) + + self.assertTrue(response["success"]) + self.assertIn("data", response) + self.assertIsNone(response["data"]) + + def test_apply_creates_missing_config_directory(self): + with tempfile.TemporaryDirectory() as tmp_dir: + payload = { + "requestId": "req-5", + "command": "apply", + "args": { + "settings": { + "bars": { + "status-bar": { + "enabled": True, + "widgets": {"left": ["workspaces"]}, + } + } + } + }, + "context": {"dryRun": False}, + } + + with patch.dict(os.environ, {"USERPROFILE": tmp_dir}): + response = self.run_main(payload) + + config_dir = os.path.join(tmp_dir, ".config", "yasb") + config_path = os.path.join(config_dir, "config.yaml") + + self.assertTrue(response["success"]) + self.assertTrue(response["changed"]) + self.assertTrue(os.path.isdir(config_dir)) + self.assertTrue(os.path.exists(config_path)) + + def test_apply_backups_corrupted_yaml_before_replacing(self): + with tempfile.TemporaryDirectory() as tmp_dir: + config_path = os.path.join(tmp_dir, ".config", "yasb", "config.yaml") + os.makedirs(os.path.dirname(config_path), exist_ok=True) + + with open(config_path, "w", encoding="utf-8") as file_handle: + file_handle.write("bars: [\n") + + payload = { + "requestId": "req-7", + "command": "apply", + "args": {"settings": {"watch_config": True}}, + "context": {"dryRun": False}, + } + + with patch.dict(os.environ, {"USERPROFILE": tmp_dir}): + response = self.run_main(payload) + + backup_dir = os.path.dirname(config_path) + backups = [ + name + for name in os.listdir(backup_dir) + if name.startswith("config.yaml.") and name.endswith(".bak") + ] + + self.assertTrue(response["success"]) + self.assertTrue(response["changed"]) + self.assertEqual(len(backups), 1) + + def test_main_returns_json_error_on_bad_input(self): + stdin = StringIO("{") + stdout = StringIO() + + with patch("sys.stdin", stdin), patch("sys.stdout", stdout): + plugin.main() + + response = json.loads(stdout.getvalue().strip()) + + self.assertFalse(response["success"]) + self.assertEqual(response["requestId"], "unknown") + self.assertIn("Failed to parse request", response["error"]) + + def test_main_returns_json_error_on_empty_input(self): + stdin = StringIO("") + stdout = StringIO() + + with patch("sys.stdin", stdin), patch("sys.stdout", stdout): + plugin.main() + + response = json.loads(stdout.getvalue().strip()) + + self.assertFalse(response["success"]) + self.assertEqual(response["requestId"], "unknown") + self.assertIn("empty stdin", response["error"]) + + def test_unknown_command(self): + response = self.run_main( + {"requestId": "req-6", "command": "explode", "args": {}, "context": {}} + ) + + self.assertFalse(response["success"]) + self.assertIn("error", response) + self.assertIsNone(response["data"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/plugins/yazi/test/test_yazi.py b/plugins/yazi/test/test_yazi.py index 87b60772..0806994a 100644 --- a/plugins/yazi/test/test_yazi.py +++ b/plugins/yazi/test/test_yazi.py @@ -12,7 +12,9 @@ except ImportError: tomllib = None -PLUGIN = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "src", "plugin.py")) +PLUGIN = os.path.abspath( + os.path.join(os.path.dirname(__file__), "..", "src", "plugin.py") +) def run_plugin(payload: dict) -> dict: @@ -85,7 +87,11 @@ def test_apply_config_routing_and_write(): "command": "apply", "args": { "manager": {"show_hidden": True}, - "keymap": {"prepend_keymap": [{"on": ["g", "h"], "run": "cd ~", "desc": "Go home"}]}, + "keymap": { + "prepend_keymap": [ + {"on": ["g", "h"], "run": "cd ~", "desc": "Go home"} + ] + }, "theme": {"type": "catppuccin-mocha"}, }, "context": {"dryRun": False}, @@ -138,7 +144,9 @@ def test_idempotent_apply(): def test_unknown_command(): - res = run_plugin({"requestId": "5", "command": "explode", "args": {}, "context": {}}) + res = run_plugin( + {"requestId": "5", "command": "explode", "args": {}, "context": {}} + ) assert not res["success"] assert "error" in res diff --git a/plugins/zed/src/plugin.py b/plugins/zed/src/plugin.py index 83232d76..17b30ffa 100644 --- a/plugins/zed/src/plugin.py +++ b/plugins/zed/src/plugin.py @@ -1,319 +1,337 @@ -import copy -import json -import os -import shutil -import sys -import tempfile -import uuid -from pathlib import Path - -PLUGIN_NAME = "zed" -SETTINGS_FILE = "settings.json" -NON_SETTING_ARG_KEYS = { - "configPath", - "config_path", -} -INT_SETTING_KEYS = { - "buffer_font_size", - "font_size", - "tab_size", -} -BOOL_SETTING_KEYS = { - "vim_mode", - "relative_line_numbers", - "copilot", -} - - -def log(message: str) -> None: - sys.stderr.write(f"[{PLUGIN_NAME}-plugin] {message}\n") - sys.stderr.flush() - - -def response(request_id: str, success: bool, changed: bool, error=None, data=None) -> dict: - result = { - "requestId": request_id, - "success": success, - "changed": changed, - } - - if error is not None: - result["error"] = error - - if data is not None: - result["data"] = data - - return result - - -def strip_jsonc_comments(text: str) -> str: - output = [] - in_string = False - escaped = False - index = 0 - - while index < len(text): - char = text[index] - next_char = text[index + 1] if index + 1 < len(text) else "" - - if in_string: - output.append(char) - if escaped: - escaped = False - elif char == "\\": - escaped = True - elif char == '"': - in_string = False - index += 1 - continue - - if char == '"': - in_string = True - output.append(char) - index += 1 - continue - - if char == "/" and next_char == "/": - while index < len(text) and text[index] not in "\r\n": - index += 1 - continue - - if char == "/" and next_char == "*": - index += 2 - while index < len(text): - if text[index] == "*" and index + 1 < len(text) and text[index + 1] == "/": - index += 2 - break - index += 1 - continue - - output.append(char) - index += 1 - - return "".join(output) - - -def appdata_dir() -> str: - appdata = os.getenv("APPDATA") - if appdata: - return appdata - - userprofile = os.getenv("USERPROFILE") - if userprofile: - return os.path.join(userprofile, "AppData", "Roaming") - - return str(Path.home() / "AppData" / "Roaming") - - -def expand_path(path: str) -> str: - return os.path.abspath(os.path.expandvars(os.path.expanduser(path))) - - -def get_config_path(args: dict, context: dict) -> str: - explicit_path = ( - args.get("configPath") or args.get("config_path") or context.get("configPath") or context.get("config_path") - ) - - if explicit_path: - return expand_path(str(explicit_path)) - - return os.path.join(appdata_dir(), "Zed", SETTINGS_FILE) - - -def read_jsonc(file_path: str) -> dict: - if not os.path.exists(file_path): - return {} - - try: - with open(file_path, "r", encoding="utf-8") as config_file: - text = config_file.read() - - if not text.strip(): - return {} - - parsed = json.loads(strip_jsonc_comments(text)) - if isinstance(parsed, dict): - return parsed - - log(f"Warning: expected object in {file_path}, got {type(parsed).__name__}") - return {} - except Exception as exc: - log(f"Warning: could not parse {file_path}: {exc}") - backup_corrupt_file(file_path) - return {} - - -def backup_corrupt_file(file_path: str) -> None: - backup_path = f"{file_path}.corrupt-{uuid.uuid4()}.bak" - try: - shutil.copy2(file_path, backup_path) - log(f"Backed up unreadable config to: {backup_path}") - except Exception as exc: - log(f"Warning: could not back up unreadable config: {exc}") - - -def write_json(file_path: str, data: dict) -> None: - os.makedirs(os.path.dirname(file_path), exist_ok=True) - temp_fd, temp_path = tempfile.mkstemp( - prefix="zed-", - suffix=".tmp", - dir=os.path.dirname(file_path), - ) - - try: - with os.fdopen(temp_fd, "w", encoding="utf-8") as config_file: - json.dump(data, config_file, indent=2) - config_file.write("\n") - - os.replace(temp_path, file_path) - except Exception: - if os.path.exists(temp_path): - os.remove(temp_path) - raise - - -def desired_config_from_args(args: dict) -> dict: - if not isinstance(args, dict): - return {} - - if "settings" in args and isinstance(args["settings"], dict): - return normalize_config(copy.deepcopy(args["settings"])) - - desired = {key: copy.deepcopy(value) for key, value in args.items() if key not in NON_SETTING_ARG_KEYS} - return normalize_config(desired) - - -def normalize_scalar(key: str, value): - if not isinstance(value, str): - return value - - stripped = value.strip() - lowered = stripped.lower() - - if key in BOOL_SETTING_KEYS or lowered in {"true", "false"}: - if lowered == "true": - return True - if lowered == "false": - return False - - if key in INT_SETTING_KEYS: - try: - return int(stripped) - except ValueError: - return value - - return value - - -def normalize_config(config: dict) -> dict: - normalized = {} - - for key, value in config.items(): - if isinstance(value, dict): - normalized[key] = normalize_config(value) - elif isinstance(value, list): - normalized[key] = [normalize_config(item) if isinstance(item, dict) else item for item in value] - else: - normalized[key] = normalize_scalar(key, value) - - return normalized - - -def deep_merge(target: dict, source: dict) -> bool: - changed = False - - for key, value in source.items(): - if isinstance(value, dict) and isinstance(target.get(key), dict): - changed = deep_merge(target[key], value) or changed - continue - - if target.get(key) != value: - target[key] = copy.deepcopy(value) - changed = True - - return changed - - -def check_installed(args: dict, request_id: str) -> dict: - installed = shutil.which("zed.exe") is not None or shutil.which("zed") is not None - return response( - request_id, - success=True, - changed=False, - data=installed, - ) - - -def apply_config(args: dict, context: dict, request_id: str) -> dict: - dry_run = bool(context.get("dryRun", False)) - - try: - config_path = get_config_path(args, context) - desired = desired_config_from_args(args) - current = read_jsonc(config_path) - next_config = copy.deepcopy(current) - changed = deep_merge(next_config, desired) - - if not changed: - return response(request_id, success=True, changed=False) - - if dry_run: - log(f"Would update {config_path} with keys: {', '.join(sorted(desired.keys()))}") - return response(request_id, success=True, changed=True) - - write_json(config_path, next_config) - log(f"Updated Zed config: {config_path}") - - return response(request_id, success=True, changed=True) - - except Exception as exc: - log(f"Failed to apply config: {exc}") - return response(request_id, success=False, changed=False, error=str(exc)) - - -def process_request(request: dict) -> dict: - request_id = request.get("requestId", "unknown") - command = request.get("command") - args = request.get("args", request.get("config", {})) - context = request.get("context", {}) - - if "dryRun" in request and "dryRun" not in context: - context = dict(context) - context["dryRun"] = request.get("dryRun") - - if command == "check_installed": - return check_installed(args, request_id) - - if command == "apply": - return apply_config(args, context, request_id) - - return response( - request_id, - success=False, - changed=False, - error=f"Unknown command: {command}", - ) - - -def main() -> None: - input_data = sys.stdin.read() - - if not input_data: - result = response("unknown", success=False, changed=False, error="Empty input") - sys.stdout.write(json.dumps(result) + "\n") - sys.stdout.flush() - return - - try: - request = json.loads(input_data) - result = process_request(request) - except Exception as exc: - log(f"Internal Script Error: {exc}") - result = response("unknown", success=False, changed=False, error=str(exc)) - - sys.stdout.write(json.dumps(result) + "\n") - sys.stdout.flush() - - -if __name__ == "__main__": - main() +import copy +import json +import os +import shutil +import sys +import tempfile +import uuid +from pathlib import Path + +PLUGIN_NAME = "zed" +SETTINGS_FILE = "settings.json" +NON_SETTING_ARG_KEYS = { + "configPath", + "config_path", +} +INT_SETTING_KEYS = { + "buffer_font_size", + "font_size", + "tab_size", +} +BOOL_SETTING_KEYS = { + "vim_mode", + "relative_line_numbers", + "copilot", +} + + +def log(message: str) -> None: + sys.stderr.write(f"[{PLUGIN_NAME}-plugin] {message}\n") + sys.stderr.flush() + + +def response( + request_id: str, success: bool, changed: bool, error=None, data=None +) -> dict: + result = { + "requestId": request_id, + "success": success, + "changed": changed, + } + + if error is not None: + result["error"] = error + + if data is not None: + result["data"] = data + + return result + + +def strip_jsonc_comments(text: str) -> str: + output = [] + in_string = False + escaped = False + index = 0 + + while index < len(text): + char = text[index] + next_char = text[index + 1] if index + 1 < len(text) else "" + + if in_string: + output.append(char) + if escaped: + escaped = False + elif char == "\\": + escaped = True + elif char == '"': + in_string = False + index += 1 + continue + + if char == '"': + in_string = True + output.append(char) + index += 1 + continue + + if char == "/" and next_char == "/": + while index < len(text) and text[index] not in "\r\n": + index += 1 + continue + + if char == "/" and next_char == "*": + index += 2 + while index < len(text): + if ( + text[index] == "*" + and index + 1 < len(text) + and text[index + 1] == "/" + ): + index += 2 + break + index += 1 + continue + + output.append(char) + index += 1 + + return "".join(output) + + +def appdata_dir() -> str: + appdata = os.getenv("APPDATA") + if appdata: + return appdata + + userprofile = os.getenv("USERPROFILE") + if userprofile: + return os.path.join(userprofile, "AppData", "Roaming") + + return str(Path.home() / "AppData" / "Roaming") + + +def expand_path(path: str) -> str: + return os.path.abspath(os.path.expandvars(os.path.expanduser(path))) + + +def get_config_path(args: dict, context: dict) -> str: + explicit_path = ( + args.get("configPath") + or args.get("config_path") + or context.get("configPath") + or context.get("config_path") + ) + + if explicit_path: + return expand_path(str(explicit_path)) + + return os.path.join(appdata_dir(), "Zed", SETTINGS_FILE) + + +def read_jsonc(file_path: str) -> dict: + if not os.path.exists(file_path): + return {} + + try: + with open(file_path, "r", encoding="utf-8") as config_file: + text = config_file.read() + + if not text.strip(): + return {} + + parsed = json.loads(strip_jsonc_comments(text)) + if isinstance(parsed, dict): + return parsed + + log(f"Warning: expected object in {file_path}, got {type(parsed).__name__}") + return {} + except Exception as exc: + log(f"Warning: could not parse {file_path}: {exc}") + backup_corrupt_file(file_path) + return {} + + +def backup_corrupt_file(file_path: str) -> None: + backup_path = f"{file_path}.corrupt-{uuid.uuid4()}.bak" + try: + shutil.copy2(file_path, backup_path) + log(f"Backed up unreadable config to: {backup_path}") + except Exception as exc: + log(f"Warning: could not back up unreadable config: {exc}") + + +def write_json(file_path: str, data: dict) -> None: + os.makedirs(os.path.dirname(file_path), exist_ok=True) + temp_fd, temp_path = tempfile.mkstemp( + prefix="zed-", + suffix=".tmp", + dir=os.path.dirname(file_path), + ) + + try: + with os.fdopen(temp_fd, "w", encoding="utf-8") as config_file: + json.dump(data, config_file, indent=2) + config_file.write("\n") + + os.replace(temp_path, file_path) + except Exception: + if os.path.exists(temp_path): + os.remove(temp_path) + raise + + +def desired_config_from_args(args: dict) -> dict: + if not isinstance(args, dict): + return {} + + if "settings" in args and isinstance(args["settings"], dict): + return normalize_config(copy.deepcopy(args["settings"])) + + desired = { + key: copy.deepcopy(value) + for key, value in args.items() + if key not in NON_SETTING_ARG_KEYS + } + return normalize_config(desired) + + +def normalize_scalar(key: str, value): + if not isinstance(value, str): + return value + + stripped = value.strip() + lowered = stripped.lower() + + if key in BOOL_SETTING_KEYS or lowered in {"true", "false"}: + if lowered == "true": + return True + if lowered == "false": + return False + + if key in INT_SETTING_KEYS: + try: + return int(stripped) + except ValueError: + return value + + return value + + +def normalize_config(config: dict) -> dict: + normalized = {} + + for key, value in config.items(): + if isinstance(value, dict): + normalized[key] = normalize_config(value) + elif isinstance(value, list): + normalized[key] = [ + normalize_config(item) if isinstance(item, dict) else item + for item in value + ] + else: + normalized[key] = normalize_scalar(key, value) + + return normalized + + +def deep_merge(target: dict, source: dict) -> bool: + changed = False + + for key, value in source.items(): + if isinstance(value, dict) and isinstance(target.get(key), dict): + changed = deep_merge(target[key], value) or changed + continue + + if target.get(key) != value: + target[key] = copy.deepcopy(value) + changed = True + + return changed + + +def check_installed(args: dict, request_id: str) -> dict: + installed = shutil.which("zed.exe") is not None or shutil.which("zed") is not None + return response( + request_id, + success=True, + changed=False, + data=installed, + ) + + +def apply_config(args: dict, context: dict, request_id: str) -> dict: + dry_run = bool(context.get("dryRun", False)) + + try: + config_path = get_config_path(args, context) + desired = desired_config_from_args(args) + current = read_jsonc(config_path) + next_config = copy.deepcopy(current) + changed = deep_merge(next_config, desired) + + if not changed: + return response(request_id, success=True, changed=False) + + if dry_run: + log( + f"Would update {config_path} with keys: {', '.join(sorted(desired.keys()))}" + ) + return response(request_id, success=True, changed=True) + + write_json(config_path, next_config) + log(f"Updated Zed config: {config_path}") + + return response(request_id, success=True, changed=True) + + except Exception as exc: + log(f"Failed to apply config: {exc}") + return response(request_id, success=False, changed=False, error=str(exc)) + + +def process_request(request: dict) -> dict: + request_id = request.get("requestId", "unknown") + command = request.get("command") + args = request.get("args", request.get("config", {})) + context = request.get("context", {}) + + if "dryRun" in request and "dryRun" not in context: + context = dict(context) + context["dryRun"] = request.get("dryRun") + + if command == "check_installed": + return check_installed(args, request_id) + + if command == "apply": + return apply_config(args, context, request_id) + + return response( + request_id, + success=False, + changed=False, + error=f"Unknown command: {command}", + ) + + +def main() -> None: + input_data = sys.stdin.read() + + if not input_data: + result = response("unknown", success=False, changed=False, error="Empty input") + sys.stdout.write(json.dumps(result) + "\n") + sys.stdout.flush() + return + + try: + request = json.loads(input_data) + result = process_request(request) + except Exception as exc: + log(f"Internal Script Error: {exc}") + result = response("unknown", success=False, changed=False, error=str(exc)) + + sys.stdout.write(json.dumps(result) + "\n") + sys.stdout.flush() + + +if __name__ == "__main__": + main() diff --git a/plugins/zed/test/test_zed.py b/plugins/zed/test/test_zed.py index 163a7b0f..0372ac55 100644 --- a/plugins/zed/test/test_zed.py +++ b/plugins/zed/test/test_zed.py @@ -1,336 +1,338 @@ -import json -import os -import subprocess -import sys -import tempfile -from contextlib import contextmanager -from unittest import mock - -PLUGIN = os.path.abspath( - os.path.join( - os.path.dirname(__file__), - "..", - "src", - "plugin.py", - ) -) - - -def run_plugin(payload: dict) -> dict: - result = subprocess.run( - [sys.executable, PLUGIN], - input=json.dumps(payload), - capture_output=True, - text=True, - ) - - assert result.returncode == 0, result.stderr - return json.loads(result.stdout.strip()) - - -def read_settings(appdata: str) -> dict: - settings_path = os.path.join(appdata, "Zed", "settings.json") - with open(settings_path, "r", encoding="utf-8") as settings_file: - return json.load(settings_file) - - -@contextmanager -def temp_appdata(): - with tempfile.TemporaryDirectory() as tmp: - with mock.patch.dict(os.environ, {"APPDATA": tmp}): - yield tmp - - -def test_check_installed(): - res = run_plugin( - { - "requestId": "1", - "command": "check_installed", - "args": {}, - "context": {}, - } - ) - - assert res["success"] - assert not res["changed"] - assert isinstance(res["data"], bool) - - -def test_apply_creates_missing_directory_and_file(): - with temp_appdata() as tmp: - res = run_plugin( - { - "requestId": "2", - "command": "apply", - "args": { - "theme": { - "mode": "system", - "light": "One Light", - "dark": "One Dark", - }, - "buffer_font_family": "JetBrains Mono", - "buffer_font_size": 14, - "tab_size": 2, - "format_on_save": "on", - "vim_mode": False, - "relative_line_numbers": True, - "soft_wrap": "editor_width", - "cursor_shape": "bar", - "terminal": { - "font_family": "JetBrains Mono", - "font_size": 14, - "shell": "powershell.json", - }, - "languages": { - "Python": { - "tab_size": 4, - }, - "JavaScript": { - "tab_size": 2, - }, - }, - "features": { - "copilot": True, - }, - "auto_install_extensions": { - "html": True, - "dockerfile": True, - }, - }, - "context": { - "dryRun": False, - }, - } - ) - - assert res["success"] - assert res["changed"] - - settings = read_settings(tmp) - assert settings["theme"]["dark"] == "One Dark" - assert settings["buffer_font_family"] == "JetBrains Mono" - assert settings["languages"]["Python"]["tab_size"] == 4 - assert settings["features"]["copilot"] is True - - -def test_apply_deep_merges_existing_commented_settings(): - with temp_appdata() as tmp: - zed_dir = os.path.join(tmp, "Zed") - os.makedirs(zed_dir) - settings_path = os.path.join(zed_dir, "settings.json") - - with open(settings_path, "w", encoding="utf-8") as settings_file: - settings_file.write( - """ -{ - // Existing comments should not prevent parsing. - "theme": { - "mode": "dark", - "dark": "Ayu Dark" - }, - "languages": { - "Python": { - "formatter": "ruff", - "tab_size": 2 - } - }, - "project_panel": { - "dock": "left" - } -} -""" - ) - - res = run_plugin( - { - "requestId": "3", - "command": "apply", - "args": { - "theme": { - "mode": "system", - "light": "One Light", - }, - "languages": { - "Python": { - "tab_size": 4, - }, - }, - }, - "context": { - "dryRun": False, - }, - } - ) - - assert res["success"] - assert res["changed"] - - settings = read_settings(tmp) - assert settings["theme"]["mode"] == "system" - assert settings["theme"]["light"] == "One Light" - assert settings["theme"]["dark"] == "Ayu Dark" - assert settings["languages"]["Python"]["formatter"] == "ruff" - assert settings["languages"]["Python"]["tab_size"] == 4 - assert settings["project_panel"]["dock"] == "left" - - -def test_apply_backs_up_corrupt_settings_before_replacing(): - with temp_appdata() as tmp: - zed_dir = os.path.join(tmp, "Zed") - os.makedirs(zed_dir) - settings_path = os.path.join(zed_dir, "settings.json") - - with open(settings_path, "w", encoding="utf-8") as settings_file: - settings_file.write("{ invalid json") - - res = run_plugin( - { - "requestId": "4", - "command": "apply", - "args": { - "tab_size": 2, - }, - "context": { - "dryRun": False, - }, - } - ) - - backups = [ - name for name in os.listdir(zed_dir) if name.startswith("settings.json.corrupt-") and name.endswith(".bak") - ] - - assert res["success"] - assert res["changed"] - assert len(backups) == 1 - assert read_settings(tmp)["tab_size"] == 2 - - -def test_dry_run_reports_change_without_writing(): - with temp_appdata() as tmp: - zed_dir = os.path.join(tmp, "Zed") - os.makedirs(zed_dir) - settings_path = os.path.join(zed_dir, "settings.json") - - with open(settings_path, "w", encoding="utf-8") as settings_file: - json.dump({"tab_size": 4}, settings_file) - - res = run_plugin( - { - "requestId": "5", - "command": "apply", - "args": { - "tab_size": 2, - }, - "context": { - "dryRun": True, - }, - } - ) - - assert res["success"] - assert res["changed"] - assert read_settings(tmp)["tab_size"] == 4 - - -def test_idempotent_apply(): - with temp_appdata(): - payload = { - "requestId": "6", - "command": "apply", - "args": { - "settings": { - "vim_mode": False, - }, - }, - "context": { - "dryRun": False, - }, - } - - first = run_plugin(payload) - second = run_plugin(payload) - - assert first["success"] - assert first["changed"] - assert second["success"] - assert not second["changed"] - - -def test_apply_normalizes_string_scalars_from_host(): - with temp_appdata() as tmp: - res = run_plugin( - { - "requestId": "7", - "command": "apply", - "args": { - "tab_size": "2", - "vim_mode": "false", - "buffer_font_size": "14", - "features": { - "copilot": "true", - }, - }, - "context": { - "dryRun": False, - }, - } - ) - - assert res["success"] - assert res["changed"] - - settings = read_settings(tmp) - assert settings["tab_size"] == 2 - assert settings["vim_mode"] is False - assert settings["buffer_font_size"] == 14 - assert settings["features"]["copilot"] is True - - -def test_top_level_dry_run_and_config_protocol(): - with tempfile.TemporaryDirectory() as tmp: - settings_path = os.path.join(tmp, "settings.json") - - res = run_plugin( - { - "requestId": "8", - "command": "apply", - "dryRun": True, - "config": { - "configPath": settings_path, - "tab_size": 8, - }, - } - ) - - assert res["success"] - assert res["changed"] - assert not os.path.exists(settings_path) - - -def test_unknown_command(): - res = run_plugin( - { - "requestId": "9", - "command": "explode", - "args": {}, - "context": {}, - } - ) - - assert not res["success"] - assert "error" in res - - -if __name__ == "__main__": - test_check_installed() - test_apply_creates_missing_directory_and_file() - test_apply_deep_merges_existing_commented_settings() - test_apply_backs_up_corrupt_settings_before_replacing() - test_dry_run_reports_change_without_writing() - test_idempotent_apply() - test_apply_normalizes_string_scalars_from_host() - test_top_level_dry_run_and_config_protocol() - test_unknown_command() - - print("\nAll tests passed.") +import json +import os +import subprocess +import sys +import tempfile +from contextlib import contextmanager +from unittest import mock + +PLUGIN = os.path.abspath( + os.path.join( + os.path.dirname(__file__), + "..", + "src", + "plugin.py", + ) +) + + +def run_plugin(payload: dict) -> dict: + result = subprocess.run( + [sys.executable, PLUGIN], + input=json.dumps(payload), + capture_output=True, + text=True, + ) + + assert result.returncode == 0, result.stderr + return json.loads(result.stdout.strip()) + + +def read_settings(appdata: str) -> dict: + settings_path = os.path.join(appdata, "Zed", "settings.json") + with open(settings_path, "r", encoding="utf-8") as settings_file: + return json.load(settings_file) + + +@contextmanager +def temp_appdata(): + with tempfile.TemporaryDirectory() as tmp: + with mock.patch.dict(os.environ, {"APPDATA": tmp}): + yield tmp + + +def test_check_installed(): + res = run_plugin( + { + "requestId": "1", + "command": "check_installed", + "args": {}, + "context": {}, + } + ) + + assert res["success"] + assert not res["changed"] + assert isinstance(res["data"], bool) + + +def test_apply_creates_missing_directory_and_file(): + with temp_appdata() as tmp: + res = run_plugin( + { + "requestId": "2", + "command": "apply", + "args": { + "theme": { + "mode": "system", + "light": "One Light", + "dark": "One Dark", + }, + "buffer_font_family": "JetBrains Mono", + "buffer_font_size": 14, + "tab_size": 2, + "format_on_save": "on", + "vim_mode": False, + "relative_line_numbers": True, + "soft_wrap": "editor_width", + "cursor_shape": "bar", + "terminal": { + "font_family": "JetBrains Mono", + "font_size": 14, + "shell": "powershell.json", + }, + "languages": { + "Python": { + "tab_size": 4, + }, + "JavaScript": { + "tab_size": 2, + }, + }, + "features": { + "copilot": True, + }, + "auto_install_extensions": { + "html": True, + "dockerfile": True, + }, + }, + "context": { + "dryRun": False, + }, + } + ) + + assert res["success"] + assert res["changed"] + + settings = read_settings(tmp) + assert settings["theme"]["dark"] == "One Dark" + assert settings["buffer_font_family"] == "JetBrains Mono" + assert settings["languages"]["Python"]["tab_size"] == 4 + assert settings["features"]["copilot"] is True + + +def test_apply_deep_merges_existing_commented_settings(): + with temp_appdata() as tmp: + zed_dir = os.path.join(tmp, "Zed") + os.makedirs(zed_dir) + settings_path = os.path.join(zed_dir, "settings.json") + + with open(settings_path, "w", encoding="utf-8") as settings_file: + settings_file.write( + """ +{ + // Existing comments should not prevent parsing. + "theme": { + "mode": "dark", + "dark": "Ayu Dark" + }, + "languages": { + "Python": { + "formatter": "ruff", + "tab_size": 2 + } + }, + "project_panel": { + "dock": "left" + } +} +""" + ) + + res = run_plugin( + { + "requestId": "3", + "command": "apply", + "args": { + "theme": { + "mode": "system", + "light": "One Light", + }, + "languages": { + "Python": { + "tab_size": 4, + }, + }, + }, + "context": { + "dryRun": False, + }, + } + ) + + assert res["success"] + assert res["changed"] + + settings = read_settings(tmp) + assert settings["theme"]["mode"] == "system" + assert settings["theme"]["light"] == "One Light" + assert settings["theme"]["dark"] == "Ayu Dark" + assert settings["languages"]["Python"]["formatter"] == "ruff" + assert settings["languages"]["Python"]["tab_size"] == 4 + assert settings["project_panel"]["dock"] == "left" + + +def test_apply_backs_up_corrupt_settings_before_replacing(): + with temp_appdata() as tmp: + zed_dir = os.path.join(tmp, "Zed") + os.makedirs(zed_dir) + settings_path = os.path.join(zed_dir, "settings.json") + + with open(settings_path, "w", encoding="utf-8") as settings_file: + settings_file.write("{ invalid json") + + res = run_plugin( + { + "requestId": "4", + "command": "apply", + "args": { + "tab_size": 2, + }, + "context": { + "dryRun": False, + }, + } + ) + + backups = [ + name + for name in os.listdir(zed_dir) + if name.startswith("settings.json.corrupt-") and name.endswith(".bak") + ] + + assert res["success"] + assert res["changed"] + assert len(backups) == 1 + assert read_settings(tmp)["tab_size"] == 2 + + +def test_dry_run_reports_change_without_writing(): + with temp_appdata() as tmp: + zed_dir = os.path.join(tmp, "Zed") + os.makedirs(zed_dir) + settings_path = os.path.join(zed_dir, "settings.json") + + with open(settings_path, "w", encoding="utf-8") as settings_file: + json.dump({"tab_size": 4}, settings_file) + + res = run_plugin( + { + "requestId": "5", + "command": "apply", + "args": { + "tab_size": 2, + }, + "context": { + "dryRun": True, + }, + } + ) + + assert res["success"] + assert res["changed"] + assert read_settings(tmp)["tab_size"] == 4 + + +def test_idempotent_apply(): + with temp_appdata(): + payload = { + "requestId": "6", + "command": "apply", + "args": { + "settings": { + "vim_mode": False, + }, + }, + "context": { + "dryRun": False, + }, + } + + first = run_plugin(payload) + second = run_plugin(payload) + + assert first["success"] + assert first["changed"] + assert second["success"] + assert not second["changed"] + + +def test_apply_normalizes_string_scalars_from_host(): + with temp_appdata() as tmp: + res = run_plugin( + { + "requestId": "7", + "command": "apply", + "args": { + "tab_size": "2", + "vim_mode": "false", + "buffer_font_size": "14", + "features": { + "copilot": "true", + }, + }, + "context": { + "dryRun": False, + }, + } + ) + + assert res["success"] + assert res["changed"] + + settings = read_settings(tmp) + assert settings["tab_size"] == 2 + assert settings["vim_mode"] is False + assert settings["buffer_font_size"] == 14 + assert settings["features"]["copilot"] is True + + +def test_top_level_dry_run_and_config_protocol(): + with tempfile.TemporaryDirectory() as tmp: + settings_path = os.path.join(tmp, "settings.json") + + res = run_plugin( + { + "requestId": "8", + "command": "apply", + "dryRun": True, + "config": { + "configPath": settings_path, + "tab_size": 8, + }, + } + ) + + assert res["success"] + assert res["changed"] + assert not os.path.exists(settings_path) + + +def test_unknown_command(): + res = run_plugin( + { + "requestId": "9", + "command": "explode", + "args": {}, + "context": {}, + } + ) + + assert not res["success"] + assert "error" in res + + +if __name__ == "__main__": + test_check_installed() + test_apply_creates_missing_directory_and_file() + test_apply_deep_merges_existing_commented_settings() + test_apply_backs_up_corrupt_settings_before_replacing() + test_dry_run_reports_change_without_writing() + test_idempotent_apply() + test_apply_normalizes_string_scalars_from_host() + test_top_level_dry_run_and_config_protocol() + test_unknown_command() + + print("\nAll tests passed.") diff --git a/plugins/zoxide/src/plugin.py b/plugins/zoxide/src/plugin.py index 00f1a55e..45682314 100644 --- a/plugins/zoxide/src/plugin.py +++ b/plugins/zoxide/src/plugin.py @@ -67,10 +67,21 @@ def build_init_line(shell: str, init_args: dict) -> str: def update_profile_content(existing_text: str, desired_line: str) -> tuple[str, bool]: current_lines = existing_text.splitlines() - matching_lines = [line for line in current_lines if "zoxide init" in line and not line.lstrip().startswith("#")] - updated_lines = [line for line in current_lines if "zoxide init" not in line or line.lstrip().startswith("#")] - - if matching_lines == [desired_line] and len(updated_lines) == len(current_lines) - 1: + matching_lines = [ + line + for line in current_lines + if "zoxide init" in line and not line.lstrip().startswith("#") + ] + updated_lines = [ + line + for line in current_lines + if "zoxide init" not in line or line.lstrip().startswith("#") + ] + + if ( + matching_lines == [desired_line] + and len(updated_lines) == len(current_lines) - 1 + ): return existing_text, False updated_lines.append(desired_line) @@ -121,7 +132,9 @@ def run_setx(var_name: str, value: str) -> None: def check_installed(_args: dict, request_id: str) -> dict: - installed = shutil.which("zoxide.exe") is not None or shutil.which("zoxide") is not None + installed = ( + shutil.which("zoxide.exe") is not None or shutil.which("zoxide") is not None + ) return { "requestId": request_id, "success": True, diff --git a/plugins/zoxide/test/test_zoxide.py b/plugins/zoxide/test/test_zoxide.py index 28b21e71..059dae24 100644 --- a/plugins/zoxide/test/test_zoxide.py +++ b/plugins/zoxide/test/test_zoxide.py @@ -1,336 +1,360 @@ -import importlib.util -import json -from io import StringIO -from pathlib import Path -from unittest.mock import MagicMock, mock_open, patch - -PLUGIN_PATH = Path(__file__).resolve().parents[1] / "src" / "plugin.py" - -spec = importlib.util.spec_from_file_location("zoxide_plugin", PLUGIN_PATH) -plugin = importlib.util.module_from_spec(spec) -assert spec and spec.loader -spec.loader.exec_module(plugin) - - -def test_check_installed_returns_true_when_zoxide_is_found(): - with patch.object(plugin.shutil, "which", side_effect=[None, "C:/Tools/zoxide"]): - result = plugin.check_installed({}, "req-1") - - assert result["requestId"] == "req-1" - assert result["success"] is True - assert result["data"] == {"installed": True} - - -def test_check_installed_returns_false_when_zoxide_is_missing(): - with patch.object(plugin.shutil, "which", return_value=None): - result = plugin.check_installed({}, "req-2") - - assert result["requestId"] == "req-2" - assert result["success"] is True - assert result["data"] == {"installed": False} - - -def test_apply_sets_env_vars_via_setx_when_values_differ(): - with ( - patch.dict( - plugin.os.environ, - { - "USERPROFILE": "C:/Users/Test", - "_ZO_MAX_DEPTH": "5", - "_ZO_ECHO": "1", - "_ZO_EXCLUDE_DIRS": "C:\\Windows;C:\\Program Files", - "_ZO_RESOLVE_SYMLINKS": "1", - }, - clear=True, - ), - patch.object(plugin, "update_profile_file", return_value=False), - patch.object( - plugin.subprocess, - "run", - return_value=MagicMock(returncode=0, stderr="", stdout=""), - ) as mock_run, - ): - result = plugin.apply_config( - { - "env_vars": { - "_ZO_MAX_DEPTH": "10", - "_ZO_ECHO": "1", - "_ZO_EXCLUDE_DIRS": "C:\\Windows;C:\\Program Files", - "_ZO_RESOLVE_SYMLINKS": "1", - }, - "init": {}, - }, - dry_run=False, - request_id="req-3", - ) - - assert result["requestId"] == "req-3" - assert result["success"] is True - assert result["changed"] is True - assert mock_run.call_count == 1 - assert mock_run.call_args.args[0] == ["setx", "_ZO_MAX_DEPTH", "10"] - - -def test_apply_skips_setx_when_env_vars_match(): - with ( - patch.dict( - plugin.os.environ, - { - "USERPROFILE": "C:/Users/Test", - "_ZO_MAX_DEPTH": "10", - "_ZO_ECHO": "1", - "_ZO_EXCLUDE_DIRS": "C:\\Windows;C:\\Program Files", - "_ZO_RESOLVE_SYMLINKS": "1", - }, - clear=True, - ), - patch.object(plugin, "update_profile_file", return_value=False), - patch.object( - plugin.subprocess, - "run", - side_effect=AssertionError("setx should not be called when values already match"), - ), - ): - result = plugin.apply_config( - { - "env_vars": { - "_ZO_MAX_DEPTH": "10", - "_ZO_ECHO": "1", - "_ZO_EXCLUDE_DIRS": "C:\\Windows;C:\\Program Files", - "_ZO_RESOLVE_SYMLINKS": "1", - }, - "init": {}, - }, - dry_run=False, - request_id="req-4", - ) - - assert result["requestId"] == "req-4" - assert result["success"] is True - assert result["changed"] is False - - -def test_apply_skips_setx_on_non_windows(): - with ( - patch.dict( - plugin.os.environ, - { - "USERPROFILE": "C:/Users/Test", - "_ZO_MAX_DEPTH": "5", - }, - clear=True, - ), - patch.object(plugin.sys, "platform", "linux"), - patch.object(plugin, "update_profile_file", return_value=False), - patch.object( - plugin.subprocess, - "run", - side_effect=AssertionError("setx should not run on non-Windows platforms"), - ), - ): - result = plugin.apply_config( - { - "env_vars": {"_ZO_MAX_DEPTH": "10"}, - "init": {}, - }, - dry_run=False, - request_id="req-nonwin", - ) - - assert result["requestId"] == "req-nonwin" - assert result["success"] is True - - -def test_apply_updates_powershell_init_line_when_flags_change(): - existing = 'Write-Host "hello"\nInvoke-Expression (& { (zoxide init powershell) })\n' - opened = mock_open(read_data=existing) - - with ( - patch.dict(plugin.os.environ, {"USERPROFILE": "C:/Users/Test"}, clear=True), - patch("builtins.open", opened), - patch.object(plugin.Path, "exists", return_value=True), - patch.object(plugin.Path, "mkdir") as mock_mkdir, - ): - profile_path = Path("C:/Users/Test/Documents/PowerShell/Microsoft.PowerShell_profile.ps1") - changed = plugin.update_profile_file( - profile_path, - plugin.build_init_line( - "powershell", - {"cmd": "z", "hook": "pwd", "no_cmd": False}, - ), - dry_run=False, - ) - - assert changed is True - mock_mkdir.assert_called_once() - handle = opened() - written = "".join(call.args[0] for call in handle.write.call_args_list) - assert "Invoke-Expression (& { (zoxide init powershell --cmd z) })" in written - assert 'Write-Host "hello"' in written - - -def test_apply_preserves_comment_lines_containing_zoxide_init(): - existing = "# How to use zoxide init\nInvoke-Expression (& { (zoxide init powershell) })\n" - updated, changed = plugin.update_profile_content( - existing, - plugin.build_init_line("powershell", {"cmd": "z", "hook": "pwd", "no_cmd": False}), - ) - - assert changed is True - assert "# How to use zoxide init" in updated - assert "Invoke-Expression (& { (zoxide init powershell --cmd z) })" in updated - - -def test_apply_appends_init_line_if_not_present(): - updated, changed = plugin.update_profile_content( - "Set-Location C:/Work\n", - plugin.build_init_line("bash", {"cmd": None, "hook": "pwd", "no_cmd": False}), - ) - - assert changed is True - assert "Set-Location C:/Work" in updated - assert 'eval "$(zoxide init bash)"' in updated - - -def test_apply_dry_run_does_not_write_files_or_run_setx(): - opened = mock_open(read_data="Set-Location C:/Work\n") - - with ( - patch.dict( - plugin.os.environ, - { - "USERPROFILE": "C:/Users/Test", - "_ZO_MAX_DEPTH": "5", - }, - clear=True, - ), - patch.object(plugin.subprocess, "run") as mock_run, - patch("builtins.open", opened), - patch.object(plugin.Path, "exists", return_value=True), - ): - result = plugin.apply_config( - { - "env_vars": {"_ZO_MAX_DEPTH": "10"}, - "init": {"cmd": "z", "hook": "pwd", "no_cmd": False}, - }, - dry_run=True, - request_id="req-5", - ) - - assert result["requestId"] == "req-5" - assert result["success"] is True - assert result["changed"] is True - mock_run.assert_not_called() - handle = opened() - handle.write.assert_not_called() - - -def test_apply_returns_changed_false_when_nothing_needs_updating(): - matching_line = plugin.build_init_line("powershell", {"cmd": None, "hook": "pwd", "no_cmd": False}) - existing = f"{matching_line}\n" - opened = mock_open(read_data=existing) - - with ( - patch.dict( - plugin.os.environ, - { - "USERPROFILE": "C:/Users/Test", - "_ZO_MAX_DEPTH": "10", - "_ZO_ECHO": "1", - "_ZO_EXCLUDE_DIRS": "C:\\Windows;C:\\Program Files", - "_ZO_RESOLVE_SYMLINKS": "1", - }, - clear=True, - ), - patch.object(plugin.subprocess, "run") as mock_run, - patch.object(plugin.shutil, "which", return_value="C:/Tools/zoxide.exe"), - patch("builtins.open", opened), - patch.object(plugin.Path, "exists", return_value=True), - patch.object(plugin, "update_profile_file", return_value=False), - ): - result = plugin.apply_config( - { - "env_vars": { - "_ZO_MAX_DEPTH": "10", - "_ZO_ECHO": "1", - "_ZO_EXCLUDE_DIRS": "C:\\Windows;C:\\Program Files", - "_ZO_RESOLVE_SYMLINKS": "1", - }, - "init": {}, - }, - dry_run=False, - request_id="req-6", - ) - - assert result["requestId"] == "req-6" - assert result["success"] is True - assert result["changed"] is False - mock_run.assert_not_called() - - -def test_apply_builds_correct_init_line_with_flags(): - line = plugin.build_init_line( - "powershell", - {"cmd": "z", "hook": "pwd", "no_cmd": True}, - ) - - assert line == "Invoke-Expression (& { (zoxide init powershell --cmd z --no-cmd) })" - - -def test_apply_handles_missing_profile_file_by_creating_it(): - profile_path = Path("C:/Users/Test/Documents/PowerShell/Microsoft.PowerShell_profile.ps1") - opened = mock_open() - - with ( - patch.object(plugin.Path, "exists", return_value=False), - patch("builtins.open", opened), - patch.object(plugin.Path, "mkdir") as mock_mkdir, - ): - changed = plugin.update_profile_file( - profile_path, - plugin.build_init_line("powershell", {"cmd": None, "hook": "pwd", "no_cmd": False}), - dry_run=False, - ) - - assert changed is True - mock_mkdir.assert_called_once() - handle = opened() - handle.write.assert_called() - - -def test_process_request_returns_error_for_unknown_command(): - result = plugin.process_request({"requestId": "req-7", "command": "explode", "args": {}}) - - assert result["requestId"] == "req-7" - assert result["success"] is False - assert "Unknown command" in result["error"] - - -def test_main_handles_pretty_printed_json_request(): - request = json.dumps( - { - "requestId": "req-main", - "command": "check_installed", - "args": {}, - "context": {}, - }, - indent=2, - ) - - with ( - patch("sys.stdin", StringIO(request)), - patch("sys.stdout", new_callable=StringIO), - patch.object(plugin.shutil, "which", side_effect=[None, "C:/Tools/zoxide"]) as mock_which, - ): - plugin.main() - output = plugin.sys.stdout.getvalue() if hasattr(plugin.sys.stdout, "getvalue") else None - - assert mock_which.call_count == 2 - - if output is None: - output = "" - - response = json.loads(output.strip()) - assert response["requestId"] == "req-main" - assert response["success"] is True - assert response["data"] == {"installed": True} +import importlib.util +import json +from io import StringIO +from pathlib import Path +from unittest.mock import MagicMock, mock_open, patch + +PLUGIN_PATH = Path(__file__).resolve().parents[1] / "src" / "plugin.py" + +spec = importlib.util.spec_from_file_location("zoxide_plugin", PLUGIN_PATH) +plugin = importlib.util.module_from_spec(spec) +assert spec and spec.loader +spec.loader.exec_module(plugin) + + +def test_check_installed_returns_true_when_zoxide_is_found(): + with patch.object(plugin.shutil, "which", side_effect=[None, "C:/Tools/zoxide"]): + result = plugin.check_installed({}, "req-1") + + assert result["requestId"] == "req-1" + assert result["success"] is True + assert result["data"] == {"installed": True} + + +def test_check_installed_returns_false_when_zoxide_is_missing(): + with patch.object(plugin.shutil, "which", return_value=None): + result = plugin.check_installed({}, "req-2") + + assert result["requestId"] == "req-2" + assert result["success"] is True + assert result["data"] == {"installed": False} + + +def test_apply_sets_env_vars_via_setx_when_values_differ(): + with ( + patch.dict( + plugin.os.environ, + { + "USERPROFILE": "C:/Users/Test", + "_ZO_MAX_DEPTH": "5", + "_ZO_ECHO": "1", + "_ZO_EXCLUDE_DIRS": "C:\\Windows;C:\\Program Files", + "_ZO_RESOLVE_SYMLINKS": "1", + }, + clear=True, + ), + patch.object(plugin, "update_profile_file", return_value=False), + patch.object( + plugin.subprocess, + "run", + return_value=MagicMock(returncode=0, stderr="", stdout=""), + ) as mock_run, + ): + result = plugin.apply_config( + { + "env_vars": { + "_ZO_MAX_DEPTH": "10", + "_ZO_ECHO": "1", + "_ZO_EXCLUDE_DIRS": "C:\\Windows;C:\\Program Files", + "_ZO_RESOLVE_SYMLINKS": "1", + }, + "init": {}, + }, + dry_run=False, + request_id="req-3", + ) + + assert result["requestId"] == "req-3" + assert result["success"] is True + assert result["changed"] is True + assert mock_run.call_count == 1 + assert mock_run.call_args.args[0] == ["setx", "_ZO_MAX_DEPTH", "10"] + + +def test_apply_skips_setx_when_env_vars_match(): + with ( + patch.dict( + plugin.os.environ, + { + "USERPROFILE": "C:/Users/Test", + "_ZO_MAX_DEPTH": "10", + "_ZO_ECHO": "1", + "_ZO_EXCLUDE_DIRS": "C:\\Windows;C:\\Program Files", + "_ZO_RESOLVE_SYMLINKS": "1", + }, + clear=True, + ), + patch.object(plugin, "update_profile_file", return_value=False), + patch.object( + plugin.subprocess, + "run", + side_effect=AssertionError( + "setx should not be called when values already match" + ), + ), + ): + result = plugin.apply_config( + { + "env_vars": { + "_ZO_MAX_DEPTH": "10", + "_ZO_ECHO": "1", + "_ZO_EXCLUDE_DIRS": "C:\\Windows;C:\\Program Files", + "_ZO_RESOLVE_SYMLINKS": "1", + }, + "init": {}, + }, + dry_run=False, + request_id="req-4", + ) + + assert result["requestId"] == "req-4" + assert result["success"] is True + assert result["changed"] is False + + +def test_apply_skips_setx_on_non_windows(): + with ( + patch.dict( + plugin.os.environ, + { + "USERPROFILE": "C:/Users/Test", + "_ZO_MAX_DEPTH": "5", + }, + clear=True, + ), + patch.object(plugin.sys, "platform", "linux"), + patch.object(plugin, "update_profile_file", return_value=False), + patch.object( + plugin.subprocess, + "run", + side_effect=AssertionError("setx should not run on non-Windows platforms"), + ), + ): + result = plugin.apply_config( + { + "env_vars": {"_ZO_MAX_DEPTH": "10"}, + "init": {}, + }, + dry_run=False, + request_id="req-nonwin", + ) + + assert result["requestId"] == "req-nonwin" + assert result["success"] is True + + +def test_apply_updates_powershell_init_line_when_flags_change(): + existing = ( + 'Write-Host "hello"\nInvoke-Expression (& { (zoxide init powershell) })\n' + ) + opened = mock_open(read_data=existing) + + with ( + patch.dict(plugin.os.environ, {"USERPROFILE": "C:/Users/Test"}, clear=True), + patch("builtins.open", opened), + patch.object(plugin.Path, "exists", return_value=True), + patch.object(plugin.Path, "mkdir") as mock_mkdir, + ): + profile_path = Path( + "C:/Users/Test/Documents/PowerShell/Microsoft.PowerShell_profile.ps1" + ) + changed = plugin.update_profile_file( + profile_path, + plugin.build_init_line( + "powershell", + {"cmd": "z", "hook": "pwd", "no_cmd": False}, + ), + dry_run=False, + ) + + assert changed is True + mock_mkdir.assert_called_once() + handle = opened() + written = "".join(call.args[0] for call in handle.write.call_args_list) + assert "Invoke-Expression (& { (zoxide init powershell --cmd z) })" in written + assert 'Write-Host "hello"' in written + + +def test_apply_preserves_comment_lines_containing_zoxide_init(): + existing = ( + "# How to use zoxide init\nInvoke-Expression (& { (zoxide init powershell) })\n" + ) + updated, changed = plugin.update_profile_content( + existing, + plugin.build_init_line( + "powershell", {"cmd": "z", "hook": "pwd", "no_cmd": False} + ), + ) + + assert changed is True + assert "# How to use zoxide init" in updated + assert "Invoke-Expression (& { (zoxide init powershell --cmd z) })" in updated + + +def test_apply_appends_init_line_if_not_present(): + updated, changed = plugin.update_profile_content( + "Set-Location C:/Work\n", + plugin.build_init_line("bash", {"cmd": None, "hook": "pwd", "no_cmd": False}), + ) + + assert changed is True + assert "Set-Location C:/Work" in updated + assert 'eval "$(zoxide init bash)"' in updated + + +def test_apply_dry_run_does_not_write_files_or_run_setx(): + opened = mock_open(read_data="Set-Location C:/Work\n") + + with ( + patch.dict( + plugin.os.environ, + { + "USERPROFILE": "C:/Users/Test", + "_ZO_MAX_DEPTH": "5", + }, + clear=True, + ), + patch.object(plugin.subprocess, "run") as mock_run, + patch("builtins.open", opened), + patch.object(plugin.Path, "exists", return_value=True), + ): + result = plugin.apply_config( + { + "env_vars": {"_ZO_MAX_DEPTH": "10"}, + "init": {"cmd": "z", "hook": "pwd", "no_cmd": False}, + }, + dry_run=True, + request_id="req-5", + ) + + assert result["requestId"] == "req-5" + assert result["success"] is True + assert result["changed"] is True + mock_run.assert_not_called() + handle = opened() + handle.write.assert_not_called() + + +def test_apply_returns_changed_false_when_nothing_needs_updating(): + matching_line = plugin.build_init_line( + "powershell", {"cmd": None, "hook": "pwd", "no_cmd": False} + ) + existing = f"{matching_line}\n" + opened = mock_open(read_data=existing) + + with ( + patch.dict( + plugin.os.environ, + { + "USERPROFILE": "C:/Users/Test", + "_ZO_MAX_DEPTH": "10", + "_ZO_ECHO": "1", + "_ZO_EXCLUDE_DIRS": "C:\\Windows;C:\\Program Files", + "_ZO_RESOLVE_SYMLINKS": "1", + }, + clear=True, + ), + patch.object(plugin.subprocess, "run") as mock_run, + patch.object(plugin.shutil, "which", return_value="C:/Tools/zoxide.exe"), + patch("builtins.open", opened), + patch.object(plugin.Path, "exists", return_value=True), + patch.object(plugin, "update_profile_file", return_value=False), + ): + result = plugin.apply_config( + { + "env_vars": { + "_ZO_MAX_DEPTH": "10", + "_ZO_ECHO": "1", + "_ZO_EXCLUDE_DIRS": "C:\\Windows;C:\\Program Files", + "_ZO_RESOLVE_SYMLINKS": "1", + }, + "init": {}, + }, + dry_run=False, + request_id="req-6", + ) + + assert result["requestId"] == "req-6" + assert result["success"] is True + assert result["changed"] is False + mock_run.assert_not_called() + + +def test_apply_builds_correct_init_line_with_flags(): + line = plugin.build_init_line( + "powershell", + {"cmd": "z", "hook": "pwd", "no_cmd": True}, + ) + + assert line == "Invoke-Expression (& { (zoxide init powershell --cmd z --no-cmd) })" + + +def test_apply_handles_missing_profile_file_by_creating_it(): + profile_path = Path( + "C:/Users/Test/Documents/PowerShell/Microsoft.PowerShell_profile.ps1" + ) + opened = mock_open() + + with ( + patch.object(plugin.Path, "exists", return_value=False), + patch("builtins.open", opened), + patch.object(plugin.Path, "mkdir") as mock_mkdir, + ): + changed = plugin.update_profile_file( + profile_path, + plugin.build_init_line( + "powershell", {"cmd": None, "hook": "pwd", "no_cmd": False} + ), + dry_run=False, + ) + + assert changed is True + mock_mkdir.assert_called_once() + handle = opened() + handle.write.assert_called() + + +def test_process_request_returns_error_for_unknown_command(): + result = plugin.process_request( + {"requestId": "req-7", "command": "explode", "args": {}} + ) + + assert result["requestId"] == "req-7" + assert result["success"] is False + assert "Unknown command" in result["error"] + + +def test_main_handles_pretty_printed_json_request(): + request = json.dumps( + { + "requestId": "req-main", + "command": "check_installed", + "args": {}, + "context": {}, + }, + indent=2, + ) + + with ( + patch("sys.stdin", StringIO(request)), + patch("sys.stdout", new_callable=StringIO), + patch.object( + plugin.shutil, "which", side_effect=[None, "C:/Tools/zoxide"] + ) as mock_which, + ): + plugin.main() + output = ( + plugin.sys.stdout.getvalue() + if hasattr(plugin.sys.stdout, "getvalue") + else None + ) + + assert mock_which.call_count == 2 + + if output is None: + output = "" + + response = json.loads(output.strip()) + assert response["requestId"] == "req-main" + assert response["success"] is True + assert response["data"] == {"installed": True} diff --git a/tests/TestPlugin/src/main.py b/tests/TestPlugin/src/main.py index 4221f24a..b4b55551 100644 --- a/tests/TestPlugin/src/main.py +++ b/tests/TestPlugin/src/main.py @@ -11,7 +11,10 @@ def main(): "requestId": request.get("requestId"), "success": True, "changed": True, - "data": {"echo": request.get("args", {}).get("message", "no message"), "python_version": sys.version}, + "data": { + "echo": request.get("args", {}).get("message", "no message"), + "python_version": sys.version, + }, } print(json.dumps(response)) From f1d10443c85b46c4e00a62fd4ebbd8d9cb8fb062 Mon Sep 17 00:00:00 2001 From: Bhagyashri Date: Wed, 22 Jul 2026 08:53:56 +0530 Subject: [PATCH 10/10] style: format python files with ruff --- plugins/7-zip/src/plugin.py | 16 ++----- plugins/7-zip/test/test_7zip.py | 12 ++--- plugins/alacritty/src/plugin.py | 9 +--- plugins/alacritty/test/test_alacritty.py | 8 +--- plugins/audacity/src/plugin.py | 4 +- plugins/audacity/test/test_audacity.py | 4 +- plugins/autohotkey/src/plugin.py | 12 +---- plugins/autohotkey/test/test_autohotkey.py | 4 +- plugins/bat/src/plugin.py | 22 ++------- plugins/betterdiscord/src/plugin.py | 6 +-- .../betterdiscord/test/test_betterdiscord.py | 4 +- plugins/cargo/src/plugin.py | 5 +- plugins/cargo/test/test_cargo.py | 8 +--- plugins/chezmoi/src/plugin.py | 8 +--- plugins/chezmoi/test/test_chezmoi.py | 8 +--- plugins/chocolatey/src/plugin.py | 14 ++---- plugins/chocolatey/test/test_chocolatey.py | 24 +++------- plugins/curl/src/plugin.py | 4 +- plugins/deno/src/plugin.py | 4 +- plugins/discord/test/test_discord.py | 4 +- plugins/ditto/test/test_ditto.py | 8 +--- plugins/docker/src/plugin.py | 8 +--- plugins/docker/test/test_docker.py | 8 +--- plugins/espanso/src/plugin.py | 10 +--- plugins/espanso/test/test_espanso.py | 24 +++------- plugins/everything/test/test_everything.py | 4 +- plugins/flow-launcher/src/plugin.py | 8 +--- plugins/flow-launcher/test/test_plugin.py | 4 +- plugins/fzf/src/plugin.py | 27 +++-------- plugins/fzf/test/test_plugin.py | 13 ++---- plugins/gh-dash/src/plugin.py | 20 ++------ plugins/gh-dash/test/test_gh_dash.py | 12 ++--- plugins/gh/test/test_gh.py | 16 ++----- plugins/github-desktop/src/plugin.py | 12 ++--- .../test/test_github_desktop.py | 4 +- plugins/go/src/plugin.py | 24 ++-------- plugins/go/test/test_go.py | 20 ++------ plugins/greenshot/src/plugin.py | 4 +- plugins/helix-editor/src/plugin.py | 14 ++---- plugins/helix-editor/test/test_plugin.py | 20 ++------ plugins/irfanview/src/plugin.py | 15 ++---- plugins/irfanview/test/test_irfanview.py | 4 +- plugins/keepassxc/src/plugin.py | 24 ++-------- plugins/keepassxc/test/test_keepassxc.py | 8 ++-- plugins/lazydocker/src/plugin.py | 16 ++----- plugins/lazygit/src/plugin.py | 4 +- plugins/lazygit/test/test_lazygit.py | 8 +--- plugins/miniconda/src/plugin.py | 4 +- plugins/mise/src/plugin.py | 4 +- plugins/mise/test/test_mise.py | 8 +--- .../test/test_notepadplusplus.py | 8 +--- plugins/npm/test/test_npm.py | 4 +- plugins/nuget/src/plugin.py | 21 ++------- plugins/nuget/test/test_nuget.py | 32 +++---------- plugins/nvm/src/plugin.py | 16 ++----- plugins/nvm/test/test_nvm.py | 4 +- plugins/obs-studio/src/plugin.py | 32 ++++--------- plugins/obs-studio/test/test_obs_studio.py | 16 ++----- plugins/obsidian/src/plugin.py | 20 ++------ plugins/obsidian/test/test_obsidian.py | 20 ++------ plugins/ohmyposh/src/plugin.py | 27 +++-------- plugins/ohmyposh/test/test_omp.py | 13 ++---- plugins/opencode/src/plugin.py | 34 +++----------- plugins/opencode/test/test_opencode.py | 8 +--- plugins/openssh/src/plugin.py | 24 ++-------- plugins/pip/src/plugin.py | 9 +--- plugins/pip/test/test_pip.py | 6 +-- plugins/pnpm/src/plugin.py | 4 +- plugins/pnpm/test/test_pnpm.py | 16 ++----- plugins/powershell/src/plugin.py | 18 ++------ plugins/powertoys/src/main.py | 4 +- plugins/powertoys/test/test_powertoys.py | 4 +- plugins/rainmeter/src/plugin.py | 9 +--- plugins/rainmeter/test/test_rainmeter.py | 16 ++----- plugins/rclone/src/plugin.py | 10 +--- plugins/rclone/test/test_rclone.py | 4 +- plugins/ripgrep/src/plugin.py | 4 +- plugins/ripgrep/test/test_plugin.py | 20 ++------ plugins/scoop/src/plugin.py | 4 +- plugins/scoop/test/test_scoop.py | 8 +--- plugins/sharex/src/plugin.py | 4 +- plugins/sharex/test/test_sharex.py | 12 +---- plugins/spicetify/src/plugin.py | 5 +- plugins/spicetify/test/test_spicetify.py | 8 +--- plugins/spotify/test/test_spotify.py | 8 +--- plugins/starship/src/plugin.py | 8 +--- plugins/starship/test/test_starship.py | 8 +--- plugins/sublime-text/src/plugin.py | 5 +- .../sublime-text/test/test_sublime_text.py | 4 +- plugins/syncthing/src/plugin.py | 4 +- plugins/topgrade/src/plugin.py | 4 +- plugins/topgrade/test/test_topgrade.py | 4 +- plugins/vim/src/main.py | 4 +- plugins/vim/tests/test_vim.py | 8 +--- plugins/vlc/src/plugin.py | 9 +--- plugins/vlc/test/test_vlc.py | 12 ++--- plugins/wallpaper-engine/src/plugin.py | 10 +--- .../test/test_wallpaper_engine.py | 8 +--- plugins/windows-explorer/src/plugin.py | 8 +--- .../test/test_windows_explorer.py | 20 ++------ plugins/windows-sandbox/src/plugin.py | 4 +- .../test/test_windows_sandbox.py | 4 +- plugins/winget/src/plugin.py | 4 +- plugins/yarn/src/plugin.py | 20 ++------ plugins/yarn/test/test_yarn.py | 4 +- plugins/yasb/src/plugin.py | 22 ++------- plugins/yasb/test/test_yasb.py | 24 +++------- plugins/yazi/test/test_yazi.py | 14 ++---- plugins/zed/src/plugin.py | 30 +++--------- plugins/zed/test/test_zed.py | 4 +- plugins/zoxide/src/plugin.py | 23 ++-------- plugins/zoxide/test/test_zoxide.py | 46 +++++-------------- 112 files changed, 306 insertions(+), 996 deletions(-) diff --git a/plugins/7-zip/src/plugin.py b/plugins/7-zip/src/plugin.py index 42e84cd4..b70d696e 100644 --- a/plugins/7-zip/src/plugin.py +++ b/plugins/7-zip/src/plugin.py @@ -33,12 +33,8 @@ def _backup_corrupt_registry(reason): timestamp = datetime.datetime.now(datetime.timezone.utc).strftime("%Y%m%d%H%M%S") suffix = uuid.uuid4().hex[:8] user_profile = os.getenv("USERPROFILE") or os.path.expanduser("~") - backup_path = os.path.join( - user_profile, f"7zip_registry.corrupted.{timestamp}.{suffix}.reg" - ) - log( - f"Registry read failed ({reason}). Backing up HKCU\\{REG_PATH} to {backup_path}" - ) + backup_path = os.path.join(user_profile, f"7zip_registry.corrupted.{timestamp}.{suffix}.reg") + log(f"Registry read failed ({reason}). Backing up HKCU\\{REG_PATH} to {backup_path}") try: subprocess.run( ["reg.exe", "export", f"HKCU\\{REG_PATH}", backup_path, "/y"], @@ -56,9 +52,7 @@ def read_settings(): return settings try: - with winreg.OpenKey( - winreg.HKEY_CURRENT_USER, REG_PATH, 0, winreg.KEY_READ - ) as key: + with winreg.OpenKey(winreg.HKEY_CURRENT_USER, REG_PATH, 0, winreg.KEY_READ) as key: i = 0 while True: try: @@ -194,9 +188,7 @@ def apply_config(args, context, request_id): } try: - with winreg.CreateKeyEx( - winreg.HKEY_CURRENT_USER, REG_PATH, 0, winreg.KEY_SET_VALUE - ) as key: + with winreg.CreateKeyEx(winreg.HKEY_CURRENT_USER, REG_PATH, 0, winreg.KEY_SET_VALUE) as key: for k, v in to_update.items(): reg_type = KEY_TYPES.get(k) if reg_type is None: diff --git a/plugins/7-zip/test/test_7zip.py b/plugins/7-zip/test/test_7zip.py index 940764d6..ccc5d62e 100644 --- a/plugins/7-zip/test/test_7zip.py +++ b/plugins/7-zip/test/test_7zip.py @@ -13,9 +13,7 @@ class Test7ZipPlugin(unittest.TestCase): def test_check_installed_returns_true_when_7z_is_in_path(self): - with patch.object( - plugin.shutil, "which", return_value="C:/Program Files/7-Zip/7z.exe" - ): + with patch.object(plugin.shutil, "which", return_value="C:/Program Files/7-Zip/7z.exe"): result = plugin.check_installed({}, "req-1") self.assertEqual(result["requestId"], "req-1") @@ -149,9 +147,7 @@ def test_apply_config_validates_inputs(self): plugin.winreg = MagicMock() # Invalid CompressionLevel type - res = plugin.apply_config( - {"settings": {"CompressionLevel": "high"}}, {}, "req-7" - ) + res = plugin.apply_config({"settings": {"CompressionLevel": "high"}}, {}, "req-7") self.assertFalse(res["success"]) self.assertIn("CompressionLevel", res["error"]) @@ -166,9 +162,7 @@ def test_apply_config_validates_inputs(self): self.assertIn("CompressionMethod", res["error"]) # Invalid EncryptHeaders type - res = plugin.apply_config( - {"settings": {"EncryptHeaders": "True"}}, {}, "req-10" - ) + res = plugin.apply_config({"settings": {"EncryptHeaders": "True"}}, {}, "req-10") self.assertFalse(res["success"]) self.assertIn("EncryptHeaders", res["error"]) diff --git a/plugins/alacritty/src/plugin.py b/plugins/alacritty/src/plugin.py index 666f62d5..649b0134 100644 --- a/plugins/alacritty/src/plugin.py +++ b/plugins/alacritty/src/plugin.py @@ -13,9 +13,7 @@ def log(msg): def get_config_path() -> str: if sys.platform == "win32": - appdata = os.environ.get("APPDATA") or os.path.join( - os.path.expanduser("~"), "AppData", "Roaming" - ) + appdata = os.environ.get("APPDATA") or os.path.join(os.path.expanduser("~"), "AppData", "Roaming") if appdata: return os.path.join(appdata, "alacritty", "alacritty.toml") return os.path.expanduser("~/.config/alacritty/alacritty.toml") @@ -117,10 +115,7 @@ def merge_settings(target: dict, source: dict) -> bool: def check_installed(args: dict, request_id: str) -> dict: - installed = ( - shutil.which("alacritty.exe") is not None - or shutil.which("alacritty") is not None - ) + installed = shutil.which("alacritty.exe") is not None or shutil.which("alacritty") is not None return { "requestId": request_id, "success": True, diff --git a/plugins/alacritty/test/test_alacritty.py b/plugins/alacritty/test/test_alacritty.py index b8bf0179..18a6acdc 100644 --- a/plugins/alacritty/test/test_alacritty.py +++ b/plugins/alacritty/test/test_alacritty.py @@ -26,9 +26,7 @@ def mock_context(): @patch("plugin.shutil.which") def test_check_installed_true(mock_which): mock_which.return_value = "/usr/local/bin/alacritty" - result = plugin.handle( - {"requestId": "req-1", "command": "check_installed", "args": {}} - ) + result = plugin.handle({"requestId": "req-1", "command": "check_installed", "args": {}}) assert result["success"] is True assert result["data"] is True @@ -37,9 +35,7 @@ def test_check_installed_true(mock_which): @patch("plugin.shutil.which") def test_check_installed_false(mock_which): mock_which.return_value = None - result = plugin.handle( - {"requestId": "req-1", "command": "check_installed", "args": {}} - ) + result = plugin.handle({"requestId": "req-1", "command": "check_installed", "args": {}}) assert result["success"] is True assert result["data"] is False diff --git a/plugins/audacity/src/plugin.py b/plugins/audacity/src/plugin.py index 79a78de9..fc8bd5f8 100644 --- a/plugins/audacity/src/plugin.py +++ b/plugins/audacity/src/plugin.py @@ -119,9 +119,7 @@ def merge_settings( parser.set(section, key, str_value) changed = True else: - existing = ( - parser.get(section, key) if parser.has_option(section, key) else None - ) + existing = parser.get(section, key) if parser.has_option(section, key) else None if existing != str_value: parser.set(section, key, str_value) diff --git a/plugins/audacity/test/test_audacity.py b/plugins/audacity/test/test_audacity.py index 7dcf9d32..7dc3b0ee 100644 --- a/plugins/audacity/test/test_audacity.py +++ b/plugins/audacity/test/test_audacity.py @@ -5,9 +5,7 @@ import sys import tempfile -PLUGIN = os.path.abspath( - os.path.join(os.path.dirname(__file__), "..", "src", "plugin.py") -) +PLUGIN = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "src", "plugin.py")) # --------------------------------------------------------------------------- # Helpers diff --git a/plugins/autohotkey/src/plugin.py b/plugins/autohotkey/src/plugin.py index d7b965bb..d2a8d108 100644 --- a/plugins/autohotkey/src/plugin.py +++ b/plugins/autohotkey/src/plugin.py @@ -205,11 +205,7 @@ def strip_managed_content(existing_text: str) -> str: i += 1 continue - if ( - stripped.endswith("::") - and i + 1 < len(lines) - and lines[i + 1].strip() == "{" - ): + if stripped.endswith("::") and i + 1 < len(lines) and lines[i + 1].strip() == "{": i += 2 depth = 1 while i < len(lines) and depth > 0: @@ -221,11 +217,7 @@ def strip_managed_content(existing_text: str) -> str: i += 1 continue - if ( - stripped.endswith("::") - and i + 1 < len(lines) - and lines[i + 1].strip() == "(" - ): + if stripped.endswith("::") and i + 1 < len(lines) and lines[i + 1].strip() == "(": i += 2 while i < len(lines) and lines[i].strip() != ")": i += 1 diff --git a/plugins/autohotkey/test/test_autohotkey.py b/plugins/autohotkey/test/test_autohotkey.py index d593306e..1b373deb 100644 --- a/plugins/autohotkey/test/test_autohotkey.py +++ b/plugins/autohotkey/test/test_autohotkey.py @@ -34,9 +34,7 @@ def test_check_installed_reports_true_when_executable_is_found(monkeypatch): plugin = load_plugin_module() monkeypatch.setattr(plugin.shutil, "which", lambda name: None) - monkeypatch.setattr( - plugin.os.path, "exists", lambda path: path.endswith("AutoHotkey64.exe") - ) + monkeypatch.setattr(plugin.os.path, "exists", lambda path: path.endswith("AutoHotkey64.exe")) monkeypatch.setenv("PROGRAMFILES", r"C:\Program Files") monkeypatch.setenv("PROGRAMFILES(X86)", r"C:\Program Files (x86)") monkeypatch.setenv("LOCALAPPDATA", r"C:\Users\Test\AppData\Local") diff --git a/plugins/bat/src/plugin.py b/plugins/bat/src/plugin.py index c6d10774..c1813bdb 100644 --- a/plugins/bat/src/plugin.py +++ b/plugins/bat/src/plugin.py @@ -39,9 +39,7 @@ def get_config_path() -> Path: system = platform.system() if system == "Windows": - appdata = os.environ.get("APPDATA") or os.path.join( - os.path.expanduser("~"), "AppData", "Roaming" - ) + appdata = os.environ.get("APPDATA") or os.path.join(os.path.expanduser("~"), "AppData", "Roaming") if not appdata: # Keep behavior explicit; host will return structured error. raise RuntimeError("APPDATA environment variable not set") @@ -155,9 +153,7 @@ def build_setting_line(key: str, value: Any) -> str: return f"{key}={s}" -def merge_settings( - lines: List[ConfigLine], settings: Dict[str, Any] -) -> Tuple[List[ConfigLine], bool]: +def merge_settings(lines: List[ConfigLine], settings: Dict[str, Any]) -> Tuple[List[ConfigLine], bool]: # Normalize incoming keys to ensure they start with -- normalized: Dict[str, Any] = {} for k, v in settings.items(): @@ -359,9 +355,7 @@ def dispatch(request: dict) -> Any: if command == "set": return handle_set(request) - return make_response( - request.get("requestId"), False, False, {}, f"Unknown command: {command}" - ) + return make_response(request.get("requestId"), False, False, {}, f"Unknown command: {command}") def main() -> None: @@ -386,9 +380,7 @@ def main() -> None: try: request = json.loads(raw) except Exception as exc: - result = make_response( - None, False, False, {}, f"Failed to parse request: {exc}" - ) + result = make_response(None, False, False, {}, f"Failed to parse request: {exc}") sys.stdout.write(json.dumps(result) + "\n") sys.stdout.flush() return @@ -399,11 +391,7 @@ def main() -> None: result = dispatch(request) except Exception as exc: result = make_response( - ( - request.get("requestId", "unknown") - if isinstance(request, dict) - else "unknown" - ), + (request.get("requestId", "unknown") if isinstance(request, dict) else "unknown"), False, False, {}, diff --git a/plugins/betterdiscord/src/plugin.py b/plugins/betterdiscord/src/plugin.py index debb7736..e9f6252e 100644 --- a/plugins/betterdiscord/src/plugin.py +++ b/plugins/betterdiscord/src/plugin.py @@ -41,11 +41,7 @@ def read_json(file_path: str) -> dict: def deep_merge(original: dict, updates: dict) -> dict: for key, value in updates.items(): - if ( - key in original - and isinstance(original[key], dict) - and isinstance(value, dict) - ): + if key in original and isinstance(original[key], dict) and isinstance(value, dict): deep_merge(original[key], value) else: original[key] = value diff --git a/plugins/betterdiscord/test/test_betterdiscord.py b/plugins/betterdiscord/test/test_betterdiscord.py index 08ec7090..b62f91f8 100644 --- a/plugins/betterdiscord/test/test_betterdiscord.py +++ b/plugins/betterdiscord/test/test_betterdiscord.py @@ -4,9 +4,7 @@ import sys import tempfile -PLUGIN = os.path.abspath( - os.path.join(os.path.dirname(__file__), "..", "src", "plugin.py") -) +PLUGIN = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "src", "plugin.py")) TMP_DIR = tempfile.mkdtemp() diff --git a/plugins/cargo/src/plugin.py b/plugins/cargo/src/plugin.py index eb4d0c8a..e0680e0b 100644 --- a/plugins/cargo/src/plugin.py +++ b/plugins/cargo/src/plugin.py @@ -90,10 +90,7 @@ def check_installed(args: dict, request_id: str) -> dict: installed = ( shutil.which("cargo.exe") is not None or shutil.which("cargo") is not None - or ( - cargo_home != "" - and os.path.exists(os.path.join(cargo_home, "bin", "cargo.exe")) - ) + or (cargo_home != "" and os.path.exists(os.path.join(cargo_home, "bin", "cargo.exe"))) ) return { "requestId": request_id, diff --git a/plugins/cargo/test/test_cargo.py b/plugins/cargo/test/test_cargo.py index b63f04aa..334d441f 100644 --- a/plugins/cargo/test/test_cargo.py +++ b/plugins/cargo/test/test_cargo.py @@ -90,9 +90,7 @@ def test_creates_file_if_not_exists(self): with tempfile.TemporaryDirectory() as tmpdir: config_path = os.path.join(tmpdir, ".cargo", "config.toml") with patch("plugin.get_config_path", return_value=config_path): - result = plugin.apply_config( - {"settings": {"build": {"jobs": 4}}}, {}, "req-10" - ) + result = plugin.apply_config({"settings": {"build": {"jobs": 4}}}, {}, "req-10") self.assertTrue(result["success"]) self.assertTrue(result["changed"]) self.assertTrue(os.path.exists(config_path)) @@ -104,9 +102,7 @@ def test_no_change_when_same(self): with open(config_path, "w") as f: f.write("[build]\njobs = 4\n") with patch("plugin.get_config_path", return_value=config_path): - result = plugin.apply_config( - {"settings": {"build": {"jobs": 4}}}, {}, "req-11" - ) + result = plugin.apply_config({"settings": {"build": {"jobs": 4}}}, {}, "req-11") self.assertTrue(result["success"]) self.assertFalse(result["changed"]) diff --git a/plugins/chezmoi/src/plugin.py b/plugins/chezmoi/src/plugin.py index 8cd368d6..5b469fe7 100644 --- a/plugins/chezmoi/src/plugin.py +++ b/plugins/chezmoi/src/plugin.py @@ -41,9 +41,7 @@ def read_yaml(file_path: str) -> dict: def write_yaml(file_path: str, data: dict) -> None: os.makedirs(os.path.dirname(file_path), exist_ok=True) - fd, temp_path = tempfile.mkstemp( - dir=os.path.dirname(file_path), prefix="chezmoi.yaml." - ) + fd, temp_path = tempfile.mkstemp(dir=os.path.dirname(file_path), prefix="chezmoi.yaml.") try: import yaml @@ -76,9 +74,7 @@ def merge_settings(target: dict, source: dict) -> bool: def check_installed(args: dict, request_id: str) -> dict: - installed = ( - shutil.which("chezmoi.exe") is not None or shutil.which("chezmoi") is not None - ) + installed = shutil.which("chezmoi.exe") is not None or shutil.which("chezmoi") is not None return { "requestId": request_id, "success": True, diff --git a/plugins/chezmoi/test/test_chezmoi.py b/plugins/chezmoi/test/test_chezmoi.py index 56f26649..70af2c84 100644 --- a/plugins/chezmoi/test/test_chezmoi.py +++ b/plugins/chezmoi/test/test_chezmoi.py @@ -26,9 +26,7 @@ def mock_context(): @patch("plugin.shutil.which") def test_check_installed_true(mock_which): mock_which.return_value = "/usr/local/bin/chezmoi" - result = plugin.handle( - {"requestId": "req-1", "command": "check_installed", "args": {}} - ) + result = plugin.handle({"requestId": "req-1", "command": "check_installed", "args": {}}) assert result["success"] is True assert result["data"] is True @@ -37,9 +35,7 @@ def test_check_installed_true(mock_which): @patch("plugin.shutil.which") def test_check_installed_false(mock_which): mock_which.return_value = None - result = plugin.handle( - {"requestId": "req-1", "command": "check_installed", "args": {}} - ) + result = plugin.handle({"requestId": "req-1", "command": "check_installed", "args": {}}) assert result["success"] is True assert result["data"] is False diff --git a/plugins/chocolatey/src/plugin.py b/plugins/chocolatey/src/plugin.py index f00f4855..b23ab840 100644 --- a/plugins/chocolatey/src/plugin.py +++ b/plugins/chocolatey/src/plugin.py @@ -34,9 +34,7 @@ def read_xml(file_path): backup_path = f"{file_path}.corrupted.{uuid.uuid4().hex[:8]}.bak" try: shutil.copy2(file_path, backup_path) - log( - f"Warning: could not parse {file_path}: {e}. Backed up to {backup_path}. Starting with default." - ) + log(f"Warning: could not parse {file_path}: {e}. Backed up to {backup_path}. Starting with default.") except Exception: log(f"Warning: could not parse {file_path}: {e}. Starting with default.") root = ET.Element(f"{{{CHOCO_NS}}}chocolatey") @@ -62,11 +60,7 @@ def merge_settings(tree, source): if value is None: continue - str_val = ( - str(value) - if not isinstance(value, bool) - else ("true" if value else "false") - ) + str_val = str(value) if not isinstance(value, bool) else ("true" if value else "false") if config_el is None: config_el = ET.SubElement(root, f"{{{CHOCO_NS}}}config") @@ -124,9 +118,7 @@ def merge_settings(tree, source): def check_installed(args, request_id): - installed = ( - shutil.which("choco.exe") is not None or shutil.which("choco") is not None - ) + installed = shutil.which("choco.exe") is not None or shutil.which("choco") is not None return { "requestId": request_id, "success": True, diff --git a/plugins/chocolatey/test/test_chocolatey.py b/plugins/chocolatey/test/test_chocolatey.py index 2f12f7ec..672acbf4 100644 --- a/plugins/chocolatey/test/test_chocolatey.py +++ b/plugins/chocolatey/test/test_chocolatey.py @@ -87,17 +87,13 @@ def test_apply_merges_config(tmp_path, monkeypatch): # Assert file modified parsed = ET.parse(config_file).getroot() add_els = parsed.findall(f".//{{{plugin.CHOCO_NS}}}add") - cache_val = next( - el.get("value") for el in add_els if el.get("key") == "cacheLocation" - ) + cache_val = next(el.get("value") for el in add_els if el.get("key") == "cacheLocation") newkey_val = next(el.get("value") for el in add_els if el.get("key") == "newKey") assert cache_val == "new_cache" assert newkey_val == "new_val" feat_els = parsed.findall(f".//{{{plugin.CHOCO_NS}}}feature") - feat_val = next( - el.get("enabled") for el in feat_els if el.get("name") == "checksumFiles" - ) + feat_val = next(el.get("enabled") for el in feat_els if el.get("name") == "checksumFiles") assert feat_val == "true" @@ -142,9 +138,7 @@ def test_apply_creates_file(tmp_path, monkeypatch): parsed = ET.parse(config_file).getroot() add_els = parsed.findall(f".//{{{plugin.CHOCO_NS}}}add") - cache_val = next( - el.get("value") for el in add_els if el.get("key") == "cacheLocation" - ) + cache_val = next(el.get("value") for el in add_els if el.get("key") == "cacheLocation") assert cache_val == "D:\\choco-cache" @@ -157,9 +151,7 @@ def test_unknown_command_via_main(monkeypatch, capsys): "context": {}, } ) - monkeypatch.setattr( - "sys.stdin", type("StdinMock", (), {"read": lambda self: request})() - ) + monkeypatch.setattr("sys.stdin", type("StdinMock", (), {"read": lambda self: request})()) plugin.main() out = capsys.readouterr().out response = json.loads(out) @@ -184,9 +176,7 @@ def test_corrupted_xml_recovery(tmp_path, monkeypatch): # Assert new valid XML parsed = ET.parse(config_file).getroot() add_els = parsed.findall(f".//{{{plugin.CHOCO_NS}}}add") - cache_val = next( - el.get("value") for el in add_els if el.get("key") == "cacheLocation" - ) + cache_val = next(el.get("value") for el in add_els if el.get("key") == "cacheLocation") assert cache_val == "recovered_cache" # Assert backup exists @@ -213,9 +203,7 @@ def test_feature_enabled_false(tmp_path, monkeypatch): parsed = ET.parse(config_file).getroot() feat_els = parsed.findall(f".//{{{plugin.CHOCO_NS}}}feature") - feat_val = next( - el.get("enabled") for el in feat_els if el.get("name") == "checksumFiles" - ) + feat_val = next(el.get("enabled") for el in feat_els if el.get("name") == "checksumFiles") assert feat_val == "false" diff --git a/plugins/curl/src/plugin.py b/plugins/curl/src/plugin.py index bb60b110..433053e1 100644 --- a/plugins/curl/src/plugin.py +++ b/plugins/curl/src/plugin.py @@ -26,9 +26,7 @@ def _backup_corrupt_config(file_path: str, reason: str): timestamp = datetime.datetime.now(datetime.timezone.utc).strftime("%Y%m%d%H%M%S") suffix = uuid.uuid4().hex[:8] backup_path = f"{file_path}.corrupted.{timestamp}.{suffix}" - log( - f"Config read failed ({reason}). Backing up to {backup_path} and starting fresh." - ) + log(f"Config read failed ({reason}). Backing up to {backup_path} and starting fresh.") try: shutil.move(file_path, backup_path) except Exception as backup_e: diff --git a/plugins/deno/src/plugin.py b/plugins/deno/src/plugin.py index 64f87f44..aee4f358 100644 --- a/plugins/deno/src/plugin.py +++ b/plugins/deno/src/plugin.py @@ -100,9 +100,7 @@ def write_deno_config(file_path, config): def check_installed(): return ( - shutil.which("deno.cmd") is not None - or shutil.which("deno.exe") is not None - or shutil.which("deno") is not None + shutil.which("deno.cmd") is not None or shutil.which("deno.exe") is not None or shutil.which("deno") is not None ) diff --git a/plugins/discord/test/test_discord.py b/plugins/discord/test/test_discord.py index 8583dbbf..353aa4e7 100644 --- a/plugins/discord/test/test_discord.py +++ b/plugins/discord/test/test_discord.py @@ -4,9 +4,7 @@ import sys import tempfile -PLUGIN = os.path.abspath( - os.path.join(os.path.dirname(__file__), "..", "src", "plugin.py") -) +PLUGIN = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "src", "plugin.py")) def run_plugin(payload: dict) -> dict: diff --git a/plugins/ditto/test/test_ditto.py b/plugins/ditto/test/test_ditto.py index b4f429aa..1a4e4d81 100644 --- a/plugins/ditto/test/test_ditto.py +++ b/plugins/ditto/test/test_ditto.py @@ -4,9 +4,7 @@ import sys import tempfile -PLUGIN = os.path.abspath( - os.path.join(os.path.dirname(__file__), "..", "src", "plugin.py") -) +PLUGIN = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "src", "plugin.py")) def run_plugin(payload: dict, env: dict = None) -> dict: @@ -125,9 +123,7 @@ def test_dry_run(): def test_unknown_command(): - res = run_plugin( - {"requestId": "6", "command": "explode", "args": {}, "context": {}} - ) + res = run_plugin({"requestId": "6", "command": "explode", "args": {}, "context": {}}) assert "error" in res assert "success" not in res print("✓ unknown_command") diff --git a/plugins/docker/src/plugin.py b/plugins/docker/src/plugin.py index f13bca91..b6ffba03 100644 --- a/plugins/docker/src/plugin.py +++ b/plugins/docker/src/plugin.py @@ -29,9 +29,7 @@ def read_json(file_path: str) -> dict: data = json.load(f) return data if isinstance(data, dict) else {} except json.JSONDecodeError: - timestamp = datetime.datetime.now(datetime.timezone.utc).strftime( - "%Y%m%d%H%M%S" - ) + timestamp = datetime.datetime.now(datetime.timezone.utc).strftime("%Y%m%d%H%M%S") suffix = uuid.uuid4().hex[:8] backup_path = f"{file_path}.corrupted.{timestamp}.{suffix}" log(f"Config corrupted. Backing up to {backup_path} and starting fresh.") @@ -76,9 +74,7 @@ def merge_settings(target: dict, source: dict) -> bool: def check_installed(args: dict, request_id: str) -> dict: # Check for docker or docker.exe in PATH - installed = ( - shutil.which("docker.exe") is not None or shutil.which("docker") is not None - ) + installed = shutil.which("docker.exe") is not None or shutil.which("docker") is not None return { "requestId": request_id, "success": True, diff --git a/plugins/docker/test/test_docker.py b/plugins/docker/test/test_docker.py index abf5630c..b0f3016b 100644 --- a/plugins/docker/test/test_docker.py +++ b/plugins/docker/test/test_docker.py @@ -23,9 +23,7 @@ def tearDown(self): @patch("plugin.shutil.which") def test_check_installed(self, mock_which): - mock_which.return_value = ( - "C:\\Program Files\\Docker\\Docker\\resources\\bin\\docker.exe" - ) + mock_which.return_value = "C:\\Program Files\\Docker\\Docker\\resources\\bin\\docker.exe" response = plugin.check_installed({}, "req-1") @@ -133,9 +131,7 @@ def test_read_corrupted_config(self, mock_get_path): # Verify backup was created dir_name = os.path.dirname(self.config_path) - backups = [ - f for f in os.listdir(dir_name) if f.startswith("settings.json.corrupted.") - ] + backups = [f for f in os.listdir(dir_name) if f.startswith("settings.json.corrupted.")] self.assertEqual(len(backups), 1) diff --git a/plugins/espanso/src/plugin.py b/plugins/espanso/src/plugin.py index 30a79bdd..6bf1a015 100644 --- a/plugins/espanso/src/plugin.py +++ b/plugins/espanso/src/plugin.py @@ -165,11 +165,7 @@ def _parse_yaml(text: str) -> dict: continue # Continuation key under a list item: " key: value" - if ( - current_item is not None - and ":" in stripped - and not stripped.startswith("-") - ): + if current_item is not None and ":" in stripped and not stripped.startswith("-"): k, _, v = stripped.partition(":") k = k.strip() v = v.strip() @@ -257,9 +253,7 @@ def deep_merge_lists(existing: list, incoming: list, key: str = "trigger") -> li New items in incoming are appended. """ merged = deepcopy(existing) - index = { - item.get(key): i for i, item in enumerate(merged) if isinstance(item, dict) - } + index = {item.get(key): i for i, item in enumerate(merged) if isinstance(item, dict)} for item in incoming: if not isinstance(item, dict): continue diff --git a/plugins/espanso/test/test_espanso.py b/plugins/espanso/test/test_espanso.py index ed18be69..9ddc250d 100644 --- a/plugins/espanso/test/test_espanso.py +++ b/plugins/espanso/test/test_espanso.py @@ -146,9 +146,7 @@ def test_merge_lists_preserves_untouched(): {"trigger": ":email", "replace": "me@example.com"}, {"trigger": ":hello", "replace": "Hello!"}, ] - result = plugin.deep_merge_lists( - existing, [{"trigger": ":email", "replace": "new@example.com"}] - ) + result = plugin.deep_merge_lists(existing, [{"trigger": ":email", "replace": "new@example.com"}]) assert len(result) == 2 assert result[1]["trigger"] == ":hello" @@ -184,9 +182,7 @@ def test_merge_config_no_change(existing_config): def test_merge_config_does_not_mutate(existing_config): original = deepcopy(existing_config) - plugin.merge_config( - existing_config, {"matches": [{"trigger": ":new", "replace": "x"}]} - ) + plugin.merge_config(existing_config, {"matches": [{"trigger": ":new", "replace": "x"}]}) assert existing_config == original @@ -242,9 +238,7 @@ def test_handle_apply_writes_file(tmp_path, monkeypatch): def test_handle_apply_dry_run_no_write(tmp_path, monkeypatch): base_yml = tmp_path / "espanso" / "match" / "base.yml" monkeypatch.setattr(plugin, "get_base_yml_path", lambda: base_yml) - result = plugin.handle_apply( - "req-4", {"matches": [{"trigger": ":t", "replace": "x"}]}, dry_run=True - ) + result = plugin.handle_apply("req-4", {"matches": [{"trigger": ":t", "replace": "x"}]}, dry_run=True) assert result == {"requestId": "req-4", "success": True, "changed": True} assert not base_yml.exists() @@ -277,9 +271,7 @@ def test_handle_apply_merges_not_overwrites(tmp_path, monkeypatch, existing_conf def test_handle_apply_creates_missing_dirs(tmp_path, monkeypatch): base_yml = tmp_path / "deep" / "nested" / "base.yml" monkeypatch.setattr(plugin, "get_base_yml_path", lambda: base_yml) - result = plugin.handle_apply( - "req-7", {"matches": [{"trigger": ":t", "replace": "test"}]} - ) + result = plugin.handle_apply("req-7", {"matches": [{"trigger": ":t", "replace": "test"}]}) assert result["success"] is True assert base_yml.exists() @@ -342,9 +334,7 @@ def test_protocol_dry_run_top_level_ignored(tmp_path, monkeypatch): "context": {}, } ) - assert ( - base_yml.exists() - ), "top-level dry_run must be ignored; file should have been written" + assert base_yml.exists(), "top-level dry_run must be ignored; file should have been written" def test_protocol_unknown_command(): @@ -354,9 +344,7 @@ def test_protocol_unknown_command(): def test_protocol_request_id_propagated(installed_appdata): - resp = run_main( - {"requestId": "my-unique-id", "command": "check_installed", "args": {}} - ) + resp = run_main({"requestId": "my-unique-id", "command": "check_installed", "args": {}}) assert resp["requestId"] == "my-unique-id" diff --git a/plugins/everything/test/test_everything.py b/plugins/everything/test/test_everything.py index 785381c4..8a814b60 100644 --- a/plugins/everything/test/test_everything.py +++ b/plugins/everything/test/test_everything.py @@ -3,9 +3,7 @@ import subprocess import sys -PLUGIN = os.path.abspath( - os.path.join(os.path.dirname(__file__), "..", "src", "plugin.py") -) +PLUGIN = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "src", "plugin.py")) def run_plugin(payload: dict): diff --git a/plugins/flow-launcher/src/plugin.py b/plugins/flow-launcher/src/plugin.py index 3cab3fcf..358ffe97 100644 --- a/plugins/flow-launcher/src/plugin.py +++ b/plugins/flow-launcher/src/plugin.py @@ -93,18 +93,14 @@ def check_installed(args): def main(): input_data = sys.stdin.read() if not input_data: - sys.stdout.write( - json.dumps({"requestId": "unknown", "error": "No input received"}) + "\n" - ) + sys.stdout.write(json.dumps({"requestId": "unknown", "error": "No input received"}) + "\n") return try: request = json.loads(input_data) except Exception as e: log(f"Failed to parse request: {e}") - sys.stdout.write( - json.dumps({"requestId": "unknown", "error": "Invalid JSON"}) + "\n" - ) + sys.stdout.write(json.dumps({"requestId": "unknown", "error": "Invalid JSON"}) + "\n") return command = request.get("command") diff --git a/plugins/flow-launcher/test/test_plugin.py b/plugins/flow-launcher/test/test_plugin.py index 43b1641a..78378aa6 100644 --- a/plugins/flow-launcher/test/test_plugin.py +++ b/plugins/flow-launcher/test/test_plugin.py @@ -113,9 +113,7 @@ def test_apply_writes_changes(): # Verify write was called written_data = "".join( - call.args[0] - for call in mock_write().write.call_args_list - if isinstance(call.args[0], str) + call.args[0] for call in mock_write().write.call_args_list if isinstance(call.args[0], str) ) written_json = json.loads(written_data) assert written_json["Hotkey"] == "Alt+Space" diff --git a/plugins/fzf/src/plugin.py b/plugins/fzf/src/plugin.py index 33fbc435..242afa2f 100644 --- a/plugins/fzf/src/plugin.py +++ b/plugins/fzf/src/plugin.py @@ -136,9 +136,7 @@ def read_fzfrc(file_path: str) -> Tuple[Dict[str, str], bool]: key, value = parse_export_line(line) config[key] = value except (OSError, UnicodeError, ValueError) as exc: - log( - f"Warning: failed to parse existing config ({exc}). Backing up and starting fresh." - ) + log(f"Warning: failed to parse existing config ({exc}). Backing up and starting fresh.") return {}, True return config, False @@ -147,12 +145,7 @@ def read_fzfrc(file_path: str) -> Tuple[Dict[str, str], bool]: def shell_quote(value: str) -> str: if "\n" in value or "\r" in value: raise ValueError("fzf config values cannot contain newlines") - escaped = ( - value.replace("\\", "\\\\") - .replace('"', '\\"') - .replace("$", "\\$") - .replace("`", "\\`") - ) + escaped = value.replace("\\", "\\\\").replace('"', '\\"').replace("$", "\\$").replace("`", "\\`") return f'"{escaped}"' @@ -161,9 +154,7 @@ def write_fzfrc(file_path: str, config: Dict[str, str]) -> None: if parent: os.makedirs(parent, exist_ok=True) - fd, temp_path = tempfile.mkstemp( - prefix=".fzfrc.", suffix=".tmp", dir=parent or None, text=True - ) + fd, temp_path = tempfile.mkstemp(prefix=".fzfrc.", suffix=".tmp", dir=parent or None, text=True) try: with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as f: for key in sorted(config): @@ -183,9 +174,7 @@ def backup_corrupted_config(file_path: str) -> str: return backup_path -def build_default_opts( - settings: Dict[str, Any], existing_value: str | None -) -> str | None: +def build_default_opts(settings: Dict[str, Any], existing_value: str | None) -> str | None: explicit = settings.get("FZF_DEFAULT_OPTS") if explicit is not None: opts = stringify_value(explicit).strip() @@ -225,9 +214,7 @@ def merge_config(target: Dict[str, str], settings: Dict[str, Any]) -> bool: return changed -def apply_config( - args: Dict[str, Any], context: Dict[str, Any], request_id: str -) -> Dict[str, Any]: +def apply_config(args: Dict[str, Any], context: Dict[str, Any], request_id: str) -> Dict[str, Any]: dry_run = context.get("dryRun", False) is True try: @@ -293,9 +280,7 @@ def dispatch(request: Dict[str, Any]) -> Dict[str, Any]: def main() -> None: input_data = sys.stdin.read() if not input_data: - sys.stdout.write( - json.dumps(response("unknown", False, False, {}, "Empty stdin")) + "\n" - ) + sys.stdout.write(json.dumps(response("unknown", False, False, {}, "Empty stdin")) + "\n") sys.stdout.flush() return diff --git a/plugins/fzf/test/test_plugin.py b/plugins/fzf/test/test_plugin.py index 86d3271a..53284b42 100644 --- a/plugins/fzf/test/test_plugin.py +++ b/plugins/fzf/test/test_plugin.py @@ -186,13 +186,8 @@ def test_corrupted_config_backup(tmp_path): assert_schema(response, success=True, changed=True) backups = list(tmp_path.glob(f"{config_file.name}.bak.*")) assert len(backups) == 1 - assert ( - backups[0].read_text(encoding="utf-8") - == 'export FZF_DEFAULT_OPTS="unterminated\n' - ) - assert 'export FZF_DEFAULT_OPTS="--height 60%"' in config_file.read_text( - encoding="utf-8" - ) + assert backups[0].read_text(encoding="utf-8") == 'export FZF_DEFAULT_OPTS="unterminated\n' + assert 'export FZF_DEFAULT_OPTS="--height 60%"' in config_file.read_text(encoding="utf-8") assert "Backed up corrupted fzf config" in result.stderr assert response["data"]["corrupted"] is True assert "backupPath" in response["data"] @@ -214,9 +209,7 @@ def test_utf8_decode_failure_backup(tmp_path): backups = list(tmp_path.glob(f"{config_file.name}.bak.*")) assert len(backups) == 1 assert backups[0].read_bytes() == b"\xff\xfe\xfa" - assert 'export FZF_DEFAULT_OPTS="--height 70%"' in config_file.read_text( - encoding="utf-8" - ) + assert 'export FZF_DEFAULT_OPTS="--height 70%"' in config_file.read_text(encoding="utf-8") def test_shell_escaping_round_trip(tmp_path): diff --git a/plugins/gh-dash/src/plugin.py b/plugins/gh-dash/src/plugin.py index 604a4021..780ce265 100644 --- a/plugins/gh-dash/src/plugin.py +++ b/plugins/gh-dash/src/plugin.py @@ -35,9 +35,7 @@ def _parse_scalar(val: str): return True if val.lower() == "false": return False - if (val.startswith('"') and val.endswith('"')) or ( - val.startswith("'") and val.endswith("'") - ): + if (val.startswith('"') and val.endswith('"')) or (val.startswith("'") and val.endswith("'")): return val[1:-1] try: return int(val) @@ -112,17 +110,11 @@ def _parse_sequence(lines: list, i: int, base_indent: int) -> tuple: if not craw.strip() or craw.strip().startswith("#"): j += 1 continue - if ( - len(craw) - len(craw.lstrip()) - ) < cont_indent or craw.lstrip().startswith("- "): + if (len(craw) - len(craw.lstrip())) < cont_indent or craw.lstrip().startswith("- "): break cm = _KEY_RE.match(craw.lstrip()) if cm: - item_dict[cm.group(1).strip()] = ( - _parse_scalar(cm.group(2).strip()) - if cm.group(2).strip() - else None - ) + item_dict[cm.group(1).strip()] = _parse_scalar(cm.group(2).strip()) if cm.group(2).strip() else None j += 1 i = j items.append(item_dict) @@ -225,11 +217,7 @@ def write_yaml(file_path: str, data: dict) -> None: parent = os.path.dirname(file_path) if parent: os.makedirs(parent, exist_ok=True) - text = ( - _yaml.dump(data, default_flow_style=False, sort_keys=False) - if _HAS_PYYAML - else _dumps_fallback(data) - ) + text = _yaml.dump(data, default_flow_style=False, sort_keys=False) if _HAS_PYYAML else _dumps_fallback(data) tmp = file_path + ".tmp" try: with open(tmp, "w", encoding="utf-8") as fh: diff --git a/plugins/gh-dash/test/test_gh_dash.py b/plugins/gh-dash/test/test_gh_dash.py index 041c1b99..a76b5481 100644 --- a/plugins/gh-dash/test/test_gh_dash.py +++ b/plugins/gh-dash/test/test_gh_dash.py @@ -15,9 +15,7 @@ import yaml -sys.path.insert( - 0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "src")) -) +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "src"))) import plugin @@ -34,9 +32,7 @@ def run_main(self, payload: dict) -> dict: def test_check_installed_true_via_which(self): with patch( "plugin.shutil.which", - side_effect=lambda name: ( - "/usr/local/bin/gh-dash" if name == "gh-dash" else None - ), + side_effect=lambda name: "/usr/local/bin/gh-dash" if name == "gh-dash" else None, ): response = self.run_main( { @@ -323,9 +319,7 @@ def test_apply_uses_settings_wrapper(self): "args": { "settings": { "defaultLimit": 40, - "prSections": [ - {"title": "Wrapped", "filters": "is:open"} - ], + "prSections": [{"title": "Wrapped", "filters": "is:open"}], } }, "context": {"dryRun": False}, diff --git a/plugins/gh/test/test_gh.py b/plugins/gh/test/test_gh.py index 1e4ee885..b998ebc8 100644 --- a/plugins/gh/test/test_gh.py +++ b/plugins/gh/test/test_gh.py @@ -15,9 +15,7 @@ import yaml -sys.path.insert( - 0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "src")) -) +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "src"))) import plugin @@ -32,13 +30,9 @@ def run_main(self, payload: dict) -> dict: def test_check_installed_returns_true_when_gh_is_found(self): with patch( "plugin.shutil.which", - side_effect=lambda name: ( - "C:/Program Files/gh/gh.exe" if name in {"gh", "gh.exe"} else None - ), + side_effect=lambda name: "C:/Program Files/gh/gh.exe" if name in {"gh", "gh.exe"} else None, ): - response = self.run_main( - {"requestId": "req-1", "command": "check_installed", "args": {}} - ) + response = self.run_main({"requestId": "req-1", "command": "check_installed", "args": {}}) self.assertEqual(response["requestId"], "req-1") self.assertTrue(response["success"]) @@ -47,9 +41,7 @@ def test_check_installed_returns_true_when_gh_is_found(self): def test_check_installed_returns_false_when_gh_is_missing(self): with patch("plugin.shutil.which", return_value=None): - response = self.run_main( - {"requestId": "req-2", "command": "check_installed", "args": {}} - ) + response = self.run_main({"requestId": "req-2", "command": "check_installed", "args": {}}) self.assertEqual(response["requestId"], "req-2") self.assertTrue(response["success"]) diff --git a/plugins/github-desktop/src/plugin.py b/plugins/github-desktop/src/plugin.py index ef1cde11..f5cb460a 100644 --- a/plugins/github-desktop/src/plugin.py +++ b/plugins/github-desktop/src/plugin.py @@ -19,9 +19,7 @@ def deep_merge(base, update): def check_installed() -> bool: """Decoupled utility to determine installation state profiles cleanly.""" appdata = os.environ.get("APPDATA", "") - config_path = ( - os.path.join(appdata, "GitHub Desktop", "config.json") if appdata else "" - ) + config_path = os.path.join(appdata, "GitHub Desktop", "config.json") if appdata else "" return bool(config_path and os.path.exists(config_path)) @@ -97,9 +95,7 @@ def main(): if not os.path.exists(config_dir): os.makedirs(config_dir, exist_ok=True) try: - fd, temp_path = tempfile.mkstemp( - dir=config_dir, prefix="config_", suffix=".json" - ) + fd, temp_path = tempfile.mkstemp(dir=config_dir, prefix="config_", suffix=".json") with os.fdopen(fd, "w", encoding="utf-8") as f: json.dump(updated_config, f, indent=2) os.replace(temp_path, config_path) @@ -120,9 +116,7 @@ def main(): json.dumps( { "requestId": request_id, - "error": ( - f"Unknown execution command structural parameter: {command}" - ), + "error": (f"Unknown execution command structural parameter: {command}"), } ) ) diff --git a/plugins/github-desktop/test/test_github_desktop.py b/plugins/github-desktop/test/test_github_desktop.py index 760d70ad..a1b52c95 100644 --- a/plugins/github-desktop/test/test_github_desktop.py +++ b/plugins/github-desktop/test/test_github_desktop.py @@ -54,9 +54,7 @@ def test_check_installed_protocol_parity(self, mock_exists, mock_stdin): @patch("tempfile.mkstemp") @patch("os.fdopen", new_callable=mock_open) @patch("os.replace") - def test_settings_deep_merge_atomic_write( - self, mock_rep, mock_fd, mock_stemp, mock_f, mock_ex, mock_in - ): + def test_settings_deep_merge_atomic_write(self, mock_rep, mock_fd, mock_stemp, mock_f, mock_ex, mock_in): """Verifies deep merges calculate variance parameters seamlessly.""" mock_in.read.return_value = json.dumps( { diff --git a/plugins/go/src/plugin.py b/plugins/go/src/plugin.py index d40950d5..f66496d2 100644 --- a/plugins/go/src/plugin.py +++ b/plugins/go/src/plugin.py @@ -42,10 +42,7 @@ def read_go_env(go_bin, key): text=True, ) if result.returncode != 0: - error = ( - result.stderr.strip() - or f"go env {key} failed with exit code {result.returncode}" - ) + error = result.stderr.strip() or f"go env {key} failed with exit code {result.returncode}" raise RuntimeError(error) return result.stdout.rstrip("\r\n") @@ -58,10 +55,7 @@ def write_go_env(go_bin, key, value): text=True, ) if result.returncode != 0: - error = ( - result.stderr.strip() - or f"go env -w {key}=... failed with exit code {result.returncode}" - ) + error = result.stderr.strip() or f"go env -w {key}=... failed with exit code {result.returncode}" raise RuntimeError(error) @@ -122,9 +116,7 @@ def apply_config(args, context, request_id): } if dry_run: - log( - f"dry_run: would update Go environment settings: {json.dumps(changes, sort_keys=True)}" - ) + log(f"dry_run: would update Go environment settings: {json.dumps(changes, sort_keys=True)}") return { "requestId": request_id, "success": True, @@ -192,9 +184,7 @@ def error_response(request_id, error): def main(): input_data = sys.stdin.read() if not input_data: - sys.stdout.write( - json.dumps(error_response("unknown", "No input provided")) + "\n" - ) + sys.stdout.write(json.dumps(error_response("unknown", "No input provided")) + "\n") sys.stdout.flush() return @@ -205,11 +195,7 @@ def main(): raise ValueError("request must be an object") response = handle(request) except Exception as error: - request_id = ( - request.get("requestId", "unknown") - if isinstance(request, dict) - else "unknown" - ) + request_id = request.get("requestId", "unknown") if isinstance(request, dict) else "unknown" response = error_response(request_id, error) sys.stdout.write(json.dumps(response) + "\n") diff --git a/plugins/go/test/test_go.py b/plugins/go/test/test_go.py index 506d66b5..8c97f2f9 100644 --- a/plugins/go/test/test_go.py +++ b/plugins/go/test/test_go.py @@ -45,9 +45,7 @@ def test_check_installed_false(self): class TestGoEnvParsing(unittest.TestCase): def test_read_go_env_strips_trailing_newlines_only(self): - with patch.object( - plugin.subprocess, "run", return_value=completed(stdout="/tmp/go\n") - ) as run: + with patch.object(plugin.subprocess, "run", return_value=completed(stdout="/tmp/go\n")) as run: value = plugin.read_go_env("/usr/bin/go", "GOPATH") self.assertEqual(value, "/tmp/go") @@ -139,9 +137,7 @@ def test_noop_when_values_already_match(self): def test_missing_go_is_graceful(self): with patch.object(plugin, "go_executable", return_value=None): - result = plugin.apply_config( - {"settings": {"GOPATH": "/tmp/go"}}, {}, "req-no-go" - ) + result = plugin.apply_config({"settings": {"GOPATH": "/tmp/go"}}, {}, "req-no-go") self.assertFalse(result["success"]) self.assertFalse(result["changed"]) @@ -149,9 +145,7 @@ def test_missing_go_is_graceful(self): def test_rejects_unsupported_setting(self): with patch.object(plugin, "go_executable", return_value="/usr/bin/go"): - result = plugin.apply_config( - {"settings": {"NOT_GO_ENV": "x"}}, {}, "req-bad" - ) + result = plugin.apply_config({"settings": {"NOT_GO_ENV": "x"}}, {}, "req-bad") self.assertFalse(result["success"]) self.assertIn("Unsupported Go environment setting", result["error"]) @@ -177,9 +171,7 @@ def test_handle_uses_settings_from_args(self): ) self.assertTrue(result["success"]) - apply.assert_called_once_with( - {"settings": {"GOPATH": "/workspace/go"}}, {}, "req" - ) + apply.assert_called_once_with({"settings": {"GOPATH": "/workspace/go"}}, {}, "req") def test_main_returns_json_error_for_invalid_json(self): result = self.run_main("{not json") @@ -196,9 +188,7 @@ def test_main_returns_json_error_for_empty_input(self): self.assertEqual(result["error"], "No input provided") def test_unknown_command(self): - result = plugin.handle( - {"requestId": "req-unknown", "command": "wat", "args": {}, "context": {}} - ) + result = plugin.handle({"requestId": "req-unknown", "command": "wat", "args": {}, "context": {}}) self.assertFalse(result["success"]) self.assertEqual(result["error"], "Unknown command: wat") diff --git a/plugins/greenshot/src/plugin.py b/plugins/greenshot/src/plugin.py index 55bb7736..f7ad1708 100644 --- a/plugins/greenshot/src/plugin.py +++ b/plugins/greenshot/src/plugin.py @@ -72,9 +72,7 @@ def main(): try: input_data = sys.stdin.read().strip() if not input_data: - sys.stdout.write( - json.dumps({"requestId": request_id, "error": "Empty stdin"}) + "\n" - ) + sys.stdout.write(json.dumps({"requestId": request_id, "error": "Empty stdin"}) + "\n") sys.stdout.flush() return diff --git a/plugins/helix-editor/src/plugin.py b/plugins/helix-editor/src/plugin.py index 4ac36f2e..9c5dfa98 100644 --- a/plugins/helix-editor/src/plugin.py +++ b/plugins/helix-editor/src/plugin.py @@ -44,9 +44,7 @@ def read_toml(file_path: str) -> dict: log(f"Warning: could not parse {file_path} using tomllib: {e}") return {} else: - log( - "Warning: tomllib not available (requires Python 3.11+). Starting with empty config." - ) + log("Warning: tomllib not available (requires Python 3.11+). Starting with empty config.") return {} @@ -67,9 +65,7 @@ def dump_value(v): def _dump_dict_recursive(d: dict, prefix: str, lines: list): # Primitives first for k, v in d.items(): - if not isinstance(v, dict) and not ( - isinstance(v, list) and len(v) > 0 and isinstance(v[0], dict) - ): + if not isinstance(v, dict) and not (isinstance(v, list) and len(v) > 0 and isinstance(v[0], dict)): lines.append(f"{k} = {dump_value(v)}") # Array of Tables @@ -123,11 +119,7 @@ def merge_settings(target: dict, source: dict) -> bool: for item in value: if "name" in item: existing_item = next( - ( - i - for i in target[key] - if isinstance(i, dict) and i.get("name") == item["name"] - ), + (i for i in target[key] if isinstance(i, dict) and i.get("name") == item["name"]), None, ) if existing_item: diff --git a/plugins/helix-editor/test/test_plugin.py b/plugins/helix-editor/test/test_plugin.py index 267f7b54..b983fdc8 100644 --- a/plugins/helix-editor/test/test_plugin.py +++ b/plugins/helix-editor/test/test_plugin.py @@ -38,21 +38,15 @@ def test_apply_config_standard(self, mock_get_helix_dir): self.assertTrue(result["changed"]) # Verify files were written - self.assertTrue( - os.path.exists(os.path.join(self.mock_helix_dir, "config.toml")) - ) - self.assertTrue( - os.path.exists(os.path.join(self.mock_helix_dir, "languages.toml")) - ) + self.assertTrue(os.path.exists(os.path.join(self.mock_helix_dir, "config.toml"))) + self.assertTrue(os.path.exists(os.path.join(self.mock_helix_dir, "languages.toml"))) config_toml = open(os.path.join(self.mock_helix_dir, "config.toml")).read() self.assertIn('theme = "dark"', config_toml) self.assertIn("[editor]", config_toml) self.assertIn('line-number = "relative"', config_toml) - languages_toml = open( - os.path.join(self.mock_helix_dir, "languages.toml") - ).read() + languages_toml = open(os.path.join(self.mock_helix_dir, "languages.toml")).read() self.assertIn("[[language]]", languages_toml) self.assertIn('name = "python"', languages_toml) self.assertIn("auto-format = true", languages_toml) @@ -69,9 +63,7 @@ def test_apply_config_dry_run(self, mock_get_helix_dir): self.assertTrue(result["changed"]) # Verify file NOT written - self.assertFalse( - os.path.exists(os.path.join(self.mock_helix_dir, "config.toml")) - ) + self.assertFalse(os.path.exists(os.path.join(self.mock_helix_dir, "config.toml"))) @patch("plugin.get_helix_dir") def test_apply_config_idempotent(self, mock_get_helix_dir): @@ -113,9 +105,7 @@ def test_unwrapped_args(self, mock_get_helix_dir): self.assertIn("[editor.cursor-shape]", config_toml) self.assertIn('insert = "bar"', config_toml) - languages_toml = open( - os.path.join(self.mock_helix_dir, "languages.toml") - ).read() + languages_toml = open(os.path.join(self.mock_helix_dir, "languages.toml")).read() self.assertIn("[[language]]", languages_toml) self.assertIn('name = "rust"', languages_toml) diff --git a/plugins/irfanview/src/plugin.py b/plugins/irfanview/src/plugin.py index dec78bb8..fdb219cd 100644 --- a/plugins/irfanview/src/plugin.py +++ b/plugins/irfanview/src/plugin.py @@ -84,15 +84,8 @@ def apply_settings(settings: dict, dry_run: bool) -> bool: changed = True for k, v in keys.items(): str_k = str(k) - str_v = ( - "1" - if isinstance(v, bool) and v - else ("0" if isinstance(v, bool) else str(v)) - ) - if ( - not parser.has_option(section, str_k) - or parser.get(section, str_k) != str_v - ): + str_v = "1" if isinstance(v, bool) and v else ("0" if isinstance(v, bool) else str(v)) + if not parser.has_option(section, str_k) or parser.get(section, str_k) != str_v: parser.set(section, str_k, str_v) changed = True @@ -104,9 +97,7 @@ def apply_settings(settings: dict, dry_run: bool) -> bool: return True os.makedirs(os.path.dirname(ini_path), exist_ok=True) - fd, temp_path = tempfile.mkstemp( - dir=os.path.dirname(ini_path), prefix="i_view.ini." - ) + fd, temp_path = tempfile.mkstemp(dir=os.path.dirname(ini_path), prefix="i_view.ini.") try: with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as f: parser.write(f, space_around_delimiters=False) diff --git a/plugins/irfanview/test/test_irfanview.py b/plugins/irfanview/test/test_irfanview.py index 67ac9dd2..af24eb84 100644 --- a/plugins/irfanview/test/test_irfanview.py +++ b/plugins/irfanview/test/test_irfanview.py @@ -95,9 +95,7 @@ def test_apply_settings_dry_run(self): def test_handle_request(self): with mock_env(): # Test check_installed - res = plugin.handle_request( - {"command": "check_installed", "requestId": "1"} - ) + res = plugin.handle_request({"command": "check_installed", "requestId": "1"}) self.assertEqual(res["requestId"], "1") self.assertIn("data", res) diff --git a/plugins/keepassxc/src/plugin.py b/plugins/keepassxc/src/plugin.py index fc186956..47545f03 100644 --- a/plugins/keepassxc/src/plugin.py +++ b/plugins/keepassxc/src/plugin.py @@ -26,14 +26,10 @@ def read_text(file_path: str) -> str: with open(file_path, "r", encoding="utf-8") as f: return f.read() except (OSError, UnicodeDecodeError) as e: - timestamp = datetime.datetime.now(datetime.timezone.utc).strftime( - "%Y%m%d%H%M%S" - ) + timestamp = datetime.datetime.now(datetime.timezone.utc).strftime("%Y%m%d%H%M%S") suffix_str = uuid.uuid4().hex[:8] backup_path = f"{file_path}.corrupted.{timestamp}.{suffix_str}" - log( - f"Config corrupted. Backing up to {backup_path} and starting fresh. Error: {e}" - ) + log(f"Config corrupted. Backing up to {backup_path} and starting fresh. Error: {e}") try: shutil.move(file_path, backup_path) except Exception as backup_e: @@ -79,9 +75,7 @@ def parse_ini(text: str) -> tuple: if match_kv: key = match_kv.group(1).strip() val = match_kv.group(2).strip() - current_block["lines"].append( - {"type": "kv", "raw": line, "key": key, "val": val} - ) + current_block["lines"].append({"type": "kv", "raw": line, "key": key, "val": val}) else: current_block["lines"].append({"type": "unknown", "raw": line}) @@ -162,11 +156,7 @@ def merge_settings(blocks: list, args: dict) -> bool: if not block: block = {"name": section_name, "lines": []} - if ( - blocks - and blocks[-1]["lines"] - and blocks[-1]["lines"][-1]["type"] != "empty" - ): + if blocks and blocks[-1]["lines"] and blocks[-1]["lines"][-1]["type"] != "empty": blocks[-1]["lines"].append({"type": "empty", "raw": ""}) block["lines"].append({"type": "section", "raw": f"[{section_name}]"}) @@ -190,11 +180,7 @@ def check_installed(args: dict, request_id: str) -> dict: paths_to_check = [ os.path.join(program_files, "KeePassXC", "KeePassXC.exe"), - ( - os.path.join(local_appdata, "KeePassXC", "KeePassXC.exe") - if local_appdata - else "" - ), + (os.path.join(local_appdata, "KeePassXC", "KeePassXC.exe") if local_appdata else ""), ] for p in paths_to_check: diff --git a/plugins/keepassxc/test/test_keepassxc.py b/plugins/keepassxc/test/test_keepassxc.py index 7666ef72..75b2c61c 100644 --- a/plugins/keepassxc/test/test_keepassxc.py +++ b/plugins/keepassxc/test/test_keepassxc.py @@ -42,7 +42,9 @@ def test_merge_settings(self): output = plugin.serialize_ini(blocks, has_newline, is_crlf) - expected = "[General]\nAutoSaveOnExit=true\nAutoSaveAfterEveryChange=true\n\n[Security]\nLockDatabaseIdle=true\n" + expected = ( + "[General]\nAutoSaveOnExit=true\nAutoSaveAfterEveryChange=true\n\n[Security]\nLockDatabaseIdle=true\n" + ) self.assertEqual(output, expected) def test_merge_no_change(self): @@ -85,9 +87,7 @@ def test_apply_config_real_run(self, mock_write, mock_read, mock_get_path): self.assertTrue(res["success"]) self.assertTrue(res["changed"]) self.assertEqual(res["requestId"], "req-3") - mock_write.assert_called_once_with( - "dummy.ini", "[General]\nAutoSaveOnExit=true\n" - ) + mock_write.assert_called_once_with("dummy.ini", "[General]\nAutoSaveOnExit=true\n") @patch("plugin.get_config_path") @patch("plugin.read_text") diff --git a/plugins/lazydocker/src/plugin.py b/plugins/lazydocker/src/plugin.py index 76b8d5c9..aa37ea23 100644 --- a/plugins/lazydocker/src/plugin.py +++ b/plugins/lazydocker/src/plugin.py @@ -96,9 +96,7 @@ def apply_config(args, context, request_id): try: shutil.copy2(config_path, backup_path) except Exception as backup_e: - sys.stderr.write( - f"[lazydocker-plugin] Warning: Failed to create backup: {str(backup_e)}\n" - ) + sys.stderr.write(f"[lazydocker-plugin] Warning: Failed to create backup: {str(backup_e)}\n") # Deep merge merged_config, changed = deep_merge(existing_config, settings) @@ -117,14 +115,10 @@ def apply_config(args, context, request_id): if not os.path.exists(config_dir): os.makedirs(config_dir, mode=0o700, exist_ok=True) - fd, temp_path = tempfile.mkstemp( - prefix="lazydocker-", suffix=".tmp", dir=os.path.dirname(config_path) - ) + fd, temp_path = tempfile.mkstemp(prefix="lazydocker-", suffix=".tmp", dir=os.path.dirname(config_path)) try: with os.fdopen(fd, "w", encoding="utf-8") as f: - yaml.dump( - merged_config, f, default_flow_style=False, sort_keys=False - ) + yaml.dump(merged_config, f, default_flow_style=False, sort_keys=False) os.replace(temp_path, config_path) except BaseException: os.unlink(temp_path) @@ -139,9 +133,7 @@ def apply_config(args, context, request_id): "error": f"Failed to write config: {str(e)}", } else: - sys.stderr.write( - f"[lazydocker-plugin] Would update {config_path} with new merged config\n" - ) + sys.stderr.write(f"[lazydocker-plugin] Would update {config_path} with new merged config\n") return {"requestId": request_id, "success": True, "changed": True, "data": None} diff --git a/plugins/lazygit/src/plugin.py b/plugins/lazygit/src/plugin.py index 7bd5de83..97d189a0 100644 --- a/plugins/lazygit/src/plugin.py +++ b/plugins/lazygit/src/plugin.py @@ -67,9 +67,7 @@ def merge_settings(target: dict, source: dict) -> bool: def check_installed(args: dict, request_id: str) -> dict: - installed = ( - shutil.which("lazygit.exe") is not None or shutil.which("lazygit") is not None - ) + installed = shutil.which("lazygit.exe") is not None or shutil.which("lazygit") is not None return { "requestId": request_id, "success": True, diff --git a/plugins/lazygit/test/test_lazygit.py b/plugins/lazygit/test/test_lazygit.py index 2a616567..990b4131 100644 --- a/plugins/lazygit/test/test_lazygit.py +++ b/plugins/lazygit/test/test_lazygit.py @@ -13,9 +13,7 @@ import yaml -PLUGIN = os.path.abspath( - os.path.join(os.path.dirname(__file__), "..", "src", "plugin.py") -) +PLUGIN = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "src", "plugin.py")) def run_plugin(payload: dict) -> dict: @@ -130,9 +128,7 @@ def test_idempotent_apply(): def test_unknown_command(): - res = run_plugin( - {"requestId": "5", "command": "explode", "args": {}, "context": {}} - ) + res = run_plugin({"requestId": "5", "command": "explode", "args": {}, "context": {}}) assert not res["success"] assert "error" in res diff --git a/plugins/miniconda/src/plugin.py b/plugins/miniconda/src/plugin.py index ee37ec84..5cd1a6cb 100644 --- a/plugins/miniconda/src/plugin.py +++ b/plugins/miniconda/src/plugin.py @@ -88,9 +88,7 @@ def main(): changed = True if dry_run: - send_response( - request_id, data={"status": "dry-run complete"}, changed=changed - ) + send_response(request_id, data={"status": "dry-run complete"}, changed=changed) return if changed: diff --git a/plugins/mise/src/plugin.py b/plugins/mise/src/plugin.py index 0d7f95ea..401dd93b 100644 --- a/plugins/mise/src/plugin.py +++ b/plugins/mise/src/plugin.py @@ -67,9 +67,7 @@ def write_section(section_name, section_data): if isinstance(contents, dict): write_section(section, contents) - fd, temp_path = tempfile.mkstemp( - dir=os.path.dirname(file_path), prefix="config.toml." - ) + fd, temp_path = tempfile.mkstemp(dir=os.path.dirname(file_path), prefix="config.toml.") try: with os.fdopen(fd, "w", encoding="utf-8") as f: f.write("\n".join(lines).strip() + "\n") diff --git a/plugins/mise/test/test_mise.py b/plugins/mise/test/test_mise.py index b7ab16f8..9f30c35f 100644 --- a/plugins/mise/test/test_mise.py +++ b/plugins/mise/test/test_mise.py @@ -25,9 +25,7 @@ def mock_context(): @patch("plugin.shutil.which") def test_check_installed_true(mock_which): mock_which.return_value = "/usr/local/bin/mise" - result = plugin.handle( - {"requestId": "req-1", "command": "check_installed", "args": {}} - ) + result = plugin.handle({"requestId": "req-1", "command": "check_installed", "args": {}}) assert result["success"] is True assert result["data"] is True @@ -36,9 +34,7 @@ def test_check_installed_true(mock_which): @patch("plugin.shutil.which") def test_check_installed_false(mock_which): mock_which.return_value = None - result = plugin.handle( - {"requestId": "req-1", "command": "check_installed", "args": {}} - ) + result = plugin.handle({"requestId": "req-1", "command": "check_installed", "args": {}}) assert result["success"] is True assert result["data"] is False diff --git a/plugins/notepadplusplus/test/test_notepadplusplus.py b/plugins/notepadplusplus/test/test_notepadplusplus.py index a2f3832c..04cdb1a9 100644 --- a/plugins/notepadplusplus/test/test_notepadplusplus.py +++ b/plugins/notepadplusplus/test/test_notepadplusplus.py @@ -4,9 +4,7 @@ import sys import tempfile -PLUGIN = os.path.abspath( - os.path.join(os.path.dirname(__file__), "..", "src", "plugin.py") -) +PLUGIN = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "src", "plugin.py")) def run_plugin(payload: dict) -> dict: @@ -103,9 +101,7 @@ def test_idempotent_apply(): def test_unknown_command(): - res = run_plugin( - {"requestId": "5", "command": "explode", "args": {}, "context": {}} - ) + res = run_plugin({"requestId": "5", "command": "explode", "args": {}, "context": {}}) assert not res["success"] assert "error" in res diff --git a/plugins/npm/test/test_npm.py b/plugins/npm/test/test_npm.py index ab716e52..ac56547f 100644 --- a/plugins/npm/test/test_npm.py +++ b/plugins/npm/test/test_npm.py @@ -28,7 +28,9 @@ def run_plugin(input_dict): def test_read_npmrc(tmp_path): - npmrc_content = "registry=https://registry.npmjs.org/\n@scope:registry=https://custom.com/\n; comment\nsave-exact=true\n" + npmrc_content = ( + "registry=https://registry.npmjs.org/\n@scope:registry=https://custom.com/\n; comment\nsave-exact=true\n" + ) npmrc_file = tmp_path / ".npmrc" npmrc_file.write_text(npmrc_content) diff --git a/plugins/nuget/src/plugin.py b/plugins/nuget/src/plugin.py index 0693f065..080b3118 100644 --- a/plugins/nuget/src/plugin.py +++ b/plugins/nuget/src/plugin.py @@ -32,9 +32,7 @@ def read_xml(file_path): backup_path = f"{file_path}.corrupted.{uuid.uuid4().hex}.bak" try: shutil.copy2(file_path, backup_path) - log( - f"Warning: could not parse {file_path}: {e}. Backed up to {backup_path}." - ) + log(f"Warning: could not parse {file_path}: {e}. Backed up to {backup_path}.") except Exception: log(f"Warning: could not parse {file_path}: {e}. Starting with default.") root = ET.Element("configuration") @@ -173,11 +171,7 @@ def merge_settings(tree, settings): def check_installed(): config_path = get_config_path() - return ( - shutil.which("nuget") is not None - or shutil.which("dotnet") is not None - or os.path.exists(config_path) - ) + return shutil.which("nuget") is not None or shutil.which("dotnet") is not None or os.path.exists(config_path) def apply_config(args, request_id): @@ -213,21 +207,14 @@ def main(): input_data = sys.stdin.read() if not input_data: - sys.stdout.write( - json.dumps({"requestId": "unknown", "error": "No input received"}) + "\n" - ) + sys.stdout.write(json.dumps({"requestId": "unknown", "error": "No input received"}) + "\n") sys.stdout.flush() return try: request = json.loads(input_data) except Exception as e: - sys.stdout.write( - json.dumps( - {"requestId": "unknown", "error": f"Failed to parse request: {e}"} - ) - + "\n" - ) + sys.stdout.write(json.dumps({"requestId": "unknown", "error": f"Failed to parse request: {e}"}) + "\n") sys.stdout.flush() return diff --git a/plugins/nuget/test/test_nuget.py b/plugins/nuget/test/test_nuget.py index 15a0d1c7..0edb64d2 100644 --- a/plugins/nuget/test/test_nuget.py +++ b/plugins/nuget/test/test_nuget.py @@ -37,11 +37,7 @@ def test_apply_dry_run_no_write(tmp_path, monkeypatch): monkeypatch.setattr(plugin, "get_config_path", lambda: str(config_file)) args = { "dryRun": True, - "settings": { - "packageSources": [ - {"name": "nuget", "source": "https://api.nuget.org/v3/index.json"} - ] - }, + "settings": {"packageSources": [{"name": "nuget", "source": "https://api.nuget.org/v3/index.json"}]}, } result = plugin.apply_config(args, "test-001") assert result["changed"] is True @@ -57,11 +53,7 @@ def test_apply_success(tmp_path, monkeypatch): monkeypatch.setattr(plugin, "get_config_path", lambda: str(config_file)) args = { "dryRun": False, - "settings": { - "packageSources": [ - {"name": "nuget", "source": "https://api.nuget.org/v3/index.json"} - ] - }, + "settings": {"packageSources": [{"name": "nuget", "source": "https://api.nuget.org/v3/index.json"}]}, } result = plugin.apply_config(args, "test-001") assert result["changed"] is True @@ -72,19 +64,13 @@ def test_apply_noop(tmp_path, monkeypatch): config_file = tmp_path / "NuGet.Config" root = ET.Element("configuration") sources = ET.SubElement(root, "packageSources") - ET.SubElement( - sources, "add", {"key": "nuget", "value": "https://api.nuget.org/v3/index.json"} - ) + ET.SubElement(sources, "add", {"key": "nuget", "value": "https://api.nuget.org/v3/index.json"}) tree = ET.ElementTree(root) tree.write(config_file, encoding="utf-8") monkeypatch.setattr(plugin, "get_config_path", lambda: str(config_file)) args = { "dryRun": False, - "settings": { - "packageSources": [ - {"name": "nuget", "source": "https://api.nuget.org/v3/index.json"} - ] - }, + "settings": {"packageSources": [{"name": "nuget", "source": "https://api.nuget.org/v3/index.json"}]}, } result = plugin.apply_config(args, "test-001") assert result["changed"] is False @@ -102,16 +88,10 @@ def test_invalid_settings_handled(tmp_path, monkeypatch): def test_config_missing(tmp_path, monkeypatch): - monkeypatch.setattr( - plugin, "get_config_path", lambda: str(tmp_path / "missing.Config") - ) + monkeypatch.setattr(plugin, "get_config_path", lambda: str(tmp_path / "missing.Config")) args = { "dryRun": False, - "settings": { - "packageSources": [ - {"name": "nuget", "source": "https://api.nuget.org/v3/index.json"} - ] - }, + "settings": {"packageSources": [{"name": "nuget", "source": "https://api.nuget.org/v3/index.json"}]}, } result = plugin.apply_config(args, "test-001") assert result["changed"] is True diff --git a/plugins/nvm/src/plugin.py b/plugins/nvm/src/plugin.py index fb0e1c64..20ed7e65 100644 --- a/plugins/nvm/src/plugin.py +++ b/plugins/nvm/src/plugin.py @@ -8,9 +8,7 @@ from typing import Optional SETTING_FILE = "settings.txt" -LINE_RE = re.compile( - r"^(?P\s*)(?P[^=\s#;]+)(?P\s*=\s*)(?P.*)$" -) +LINE_RE = re.compile(r"^(?P\s*)(?P[^=\s#;]+)(?P\s*=\s*)(?P.*)$") def log(msg: str) -> None: @@ -19,9 +17,7 @@ def log(msg: str) -> None: def get_appdata_dir() -> str: - appdata = os.environ.get("APPDATA") or os.path.join( - os.path.expanduser("~"), "AppData", "Roaming" - ) + appdata = os.environ.get("APPDATA") or os.path.join(os.path.expanduser("~"), "AppData", "Roaming") if appdata: return appdata @@ -128,9 +124,7 @@ def render_lines(lines: list[SettingLine]) -> str: continue value = "" if line.value is None else line.value - rendered.append( - f"{line.leading}{line.key}{line.separator}{value}{line.suffix}\n" - ) + rendered.append(f"{line.leading}{line.key}{line.separator}{value}{line.suffix}\n") text = "".join(rendered) @@ -208,9 +202,7 @@ def write_atomic(file_path: str, content: str) -> None: def check_installed(_args: dict, request_id: str) -> dict: installed = ( - os.path.exists(get_settings_path()) - or shutil.which("nvm.exe") is not None - or shutil.which("nvm") is not None + os.path.exists(get_settings_path()) or shutil.which("nvm.exe") is not None or shutil.which("nvm") is not None ) return { diff --git a/plugins/nvm/test/test_nvm.py b/plugins/nvm/test/test_nvm.py index cac30fbe..58785609 100644 --- a/plugins/nvm/test/test_nvm.py +++ b/plugins/nvm/test/test_nvm.py @@ -138,9 +138,7 @@ def test_apply_preserves_inline_comment_suffix(tmp_path): assert response["success"] is True assert response["changed"] is True - assert settings_path.read_text(encoding="utf-8") == ( - "root=D:\\tools\\nvm # install directory\n" - ) + assert settings_path.read_text(encoding="utf-8") == ("root=D:\\tools\\nvm # install directory\n") def test_apply_dry_run_does_not_write(tmp_path): diff --git a/plugins/obs-studio/src/plugin.py b/plugins/obs-studio/src/plugin.py index da356920..db022b98 100644 --- a/plugins/obs-studio/src/plugin.py +++ b/plugins/obs-studio/src/plugin.py @@ -26,14 +26,10 @@ def read_ini(path): try: config.read(path, encoding="utf-8") except configparser.Error as e: - timestamp = datetime.datetime.now(datetime.timezone.utc).strftime( - "%Y%m%d%H%M%S" - ) + timestamp = datetime.datetime.now(datetime.timezone.utc).strftime("%Y%m%d%H%M%S") suffix = uuid.uuid4().hex[:8] backup_path = f"{path}.corrupted.{timestamp}.{suffix}" - log( - f"Config corrupted. Backing up to {backup_path} and starting fresh. Error: {e}" - ) + log(f"Config corrupted. Backing up to {backup_path} and starting fresh. Error: {e}") try: shutil.move(path, backup_path) except Exception as backup_e: @@ -97,9 +93,7 @@ def apply_global_ini(obs_dir, general, dry_run): changed = False if general: - mapped = { - GENERAL_KEY_MAP[k]: v for k, v in general.items() if k in GENERAL_KEY_MAP - } + mapped = {GENERAL_KEY_MAP[k]: v for k, v in general.items() if k in GENERAL_KEY_MAP} if mapped: if dry_run: log(f"Would update {global_ini_path} [General] with: {mapped}") @@ -188,16 +182,12 @@ def apply_profile_ini(obs_dir, profile_name, args, dry_run): if output_section: changed = merge_ini_section(config, "Output", output_section) or changed if simple_output: - changed = ( - merge_ini_section(config, "SimpleOutput", simple_output) or changed - ) + changed = merge_ini_section(config, "SimpleOutput", simple_output) or changed else: if output_section: changed = merge_ini_section(config, "Output", output_section) or changed if simple_output: - changed = ( - merge_ini_section(config, "SimpleOutput", simple_output) or changed - ) + changed = merge_ini_section(config, "SimpleOutput", simple_output) or changed hotkeys = args.get("hotkeys") if hotkeys: @@ -262,19 +252,13 @@ def apply_config(args, context, request_id): changed = apply_global_ini(obs_dir, general, dry_run) or changed profile_name = args.get("profile") or get_active_profile(obs_dir) - has_profile_settings = any( - args.get(k) for k in ("video", "audio", "output", "hotkeys") - ) + has_profile_settings = any(args.get(k) for k in ("video", "audio", "output", "hotkeys")) if has_profile_settings: if not profile_name: - log( - "No profile specified and no active profile found — skipping video/audio/output/hotkeys" - ) + log("No profile specified and no active profile found — skipping video/audio/output/hotkeys") else: - changed = ( - apply_profile_ini(obs_dir, profile_name, args, dry_run) or changed - ) + changed = apply_profile_ini(obs_dir, profile_name, args, dry_run) or changed profiles = args.get("profiles", []) if profiles: diff --git a/plugins/obs-studio/test/test_obs_studio.py b/plugins/obs-studio/test/test_obs_studio.py index 7cba06b3..219a6e7b 100644 --- a/plugins/obs-studio/test/test_obs_studio.py +++ b/plugins/obs-studio/test/test_obs_studio.py @@ -5,9 +5,7 @@ import sys import tempfile -PLUGIN = os.path.abspath( - os.path.join(os.path.dirname(__file__), "..", "src", "plugin.py") -) +PLUGIN = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "src", "plugin.py")) def run_plugin(payload: dict, env=None) -> dict: @@ -221,12 +219,8 @@ def test_apply_creates_profile_directories(): assert res["success"] assert res["changed"] - assert os.path.exists( - os.path.join(obs_dir, "basic", "profiles", "Streaming", "basic.ini") - ) - assert os.path.exists( - os.path.join(obs_dir, "basic", "profiles", "Recording", "basic.ini") - ) + assert os.path.exists(os.path.join(obs_dir, "basic", "profiles", "Streaming", "basic.ini")) + assert os.path.exists(os.path.join(obs_dir, "basic", "profiles", "Recording", "basic.ini")) print("PASS: apply_creates_profile_directories") @@ -257,9 +251,7 @@ def test_apply_profile_explicit_name(): def test_unknown_command(): - res = run_plugin( - {"requestId": "10", "command": "explode", "args": {}, "context": {}} - ) + res = run_plugin({"requestId": "10", "command": "explode", "args": {}, "context": {}}) assert not res["success"] assert "error" in res print("PASS: unknown_command") diff --git a/plugins/obsidian/src/plugin.py b/plugins/obsidian/src/plugin.py index 82058ec0..6ad21575 100644 --- a/plugins/obsidian/src/plugin.py +++ b/plugins/obsidian/src/plugin.py @@ -53,24 +53,18 @@ def make_request(url: str): """Make an HTTP request to the given URL with a user agent""" if os.environ.get("WINHOME_TEST_MOCK_URLOPEN") == "1": if "community-plugins.json" in url: - data = json.dumps( - [{"id": "obsidian-git", "repo": "mgmeyers/obsidian-git"}] - ).encode("utf-8") + data = json.dumps([{"id": "obsidian-git", "repo": "mgmeyers/obsidian-git"}]).encode("utf-8") return MockResponse(data) elif "releases/latest" in url: data = json.dumps({"tag_name": "v1.2.3"}).encode("utf-8") return MockResponse(data) elif "manifest.json" in url: - data = json.dumps({"id": "obsidian-git", "version": "1.2.3"}).encode( - "utf-8" - ) + data = json.dumps({"id": "obsidian-git", "version": "1.2.3"}).encode("utf-8") return MockResponse(data) else: return MockResponse(b"mock content") - req = urllib.request.Request( - url, headers={"User-Agent": "WinHome-Environment-Manager/1.0"} - ) + req = urllib.request.Request(url, headers={"User-Agent": "WinHome-Environment-Manager/1.0"}) return urllib.request.urlopen(req) @@ -224,9 +218,7 @@ def download_plugin(vault_path: str, plugin_id: str, repo: str, version: str) -> continue if not success and file != "styles.css": - raise Exception( - f"Failed to download required asset '{file}' for {plugin_id}: {last_error}" - ) + raise Exception(f"Failed to download required asset '{file}' for {plugin_id}: {last_error}") # Apply Changes @@ -348,9 +340,7 @@ def apply_config(args: dict, context: dict, request_id: str) -> dict: continue if vault.get("settings"): - res = apply_vault_settings( - vault_path, vault["settings"], context.get("dryRun", False) - ) + res = apply_vault_settings(vault_path, vault["settings"], context.get("dryRun", False)) if not res["success"]: overall_success = False if res["changed"]: diff --git a/plugins/obsidian/test/test_obsidian.py b/plugins/obsidian/test/test_obsidian.py index 7edc1e67..da7ec87e 100644 --- a/plugins/obsidian/test/test_obsidian.py +++ b/plugins/obsidian/test/test_obsidian.py @@ -4,9 +4,7 @@ import sys import tempfile -PLUGIN = os.path.abspath( - os.path.join(os.path.dirname(__file__), "..", "src", "plugin.py") -) +PLUGIN = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "src", "plugin.py")) def run_plugin(payload: dict) -> dict: @@ -56,9 +54,7 @@ def test_apply_settings(): assert res["changed"] app = json.loads(open(os.path.join(vault, ".obsidian", "app.json")).read()) - appearance = json.loads( - open(os.path.join(vault, ".obsidian", "appearance.json")).read() - ) + appearance = json.loads(open(os.path.join(vault, ".obsidian", "appearance.json")).read()) assert app["spellcheck"] == True assert appearance["accentColor"] == "#002aff" print("✓ apply_settings") @@ -135,9 +131,7 @@ def test_nonexistent_vault(): def test_unknown_command(): - res = run_plugin( - {"requestId": "6", "command": "explode", "args": {}, "context": {}} - ) + res = run_plugin({"requestId": "6", "command": "explode", "args": {}, "context": {}}) assert not res["success"] assert "error" in res print("✓ unknown_command") @@ -161,9 +155,7 @@ def test_install_plugin(): assert os.path.exists(os.path.join(plugin_dir, "main.js")) assert os.path.exists(os.path.join(plugin_dir, "manifest.json")) - enabled = json.loads( - open(os.path.join(vault, ".obsidian", "community-plugins.json")).read() - ) + enabled = json.loads(open(os.path.join(vault, ".obsidian", "community-plugins.json")).read()) assert "obsidian-git" in enabled print("✓ install_plugin") @@ -217,9 +209,7 @@ def test_uninstall_plugin(): plugin_dir = os.path.join(vault, ".obsidian", "plugins", "obsidian-git") assert not os.path.exists(plugin_dir) - enabled = json.loads( - open(os.path.join(vault, ".obsidian", "community-plugins.json")).read() - ) + enabled = json.loads(open(os.path.join(vault, ".obsidian", "community-plugins.json")).read()) assert "obsidian-git" not in enabled print("✓ uninstall_plugin") diff --git a/plugins/ohmyposh/src/plugin.py b/plugins/ohmyposh/src/plugin.py index e73e9b0c..9989b948 100644 --- a/plugins/ohmyposh/src/plugin.py +++ b/plugins/ohmyposh/src/plugin.py @@ -4,17 +4,11 @@ # Dynamic Profile Discovery -_MODERN_PATH = os.path.expandvars( - r"%USERPROFILE%\Documents\PowerShell\Microsoft.PowerShell_profile.ps1" -) -_LEGACY_PATH = os.path.expandvars( - r"%USERPROFILE%\Documents\WindowsPowerShell\Microsoft.PowerShell_profile.ps1" -) +_MODERN_PATH = os.path.expandvars(r"%USERPROFILE%\Documents\PowerShell\Microsoft.PowerShell_profile.ps1") +_LEGACY_PATH = os.path.expandvars(r"%USERPROFILE%\Documents\WindowsPowerShell\Microsoft.PowerShell_profile.ps1") # Default modern path, fallback to legacy -PROFILE_PATH = ( - _MODERN_PATH if os.path.exists(os.path.dirname(_MODERN_PATH)) else _LEGACY_PATH -) +PROFILE_PATH = _MODERN_PATH if os.path.exists(os.path.dirname(_MODERN_PATH)) else _LEGACY_PATH OMP_BEGIN = "# OH-MY-POSH-PLUGIN BEGIN" OMP_END = "# OH-MY-POSH-PLUGIN END" @@ -58,9 +52,7 @@ def apply_config(args: dict, context: dict, request_id: str) -> dict: "error": "No theme specified", } - profile_path = ( - args.get("profile") or args.get("settings", {}).get("profile") or PROFILE_PATH - ) + profile_path = args.get("profile") or args.get("settings", {}).get("profile") or PROFILE_PATH desired_line = build_omp_line(theme) current_content = read_profile(profile_path) @@ -90,12 +82,7 @@ def apply_config(args: dict, context: dict, request_id: str) -> dict: log(f"Updated theme to {theme}") else: # first time setting - new_profile = ( - current_content.rstrip("\n") - + ("\n\n" if current_content else "") - + omp_block - + "\n" - ) + new_profile = current_content.rstrip("\n") + ("\n\n" if current_content else "") + omp_block + "\n" log(f"Created new omp block for {theme}") write_profile(profile_path, new_profile) @@ -112,9 +99,7 @@ def apply_config(args: dict, context: dict, request_id: str) -> dict: def check_installed(args: dict, request_id: str) -> dict: theme = args.get("theme") or args.get("settings", {}).get("theme") - profile_path = ( - args.get("profile") or args.get("settings", {}).get("profile") or PROFILE_PATH - ) + profile_path = args.get("profile") or args.get("settings", {}).get("profile") or PROFILE_PATH current_content = read_profile(profile_path) if theme: diff --git a/plugins/ohmyposh/test/test_omp.py b/plugins/ohmyposh/test/test_omp.py index e1d74620..b08e0354 100644 --- a/plugins/ohmyposh/test/test_omp.py +++ b/plugins/ohmyposh/test/test_omp.py @@ -5,9 +5,7 @@ import tempfile # Compute dynamic path to the oh-my-posh plugin script -PLUGIN = os.path.abspath( - os.path.join(os.path.dirname(__file__), "..", "src", "plugin.py") -) +PLUGIN = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "src", "plugin.py")) def run_plugin(payload: dict) -> dict: @@ -54,10 +52,7 @@ def test_apply_fresh_install(): content = read_file(profile) assert "# OH-MY-POSH-PLUGIN BEGIN" in content - assert ( - 'oh-my-posh init pwsh --config "tokyonight_storm" | Invoke-Expression' - in content - ) + assert 'oh-my-posh init pwsh --config "tokyonight_storm" | Invoke-Expression' in content assert "# OH-MY-POSH-PLUGIN END" in content print("✓ test_apply_fresh_install") @@ -217,9 +212,7 @@ def test_check_installed(): def test_invalid_payloads(): - res_err = run_plugin( - {"requestId": "6a", "command": "apply", "args": {}, "context": {}} - ) + res_err = run_plugin({"requestId": "6a", "command": "apply", "args": {}, "context": {}}) assert not res_err["success"] assert "error" in res_err diff --git a/plugins/opencode/src/plugin.py b/plugins/opencode/src/plugin.py index 510a29f6..5bf97924 100644 --- a/plugins/opencode/src/plugin.py +++ b/plugins/opencode/src/plugin.py @@ -21,9 +21,7 @@ def log(message: str) -> None: sys.stderr.flush() -def response( - request_id: str, success: bool, changed: bool, error=None, data=None -) -> dict: +def response(request_id: str, success: bool, changed: bool, error=None, data=None) -> dict: result = { "requestId": request_id, "success": success, @@ -74,11 +72,7 @@ def strip_jsonc_comments(text: str) -> str: if char == "/" and next_char == "*": index += 2 while index < len(text): - if ( - text[index] == "*" - and index + 1 < len(text) - and text[index + 1] == "/" - ): + if text[index] == "*" and index + 1 < len(text) and text[index + 1] == "/": index += 2 break index += 1 @@ -129,21 +123,13 @@ def user_home() -> str: def get_config_path(args: dict, context: dict) -> str: explicit_path = ( - args.get("configPath") - or args.get("config_path") - or context.get("configPath") - or context.get("config_path") + args.get("configPath") or args.get("config_path") or context.get("configPath") or context.get("config_path") ) if explicit_path: - return os.path.abspath( - os.path.expandvars(os.path.expanduser(str(explicit_path))) - ) + return os.path.abspath(os.path.expandvars(os.path.expanduser(str(explicit_path)))) project_root = ( - args.get("projectRoot") - or args.get("project_root") - or context.get("projectRoot") - or context.get("project_root") + args.get("projectRoot") or args.get("project_root") or context.get("projectRoot") or context.get("project_root") ) if project_root: return os.path.join( @@ -161,11 +147,7 @@ def desired_config_from_args(args: dict) -> dict: if "settings" in args and isinstance(args["settings"], dict): return copy.deepcopy(args["settings"]) - return { - key: copy.deepcopy(value) - for key, value in args.items() - if key not in PATH_ARG_KEYS - } + return {key: copy.deepcopy(value) for key, value in args.items() if key not in PATH_ARG_KEYS} def deep_merge(target: dict, source: dict) -> bool: @@ -208,9 +190,7 @@ def apply_config(args: dict, context: dict, request_id: str) -> dict: return response(request_id, success=True, changed=False) if dry_run: - log( - f"Would update {config_path} with keys: {', '.join(sorted(desired.keys()))}" - ) + log(f"Would update {config_path} with keys: {', '.join(sorted(desired.keys()))}") return response(request_id, success=True, changed=True) write_json(config_path, next_config) diff --git a/plugins/opencode/test/test_opencode.py b/plugins/opencode/test/test_opencode.py index 31538ab2..729127c4 100644 --- a/plugins/opencode/test/test_opencode.py +++ b/plugins/opencode/test/test_opencode.py @@ -243,9 +243,7 @@ def test_apply_supports_legacy_config_and_top_level_dry_run(): def test_strip_jsonc_comments_keeps_comment_like_text_in_strings(): plugin = load_plugin() - cleaned = plugin.strip_jsonc_comments( - '{"url": "https://example.com//ok", // remove me\n "value": 1}' - ) + cleaned = plugin.strip_jsonc_comments('{"url": "https://example.com//ok", // remove me\n "value": 1}') assert json.loads(cleaned) == { "url": "https://example.com//ok", @@ -255,9 +253,7 @@ def test_strip_jsonc_comments_keeps_comment_like_text_in_strings(): def test_strip_jsonc_comments_handles_block_comments(): plugin = load_plugin() - cleaned = plugin.strip_jsonc_comments( - '{"url": "https://example.com//ok", /* remove\n me */ "value": 1}' - ) + cleaned = plugin.strip_jsonc_comments('{"url": "https://example.com//ok", /* remove\n me */ "value": 1}') assert json.loads(cleaned) == { "url": "https://example.com//ok", diff --git a/plugins/openssh/src/plugin.py b/plugins/openssh/src/plugin.py index cdab44dc..efc2f6d9 100644 --- a/plugins/openssh/src/plugin.py +++ b/plugins/openssh/src/plugin.py @@ -65,13 +65,9 @@ def parse_ssh_config(text: str) -> tuple: if key.lower() == "host": current_block = {"name": val, "lines": []} blocks.append(current_block) - current_block["lines"].append( - {"type": "kv", "raw": line, "key": key, "val": val} - ) + current_block["lines"].append({"type": "kv", "raw": line, "key": key, "val": val}) else: - current_block["lines"].append( - {"type": "kv", "raw": line, "key": key, "val": val} - ) + current_block["lines"].append({"type": "kv", "raw": line, "key": key, "val": val}) else: current_block["lines"].append({"type": "unknown", "raw": line}) @@ -145,10 +141,7 @@ def merge_settings(blocks: list, args: dict) -> bool: # find host blocks (case-insensitive) while preserving original casing in output normalized_host_name = str(host_name).casefold() matching_blocks = [ - b - for b in blocks - if b["name"] is not None - and str(b["name"]).casefold() == normalized_host_name + b for b in blocks if b["name"] is not None and str(b["name"]).casefold() == normalized_host_name ] if not matching_blocks: @@ -156,11 +149,7 @@ def merge_settings(blocks: list, args: dict) -> bool: new_block = {"name": host_name, "lines": []} # ensure previous block ends with empty line for spacing - if ( - blocks - and blocks[-1]["lines"] - and blocks[-1]["lines"][-1]["type"] != "empty" - ): + if blocks and blocks[-1]["lines"] and blocks[-1]["lines"][-1]["type"] != "empty": blocks[-1]["lines"].append({"type": "empty", "raw": ""}) new_block["lines"].append( @@ -182,10 +171,7 @@ def merge_settings(blocks: list, args: dict) -> bool: # Update key in all blocks where it exists key_found = False for b in matching_blocks: - if any( - line["type"] == "kv" and line["key"].lower() == k.lower() - for line in b["lines"] - ): + if any(line["type"] == "kv" and line["key"].lower() == k.lower() for line in b["lines"]): if merge_kv(b, k, v): changed = True key_found = True diff --git a/plugins/pip/src/plugin.py b/plugins/pip/src/plugin.py index 08f446b2..21ee0524 100644 --- a/plugins/pip/src/plugin.py +++ b/plugins/pip/src/plugin.py @@ -43,9 +43,7 @@ def apply_config(args, context, request_id): try: config.read(pip_ini_path, encoding="utf-8") except configparser.Error as e: - log( - f"Warning: Failed to parse existing config ({e}). Backing up and starting fresh." - ) + log(f"Warning: Failed to parse existing config ({e}). Backing up and starting fresh.") backup_path = f"{pip_ini_path}.{int(time.time())}.bak" shutil.copy2(pip_ini_path, backup_path) # Start fresh with empty config @@ -67,10 +65,7 @@ def apply_config(args, context, request_id): else: str_value = str(value) - if ( - not config.has_option("global", key) - or config.get("global", key) != str_value - ): + if not config.has_option("global", key) or config.get("global", key) != str_value: config.set("global", key, str_value) changed = True diff --git a/plugins/pip/test/test_pip.py b/plugins/pip/test/test_pip.py index f09ca378..7b8b1ffe 100644 --- a/plugins/pip/test/test_pip.py +++ b/plugins/pip/test/test_pip.py @@ -115,11 +115,7 @@ def test_apply_config_corrupted_recovery(self, mock_get_path): self.assertTrue(response["changed"]) # Verify it backed up the file - backups = [ - f - for f in os.listdir(os.path.dirname(self.pip_ini_path)) - if f.endswith(".bak") - ] + backups = [f for f in os.listdir(os.path.dirname(self.pip_ini_path)) if f.endswith(".bak")] self.assertTrue(len(backups) > 0) # Verify new config diff --git a/plugins/pnpm/src/plugin.py b/plugins/pnpm/src/plugin.py index 4f55786c..5c20d7e8 100644 --- a/plugins/pnpm/src/plugin.py +++ b/plugins/pnpm/src/plugin.py @@ -131,9 +131,7 @@ def write_npmrc(file_path: str, config: dict) -> None: def check_installed(args: dict, request_id: str) -> dict: installed = ( - shutil.which("pnpm.cmd") is not None - or shutil.which("pnpm.exe") is not None - or shutil.which("pnpm") is not None + shutil.which("pnpm.cmd") is not None or shutil.which("pnpm.exe") is not None or shutil.which("pnpm") is not None ) return { diff --git a/plugins/pnpm/test/test_pnpm.py b/plugins/pnpm/test/test_pnpm.py index 0918305a..9d857aca 100644 --- a/plugins/pnpm/test/test_pnpm.py +++ b/plugins/pnpm/test/test_pnpm.py @@ -4,9 +4,7 @@ import sys import tempfile -PLUGIN = os.path.abspath( - os.path.join(os.path.dirname(__file__), "..", "src", "plugin.py") -) +PLUGIN = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "src", "plugin.py")) def run_plugin(payload: dict) -> dict: @@ -21,9 +19,7 @@ def run_plugin(payload: dict) -> dict: def test_check_installed_response_format(): - res = run_plugin( - {"requestId": "1", "command": "check_installed", "args": {}, "context": {}} - ) + res = run_plugin({"requestId": "1", "command": "check_installed", "args": {}, "context": {}}) assert res["requestId"] == "1" assert res["success"] @@ -40,9 +36,7 @@ def test_apply_config_dry_run(): { "requestId": "2", "command": "apply", - "args": { - "settings": {"storeDir": "C:/pnpm/store", "autoInstallPeers": True} - }, + "args": {"settings": {"storeDir": "C:/pnpm/store", "autoInstallPeers": True}}, "context": {"dryRun": True}, } ) @@ -164,9 +158,7 @@ def test_invalid_settings_returns_json_error(): def test_empty_stdin_returns_json_error(): - result = subprocess.run( - [sys.executable, PLUGIN], input="", capture_output=True, text=True - ) + result = subprocess.run([sys.executable, PLUGIN], input="", capture_output=True, text=True) res = json.loads(result.stdout.strip()) diff --git a/plugins/powershell/src/plugin.py b/plugins/powershell/src/plugin.py index c57f5d44..028f73ae 100644 --- a/plugins/powershell/src/plugin.py +++ b/plugins/powershell/src/plugin.py @@ -53,11 +53,7 @@ def read_profile(file_path: str): if MARKER_START in content and MARKER_END in content: parts = content.split(MARKER_START) before = parts[0] - after = ( - parts[1].split(MARKER_END)[1] - if len(parts[1].split(MARKER_END)) > 1 - else "" - ) + after = parts[1].split(MARKER_END)[1] if len(parts[1].split(MARKER_END)) > 1 else "" return before, after return content, "" except Exception as e: @@ -86,13 +82,9 @@ def generate_script(settings: dict) -> str: cmd = v["init"].get("cmd", "") hook = v["init"].get("hook", "") if cmd and hook: - lines.append( - f"Invoke-Expression (& {k} init powershell --cmd {cmd} --hook {hook} | Out-String)" - ) + lines.append(f"Invoke-Expression (& {k} init powershell --cmd {cmd} --hook {hook} | Out-String)") else: - lines.append( - f"Invoke-Expression (& {k} init powershell | Out-String)" - ) + lines.append(f"Invoke-Expression (& {k} init powershell | Out-String)") prompt = settings.get("prompt", {}) if prompt: @@ -100,9 +92,7 @@ def generate_script(settings: dict) -> str: if p_type == "oh-my-posh": theme = prompt.get("theme", "") if theme: - lines.append( - f"oh-my-posh init powershell --config '{theme}' | Invoke-Expression" - ) + lines.append(f"oh-my-posh init powershell --config '{theme}' | Invoke-Expression") else: lines.append("oh-my-posh init powershell | Invoke-Expression") diff --git a/plugins/powertoys/src/main.py b/plugins/powertoys/src/main.py index 07929491..bbd7d957 100644 --- a/plugins/powertoys/src/main.py +++ b/plugins/powertoys/src/main.py @@ -159,9 +159,7 @@ def apply_config(args, context, request_id): if first_error is None: first_error = error else: - success, general_changed, error = apply_general_config( - general_config, current_general - ) + success, general_changed, error = apply_general_config(general_config, current_general) if not success: log(error) overall_success = False diff --git a/plugins/powertoys/test/test_powertoys.py b/plugins/powertoys/test/test_powertoys.py index 61bee669..856cb2c7 100644 --- a/plugins/powertoys/test/test_powertoys.py +++ b/plugins/powertoys/test/test_powertoys.py @@ -5,9 +5,7 @@ import tempfile # Resolve the main plugin path relative to the test file location -PLUGIN = os.path.abspath( - os.path.join(os.path.dirname(__file__), "..", "src", "main.py") -) +PLUGIN = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "src", "main.py")) def run_plugin(payload: dict, env: dict) -> dict: diff --git a/plugins/rainmeter/src/plugin.py b/plugins/rainmeter/src/plugin.py index e25ef6cc..f93799ea 100644 --- a/plugins/rainmeter/src/plugin.py +++ b/plugins/rainmeter/src/plugin.py @@ -78,10 +78,7 @@ def apply_config(args: dict, context: dict, request_id: str) -> dict: for key, value in keys.items(): str_val = str(value) - if ( - not parser.has_option(section, key) - or parser.get(section, key) != str_val - ): + if not parser.has_option(section, key) or parser.get(section, key) != str_val: parser.set(section, key, str_val) changed = True @@ -113,9 +110,7 @@ def apply_config(args: dict, context: dict, request_id: str) -> dict: def main(): input_data = sys.stdin.read() if not input_data: - sys.stdout.write( - json.dumps({"requestId": "unknown", "error": "Empty input"}) + "\n" - ) + sys.stdout.write(json.dumps({"requestId": "unknown", "error": "Empty input"}) + "\n") sys.stdout.flush() return diff --git a/plugins/rainmeter/test/test_rainmeter.py b/plugins/rainmeter/test/test_rainmeter.py index e980333a..07d9997e 100644 --- a/plugins/rainmeter/test/test_rainmeter.py +++ b/plugins/rainmeter/test/test_rainmeter.py @@ -19,9 +19,7 @@ def setUp(self): self.temp_dir = tempfile.mkdtemp() self.appdata = os.path.join(self.temp_dir, "AppData") os.makedirs(self.appdata) - self.old_appdata = os.environ.get("APPDATA") or os.path.join( - os.path.expanduser("~"), "AppData", "Roaming" - ) + self.old_appdata = os.environ.get("APPDATA") or os.path.join(os.path.expanduser("~"), "AppData", "Roaming") os.environ["APPDATA"] = self.appdata self.config_dir = os.path.join(self.appdata, "Rainmeter") self.config_file = os.path.join(self.config_dir, "Rainmeter.ini") @@ -76,9 +74,7 @@ def test_apply_config_creates_new_file(self): def test_apply_config_merges_with_existing(self): os.makedirs(self.config_dir) with open(self.config_file, "w") as f: - f.write( - "[Rainmeter]\nConfigEditor=old.exe\nSkinPath=C:\\Skins\n\n[ExistingSkin]\nActive=1\n" - ) + f.write("[Rainmeter]\nConfigEditor=old.exe\nSkinPath=C:\\Skins\n\n[ExistingSkin]\nActive=1\n") args = { "settings": { @@ -137,9 +133,7 @@ def test_apply_config_corrupted_ini_creates_backup(self): backups = [f for f in os.listdir(self.config_dir) if f.endswith(".bak")] self.assertEqual(len(backups), 1) with open(os.path.join(self.config_dir, backups[0]), "r") as f: - self.assertEqual( - f.read(), "invalid ini content without section header\nkey=value\n" - ) + self.assertEqual(f.read(), "invalid ini content without section header\nkey=value\n") def test_apply_config_invalid_settings_type(self): args = {"settings": ["not", "a", "dict"]} @@ -190,9 +184,7 @@ def test_main_empty_input(self, mock_stdin, mock_stdout): @patch("sys.stdout") @patch("sys.stdin") def test_main_unknown_command(self, mock_stdin, mock_stdout): - mock_stdin.read.return_value = ( - '{"command": "invalid_cmd", "requestId": "req-10"}' - ) + mock_stdin.read.return_value = '{"command": "invalid_cmd", "requestId": "req-10"}' output = [] mock_stdout.write.side_effect = output.append diff --git a/plugins/rclone/src/plugin.py b/plugins/rclone/src/plugin.py index de1ea66f..82711f31 100644 --- a/plugins/rclone/src/plugin.py +++ b/plugins/rclone/src/plugin.py @@ -65,9 +65,7 @@ def parse_ini(text: str) -> tuple: if match_kv: key = match_kv.group(1).strip() val = match_kv.group(2).strip() - current_block["lines"].append( - {"type": "kv", "raw": line, "key": key, "val": val} - ) + current_block["lines"].append({"type": "kv", "raw": line, "key": key, "val": val}) else: current_block["lines"].append({"type": "unknown", "raw": line}) @@ -138,11 +136,7 @@ def merge_settings(blocks: list, args: dict) -> bool: if not block: block = {"name": remote_name, "lines": []} - if ( - blocks - and blocks[-1]["lines"] - and blocks[-1]["lines"][-1]["type"] != "empty" - ): + if blocks and blocks[-1]["lines"] and blocks[-1]["lines"][-1]["type"] != "empty": blocks[-1]["lines"].append({"type": "empty", "raw": ""}) block["lines"].append({"type": "section", "raw": f"[{remote_name}]"}) diff --git a/plugins/rclone/test/test_rclone.py b/plugins/rclone/test/test_rclone.py index 0a658f19..58b5bc6d 100644 --- a/plugins/rclone/test/test_rclone.py +++ b/plugins/rclone/test/test_rclone.py @@ -88,9 +88,7 @@ def test_apply_config_real_run(self, mock_write, mock_read, mock_get_path): self.assertTrue(res["success"]) self.assertTrue(res["changed"]) self.assertEqual(res["requestId"], "req-3") - mock_write.assert_called_once_with( - "dummy.conf", "tpslimit = 10\n[drive]\ntype = drive\n" - ) + mock_write.assert_called_once_with("dummy.conf", "tpslimit = 10\n[drive]\ntype = drive\n") @patch("plugin.get_config_path") @patch("plugin.read_text") diff --git a/plugins/ripgrep/src/plugin.py b/plugins/ripgrep/src/plugin.py index 42ea6e7e..64cde91b 100644 --- a/plugins/ripgrep/src/plugin.py +++ b/plugins/ripgrep/src/plugin.py @@ -28,9 +28,7 @@ def _backup_corrupt_config(file_path: str, reason: str): timestamp = datetime.datetime.now(datetime.timezone.utc).strftime("%Y%m%d%H%M%S") suffix = uuid.uuid4().hex[:8] backup_path = f"{file_path}.corrupted.{timestamp}.{suffix}" - log( - f"Config read failed ({reason}). Backing up to {backup_path} and starting fresh." - ) + log(f"Config read failed ({reason}). Backing up to {backup_path} and starting fresh.") try: shutil.move(file_path, backup_path) diff --git a/plugins/ripgrep/test/test_plugin.py b/plugins/ripgrep/test/test_plugin.py index c91f427e..c51cc16a 100644 --- a/plugins/ripgrep/test/test_plugin.py +++ b/plugins/ripgrep/test/test_plugin.py @@ -4,9 +4,7 @@ import sys import tempfile -PLUGIN = os.path.abspath( - os.path.join(os.path.dirname(__file__), "..", "src", "plugin.py") -) +PLUGIN = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "src", "plugin.py")) def run_plugin(payload: dict) -> dict: @@ -21,9 +19,7 @@ def run_plugin(payload: dict) -> dict: def test_check_installed(): - res = run_plugin( - {"requestId": "1", "command": "check_installed", "args": {}, "context": {}} - ) + res = run_plugin({"requestId": "1", "command": "check_installed", "args": {}, "context": {}}) assert res["requestId"] == "1" assert res["success"] @@ -40,9 +36,7 @@ def test_apply_config_dry_run(): { "requestId": "2", "command": "apply", - "args": { - "settings": {"smart-case": True, "hidden": True, "max-columns": 150} - }, + "args": {"settings": {"smart-case": True, "hidden": True, "max-columns": 150}}, "context": {"dryRun": True}, } ) @@ -63,9 +57,7 @@ def test_apply_config_write(): { "requestId": "3", "command": "apply", - "args": { - "settings": {"smart-case": True, "hidden": True, "max-columns": 150} - }, + "args": {"settings": {"smart-case": True, "hidden": True, "max-columns": 150}}, "context": {"dryRun": False}, } ) @@ -110,9 +102,7 @@ def test_preserves_existing_unknown_flags(): def test_empty_stdin_returns_json_error(): - result = subprocess.run( - [sys.executable, PLUGIN], input="", capture_output=True, text=True - ) + result = subprocess.run([sys.executable, PLUGIN], input="", capture_output=True, text=True) res = json.loads(result.stdout.strip()) diff --git a/plugins/scoop/src/plugin.py b/plugins/scoop/src/plugin.py index 37f27c0a..14aa25ce 100644 --- a/plugins/scoop/src/plugin.py +++ b/plugins/scoop/src/plugin.py @@ -31,9 +31,7 @@ def _backup_corrupt_config(file_path: str, reason: str): timestamp = datetime.datetime.now(datetime.timezone.utc).strftime("%Y%m%d%H%M%S") suffix = uuid.uuid4().hex[:8] backup_path = f"{file_path}.corrupted.{timestamp}.{suffix}" - log( - f"Config read failed ({reason}). Backing up to {backup_path} and starting fresh." - ) + log(f"Config read failed ({reason}). Backing up to {backup_path} and starting fresh.") try: shutil.move(file_path, backup_path) except Exception as backup_e: diff --git a/plugins/scoop/test/test_scoop.py b/plugins/scoop/test/test_scoop.py index 7816f682..dcc5869b 100644 --- a/plugins/scoop/test/test_scoop.py +++ b/plugins/scoop/test/test_scoop.py @@ -160,9 +160,7 @@ def test_read_json_oserror_backup(self, mock_move, mock_file, mock_exists): @patch("plugin.os.replace") @patch("plugin.os.makedirs") @patch("plugin.os.fdopen") - def test_write_json_uses_mkstemp( - self, mock_fdopen, mock_makedirs, mock_replace, mock_mkstemp - ): + def test_write_json_uses_mkstemp(self, mock_fdopen, mock_makedirs, mock_replace, mock_mkstemp): mock_mkstemp.return_value = (5, "/tmp/scoop-abc123") mock_fdopen.return_value.__enter__ = lambda s: s mock_fdopen.return_value.__exit__ = lambda s, *a: False @@ -176,9 +174,7 @@ def test_write_json_uses_mkstemp( call_kwargs.kwargs.get("prefix") or call_kwargs[1].get("prefix"), "scoop-", ) - mock_replace.assert_called_once_with( - "/tmp/scoop-abc123", "/fake/path/config.json" - ) + mock_replace.assert_called_once_with("/tmp/scoop-abc123", "/fake/path/config.json") def test_write_json_atomic_real(self): import json as js diff --git a/plugins/sharex/src/plugin.py b/plugins/sharex/src/plugin.py index 59467c71..6b86a646 100644 --- a/plugins/sharex/src/plugin.py +++ b/plugins/sharex/src/plugin.py @@ -25,9 +25,7 @@ def read_json(file_path: str) -> dict: with open(file_path, "r", encoding="utf-8") as f: return json.load(f) except json.JSONDecodeError: - timestamp = datetime.datetime.now(datetime.timezone.utc).strftime( - "%Y%m%d%H%M%S" - ) + timestamp = datetime.datetime.now(datetime.timezone.utc).strftime("%Y%m%d%H%M%S") suffix = uuid.uuid4().hex[:8] backup_path = f"{file_path}.corrupted.{timestamp}.{suffix}" try: diff --git a/plugins/sharex/test/test_sharex.py b/plugins/sharex/test/test_sharex.py index 94a046b8..5e62a354 100644 --- a/plugins/sharex/test/test_sharex.py +++ b/plugins/sharex/test/test_sharex.py @@ -30,11 +30,7 @@ def test_deep_merge(self): changed = plugin.deep_merge(target, source) self.assertTrue(changed) self.assertTrue(target["ApplicationSettings"]["ShowTray"]) - self.assertFalse( - target["ApplicationSettings"]["CaptureSettings"]["Screenshot"][ - "CaptureTransparency" - ] - ) + self.assertFalse(target["ApplicationSettings"]["CaptureSettings"]["Screenshot"]["CaptureTransparency"]) self.assertEqual(target["ImageSettings"]["ImageFormat"], "JPEG") self.assertEqual(target["UploadSettings"]["DestinationType"], "Imgur") @@ -150,11 +146,7 @@ def test_read_corrupted_config(self, mock_get_path): self.assertEqual(content["a"], 2) # Verify backup was created - backups = [ - f - for f in os.listdir(temp_dir) - if f.startswith("ShareX.json.corrupted.") - ] + backups = [f for f in os.listdir(temp_dir) if f.startswith("ShareX.json.corrupted.")] self.assertEqual(len(backups), 1) diff --git a/plugins/spicetify/src/plugin.py b/plugins/spicetify/src/plugin.py index d02609af..efc337d4 100644 --- a/plugins/spicetify/src/plugin.py +++ b/plugins/spicetify/src/plugin.py @@ -77,10 +77,7 @@ def merge_settings(config: configparser.ConfigParser, settings: dict) -> bool: for key, value in values.items(): normalized = normalize_value(value) - if ( - not config.has_option(section, key) - or config.get(section, key) != normalized - ): + if not config.has_option(section, key) or config.get(section, key) != normalized: config.set(section, key, normalized) changed = True diff --git a/plugins/spicetify/test/test_spicetify.py b/plugins/spicetify/test/test_spicetify.py index 19744ecf..13c2e927 100644 --- a/plugins/spicetify/test/test_spicetify.py +++ b/plugins/spicetify/test/test_spicetify.py @@ -4,9 +4,7 @@ import sys import tempfile -PLUGIN = os.path.abspath( - os.path.join(os.path.dirname(__file__), "..", "src", "plugin.py") -) +PLUGIN = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "src", "plugin.py")) def run_plugin(payload: dict) -> dict: @@ -24,9 +22,7 @@ def test_check_installed_absent(): with tempfile.TemporaryDirectory() as tmp: os.environ["USERPROFILE"] = tmp - res = run_plugin( - {"requestId": "1", "command": "check_installed", "args": {}, "context": {}} - ) + res = run_plugin({"requestId": "1", "command": "check_installed", "args": {}, "context": {}}) assert res["requestId"] == "1" assert res["success"] diff --git a/plugins/spotify/test/test_spotify.py b/plugins/spotify/test/test_spotify.py index c5b3be67..a9e59704 100644 --- a/plugins/spotify/test/test_spotify.py +++ b/plugins/spotify/test/test_spotify.py @@ -4,9 +4,7 @@ import sys import tempfile -PLUGIN = os.path.abspath( - os.path.join(os.path.dirname(__file__), "..", "src", "plugin.py") -) +PLUGIN = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "src", "plugin.py")) def run_plugin(payload: dict) -> dict: @@ -65,9 +63,7 @@ def test_merge_existing_preferences(): prefs_path = os.path.join(spotify_dir, "prefs") with open(prefs_path, "w", encoding="utf-8") as f: - f.write( - "audio.play_bitrate_enumeration=4\nui.track_notifications_enabled=false\n" - ) + f.write("audio.play_bitrate_enumeration=4\nui.track_notifications_enabled=false\n") res = run_plugin( { diff --git a/plugins/starship/src/plugin.py b/plugins/starship/src/plugin.py index 1c5b2e14..49a9572c 100644 --- a/plugins/starship/src/plugin.py +++ b/plugins/starship/src/plugin.py @@ -40,9 +40,7 @@ def read_toml(file_path: str) -> dict: log(f"Warning: could not parse {file_path} using tomllib: {e}") return {} else: - log( - "Warning: tomllib not available (requires Python 3.11+). Starting with empty config." - ) + log("Warning: tomllib not available (requires Python 3.11+). Starting with empty config.") return {} @@ -106,9 +104,7 @@ def merge_settings(target: dict, source: dict) -> bool: def check_installed(args: dict, request_id: str) -> dict: - installed = ( - shutil.which("starship.exe") is not None or shutil.which("starship") is not None - ) + installed = shutil.which("starship.exe") is not None or shutil.which("starship") is not None return { "requestId": request_id, "success": True, diff --git a/plugins/starship/test/test_starship.py b/plugins/starship/test/test_starship.py index 8ad243ac..f3f08450 100644 --- a/plugins/starship/test/test_starship.py +++ b/plugins/starship/test/test_starship.py @@ -4,9 +4,7 @@ import sys import tempfile -PLUGIN = os.path.abspath( - os.path.join(os.path.dirname(__file__), "..", "src", "plugin.py") -) +PLUGIN = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "src", "plugin.py")) def run_plugin(payload: dict) -> dict: @@ -116,9 +114,7 @@ def test_idempotent_apply(): def test_unknown_command(): - res = run_plugin( - {"requestId": "5", "command": "explode", "args": {}, "context": {}} - ) + res = run_plugin({"requestId": "5", "command": "explode", "args": {}, "context": {}}) assert not res["success"] assert "error" in res diff --git a/plugins/sublime-text/src/plugin.py b/plugins/sublime-text/src/plugin.py index 0ea3be62..b3025ebc 100644 --- a/plugins/sublime-text/src/plugin.py +++ b/plugins/sublime-text/src/plugin.py @@ -76,10 +76,7 @@ def merge_settings(target: dict, source: dict) -> bool: def check_installed() -> bool: - return ( - shutil.which("subl.exe") is not None - or shutil.which("sublime_text.exe") is not None - ) + return shutil.which("subl.exe") is not None or shutil.which("sublime_text.exe") is not None def apply_config(args: dict, context: dict, request_id: str) -> dict: diff --git a/plugins/sublime-text/test/test_sublime_text.py b/plugins/sublime-text/test/test_sublime_text.py index 57f9ec2c..5a7456d9 100644 --- a/plugins/sublime-text/test/test_sublime_text.py +++ b/plugins/sublime-text/test/test_sublime_text.py @@ -4,9 +4,7 @@ import sys import tempfile -PLUGIN = os.path.abspath( - os.path.join(os.path.dirname(__file__), "..", "src", "plugin.py") -) +PLUGIN = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "src", "plugin.py")) def run_plugin(payload: dict) -> dict: diff --git a/plugins/syncthing/src/plugin.py b/plugins/syncthing/src/plugin.py index 33b9aea5..20d18bc7 100644 --- a/plugins/syncthing/src/plugin.py +++ b/plugins/syncthing/src/plugin.py @@ -70,9 +70,7 @@ def process_command(request): if "gui" in settings and update_element(root, "gui", settings["gui"]): changed = True - if "options" in settings and update_element( - root, "options", settings["options"] - ): + if "options" in settings and update_element(root, "options", settings["options"]): changed = True if changed and not dry_run: diff --git a/plugins/topgrade/src/plugin.py b/plugins/topgrade/src/plugin.py index 0c187242..a5c7c74e 100644 --- a/plugins/topgrade/src/plugin.py +++ b/plugins/topgrade/src/plugin.py @@ -19,9 +19,7 @@ def log(msg): def get_topgrade_config_path(): - appdata = os.environ.get("APPDATA") or os.path.join( - os.path.expanduser("~"), "AppData", "Roaming" - ) + appdata = os.environ.get("APPDATA") or os.path.join(os.path.expanduser("~"), "AppData", "Roaming") if not appdata: return None diff --git a/plugins/topgrade/test/test_topgrade.py b/plugins/topgrade/test/test_topgrade.py index dc3699cf..f52b2974 100644 --- a/plugins/topgrade/test/test_topgrade.py +++ b/plugins/topgrade/test/test_topgrade.py @@ -12,9 +12,7 @@ def run_plugin(request_dict): input_str = json.dumps(request_dict) # Use standard python to run since uv might not be perfectly nested here, # but the script uses inline dependencies so we must use 'uv run' - result = subprocess.run( - ["uv", "run", PLUGIN_SCRIPT], input=input_str, text=True, capture_output=True - ) + result = subprocess.run(["uv", "run", PLUGIN_SCRIPT], input=input_str, text=True, capture_output=True) return json.loads(result.stdout) if result.stdout else None, result.stderr diff --git a/plugins/vim/src/main.py b/plugins/vim/src/main.py index 7082dc16..74815774 100644 --- a/plugins/vim/src/main.py +++ b/plugins/vim/src/main.py @@ -104,9 +104,7 @@ def apply_config(config, context): for key, value in settings.items(): if key == "theme": lines.append(f"vim.cmd('colorscheme {value}')") - elif isinstance(value, bool) or ( - isinstance(value, str) and value.lower() in ("true", "false") - ): + elif isinstance(value, bool) or (isinstance(value, str) and value.lower() in ("true", "false")): if isinstance(value, bool): val_str = "true" if value else "false" else: diff --git a/plugins/vim/tests/test_vim.py b/plugins/vim/tests/test_vim.py index b00a97c2..be09d70f 100644 --- a/plugins/vim/tests/test_vim.py +++ b/plugins/vim/tests/test_vim.py @@ -24,9 +24,7 @@ def test_check_installed_returns_true_if_dir_exists(self, mock_isdir): @patch("os.path.exists") @patch("os.makedirs") @patch("builtins.open", new_callable=mock_open) - def test_apply_config_generates_correct_lua( - self, mock_file, mock_makedirs, mock_exists - ): + def test_apply_config_generates_correct_lua(self, mock_file, mock_makedirs, mock_exists): mock_exists.return_value = False config = {"settings": {"number": True, "theme": "gruvbox", "shiftwidth": 4}} context = {"dryRun": False} @@ -37,9 +35,7 @@ def test_apply_config_generates_correct_lua( self.assertTrue(result["changed"]) # Verify content - written_content = "".join( - call.args[0] for call in mock_file().write.call_args_list - ) + written_content = "".join(call.args[0] for call in mock_file().write.call_args_list) self.assertIn("vim.opt.number = true", written_content) self.assertIn("vim.cmd('colorscheme gruvbox')", written_content) self.assertIn("vim.opt.shiftwidth = 4", written_content) diff --git a/plugins/vlc/src/plugin.py b/plugins/vlc/src/plugin.py index 5d4aa369..865e220c 100644 --- a/plugins/vlc/src/plugin.py +++ b/plugins/vlc/src/plugin.py @@ -149,19 +149,14 @@ def main() -> None: input_data = sys.stdin.read() if not input_data: - sys.stdout.write( - json.dumps({"requestId": "unknown", "error": "Empty stdin"}) + "\n" - ) + sys.stdout.write(json.dumps({"requestId": "unknown", "error": "Empty stdin"}) + "\n") sys.stdout.flush() return try: request = json.loads(input_data) except Exception as exc: - sys.stdout.write( - json.dumps({"requestId": "unknown", "error": f"JSON parse error: {exc}"}) - + "\n" - ) + sys.stdout.write(json.dumps({"requestId": "unknown", "error": f"JSON parse error: {exc}"}) + "\n") sys.stdout.flush() return diff --git a/plugins/vlc/test/test_vlc.py b/plugins/vlc/test/test_vlc.py index c05e1630..b45f6ca2 100644 --- a/plugins/vlc/test/test_vlc.py +++ b/plugins/vlc/test/test_vlc.py @@ -14,9 +14,7 @@ import tempfile import textwrap -PLUGIN = os.path.abspath( - os.path.join(os.path.dirname(__file__), "..", "src", "plugin.py") -) +PLUGIN = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "src", "plugin.py")) SAMPLE_VLCRC = textwrap.dedent( """\ @@ -335,9 +333,7 @@ def test_apply_settings_not_dict(): def test_unknown_command(): - res = run_plugin( - {"requestId": "12", "command": "explode", "args": {}, "context": {}} - ) + res = run_plugin({"requestId": "12", "command": "explode", "args": {}, "context": {}}) assert "error" in res print("✓ unknown_command") @@ -356,9 +352,7 @@ def test_request_id_echoed(): def test_request_id_null_defaults_to_unknown(): - res = run_plugin( - {"requestId": None, "command": "check_installed", "args": {}, "context": {}} - ) + res = run_plugin({"requestId": None, "command": "check_installed", "args": {}, "context": {}}) assert res["requestId"] == "unknown" print("✓ request_id_null_defaults_to_unknown") diff --git a/plugins/wallpaper-engine/src/plugin.py b/plugins/wallpaper-engine/src/plugin.py index fa184c56..2edc96f0 100644 --- a/plugins/wallpaper-engine/src/plugin.py +++ b/plugins/wallpaper-engine/src/plugin.py @@ -62,9 +62,7 @@ def apply(request_id, args): target_path = path break if not target_path: - program_files_x86 = os.environ.get( - "ProgramFiles(x86)", r"C:\Program Files (x86)" - ) + program_files_x86 = os.environ.get("ProgramFiles(x86)", r"C:\Program Files (x86)") target_path = os.path.join( program_files_x86, "Steam", @@ -137,11 +135,7 @@ def main(): result = apply(request_id, args) print(json.dumps(result)) else: - print( - json.dumps( - {"requestId": request_id, "error": f"Unknown command: {command}"} - ) - ) + print(json.dumps({"requestId": request_id, "error": f"Unknown command: {command}"})) if __name__ == "__main__": diff --git a/plugins/wallpaper-engine/test/test_wallpaper_engine.py b/plugins/wallpaper-engine/test/test_wallpaper_engine.py index 6cdc4ae3..d24059c2 100644 --- a/plugins/wallpaper-engine/test/test_wallpaper_engine.py +++ b/plugins/wallpaper-engine/test/test_wallpaper_engine.py @@ -11,13 +11,9 @@ class TestWallpaperEnginePluginContract(unittest.TestCase): def setUp(self): self.test_dir = tempfile.mkdtemp() - self.config_dir = os.path.join( - self.test_dir, "Steam", "steamapps", "common", "wallpaper_engine", "config" - ) + self.config_dir = os.path.join(self.test_dir, "Steam", "steamapps", "common", "wallpaper_engine", "config") self.config_file = os.path.join(self.config_dir, "config.json") - self.plugin_script = os.path.abspath( - os.path.join(os.path.dirname(__file__), "../src/plugin.py") - ) + self.plugin_script = os.path.abspath(os.path.join(os.path.dirname(__file__), "../src/plugin.py")) self.orig_p86 = os.environ.get("ProgramFiles(x86)") os.environ["ProgramFiles(x86)"] = self.test_dir diff --git a/plugins/windows-explorer/src/plugin.py b/plugins/windows-explorer/src/plugin.py index 1543c8c0..50819943 100644 --- a/plugins/windows-explorer/src/plugin.py +++ b/plugins/windows-explorer/src/plugin.py @@ -13,9 +13,7 @@ def log(msg): def read_registry_values(): values = {} try: - with winreg.OpenKey( - winreg.HKEY_CURRENT_USER, REG_PATH, 0, winreg.KEY_READ - ) as key: + with winreg.OpenKey(winreg.HKEY_CURRENT_USER, REG_PATH, 0, winreg.KEY_READ) as key: try: i = 0 while True: @@ -84,9 +82,7 @@ def apply_config(args): log(f"Dry run: Would update registry key {k} to {v}") elif changed: try: - with winreg.OpenKey( - winreg.HKEY_CURRENT_USER, REG_PATH, 0, winreg.KEY_SET_VALUE - ) as key: + with winreg.OpenKey(winreg.HKEY_CURRENT_USER, REG_PATH, 0, winreg.KEY_SET_VALUE) as key: for k, v in updates.items(): winreg.SetValueEx(key, k, 0, winreg.REG_DWORD, v) log(f"Updated registry key {k} to {v}") diff --git a/plugins/windows-explorer/test/test_windows_explorer.py b/plugins/windows-explorer/test/test_windows_explorer.py index 327b6abf..b6d696b1 100644 --- a/plugins/windows-explorer/test/test_windows_explorer.py +++ b/plugins/windows-explorer/test/test_windows_explorer.py @@ -45,9 +45,7 @@ def test_apply_config_no_changes_needed(self, mock_winreg, mock_read): @patch("plugin.winreg") def test_apply_config_changes_needed(self, mock_winreg, mock_read): mock_read.return_value = {"HideFileExt": 0, "Hidden": 1, "ShowSuperHidden": 1} - args = { - "settings": {"HideFileExt": True, "Hidden": 2, "ShowSuperHidden": False} - } + args = {"settings": {"HideFileExt": True, "Hidden": 2, "ShowSuperHidden": False}} mock_key = MagicMock() mock_winreg.OpenKey.return_value.__enter__.return_value = mock_key @@ -55,15 +53,9 @@ def test_apply_config_changes_needed(self, mock_winreg, mock_read): self.assertEqual(result, {"changed": True}) self.assertEqual(mock_winreg.SetValueEx.call_count, 3) - mock_winreg.SetValueEx.assert_any_call( - mock_key, "HideFileExt", 0, mock_winreg.REG_DWORD, 1 - ) - mock_winreg.SetValueEx.assert_any_call( - mock_key, "Hidden", 0, mock_winreg.REG_DWORD, 2 - ) - mock_winreg.SetValueEx.assert_any_call( - mock_key, "ShowSuperHidden", 0, mock_winreg.REG_DWORD, 0 - ) + mock_winreg.SetValueEx.assert_any_call(mock_key, "HideFileExt", 0, mock_winreg.REG_DWORD, 1) + mock_winreg.SetValueEx.assert_any_call(mock_key, "Hidden", 0, mock_winreg.REG_DWORD, 2) + mock_winreg.SetValueEx.assert_any_call(mock_key, "ShowSuperHidden", 0, mock_winreg.REG_DWORD, 0) @patch("plugin.read_registry_values") @patch("plugin.winreg") @@ -86,9 +78,7 @@ def test_apply_config_invalid_hidden(self, mock_winreg, mock_read): result = plugin.apply_config(args) - self.assertEqual( - result, {"error": "Invalid value for Hidden: 3. Must be 1 or 2."} - ) + self.assertEqual(result, {"error": "Invalid value for Hidden: 3. Must be 1 or 2."}) @patch("plugin.read_registry_values") @patch("plugin.winreg") diff --git a/plugins/windows-sandbox/src/plugin.py b/plugins/windows-sandbox/src/plugin.py index 1e57ef17..4bdf7bab 100644 --- a/plugins/windows-sandbox/src/plugin.py +++ b/plugins/windows-sandbox/src/plugin.py @@ -120,9 +120,7 @@ def merge_settings(root: ET.Element, settings: dict) -> bool: { "hostFolder": host.text if host is not None else "", "readOnly": ( - readonly.text.lower() == "true" - if readonly is not None and readonly.text - else False + readonly.text.lower() == "true" if readonly is not None and readonly.text else False ), } ) diff --git a/plugins/windows-sandbox/test/test_windows_sandbox.py b/plugins/windows-sandbox/test/test_windows_sandbox.py index 4b12df52..cd1be299 100644 --- a/plugins/windows-sandbox/test/test_windows_sandbox.py +++ b/plugins/windows-sandbox/test/test_windows_sandbox.py @@ -4,9 +4,7 @@ import sys import tempfile -PLUGIN = os.path.abspath( - os.path.join(os.path.dirname(__file__), "..", "src", "plugin.py") -) +PLUGIN = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "src", "plugin.py")) def run_plugin(payload: dict) -> dict: diff --git a/plugins/winget/src/plugin.py b/plugins/winget/src/plugin.py index 7a2cb5f9..9e72c6c8 100644 --- a/plugins/winget/src/plugin.py +++ b/plugins/winget/src/plugin.py @@ -70,9 +70,7 @@ def deep_merge(target: dict, source: dict) -> bool: def check_installed(args: dict, request_id: str) -> dict: - installed = ( - shutil.which("winget.exe") is not None or shutil.which("winget") is not None - ) + installed = shutil.which("winget.exe") is not None or shutil.which("winget") is not None return { "requestId": request_id, "success": True, diff --git a/plugins/yarn/src/plugin.py b/plugins/yarn/src/plugin.py index 6145a861..bcae8b21 100644 --- a/plugins/yarn/src/plugin.py +++ b/plugins/yarn/src/plugin.py @@ -154,9 +154,7 @@ def _atomic_write(path: str, content: str): def check_installed(args: dict) -> bool: paths = get_yarnrc_paths() found_yarn = ( - shutil.which("yarn.cmd") is not None - or shutil.which("yarn.exe") is not None - or shutil.which("yarn") is not None + shutil.which("yarn.cmd") is not None or shutil.which("yarn.exe") is not None or shutil.which("yarn") is not None ) cfg_exists = os.path.exists(paths["berry"]) or os.path.exists(paths["classic"]) @@ -216,9 +214,7 @@ def _validate_and_normalize_settings(settings_raw: object): return settings -def _apply_berry( - berry_path: str, settings: dict, dry_run: bool, request_id: str -) -> dict: +def _apply_berry(berry_path: str, settings: dict, dry_run: bool, request_id: str) -> dict: existing = {} if os.path.exists(berry_path): try: @@ -252,9 +248,7 @@ def _apply_berry( } -def _apply_classic( - classic_path: str, settings: dict, dry_run: bool, request_id: str -) -> dict: +def _apply_classic(classic_path: str, settings: dict, dry_run: bool, request_id: str) -> dict: existing = {} if os.path.exists(classic_path): try: @@ -303,13 +297,9 @@ def apply_config(args: dict, request_id: str) -> dict: classic_exists = os.path.exists(paths["classic"]) if berry_exists or (not classic_exists): - return _apply_berry( - paths["berry"], settings, dry_run=dry_run, request_id=request_id - ) + return _apply_berry(paths["berry"], settings, dry_run=dry_run, request_id=request_id) - return _apply_classic( - paths["classic"], settings, dry_run=dry_run, request_id=request_id - ) + return _apply_classic(paths["classic"], settings, dry_run=dry_run, request_id=request_id) except Exception as e: log_error(f"Failed to apply config: {e}") diff --git a/plugins/yarn/test/test_yarn.py b/plugins/yarn/test/test_yarn.py index c3e89207..4596520d 100644 --- a/plugins/yarn/test/test_yarn.py +++ b/plugins/yarn/test/test_yarn.py @@ -107,9 +107,7 @@ def test_apply_classic_prefers_classic_if_present(mock_home, tmp_path): mock_home.return_value = str(tmp_path) # Create classic file - (tmp_path / ".yarnrc").write_text( - "npmRegistryServer https://example.com\n", encoding="utf-8" - ) + (tmp_path / ".yarnrc").write_text("npmRegistryServer https://example.com\n", encoding="utf-8") request = { "requestId": "r4", diff --git a/plugins/yasb/src/plugin.py b/plugins/yasb/src/plugin.py index 4e56d095..c61cfd91 100644 --- a/plugins/yasb/src/plugin.py +++ b/plugins/yasb/src/plugin.py @@ -52,13 +52,9 @@ def read_yaml(file_path: str) -> dict: try: shutil.copy2(file_path, backup_path) - log( - f"Warning: could not parse {file_path}: {error}. Backed up to {backup_path} and starting fresh." - ) + log(f"Warning: could not parse {file_path}: {error}. Backed up to {backup_path} and starting fresh.") except Exception as backup_error: - log( - f"Warning: could not parse {file_path}: {error}. Failed to back it up: {backup_error}. Starting fresh." - ) + log(f"Warning: could not parse {file_path}: {error}. Failed to back it up: {backup_error}. Starting fresh.") return {} @@ -66,9 +62,7 @@ def read_yaml(file_path: str) -> dict: def write_yaml(file_path: str, data: dict) -> None: os.makedirs(os.path.dirname(file_path), exist_ok=True) - temp_fd, temp_path = tempfile.mkstemp( - prefix="yasb-", dir=os.path.dirname(file_path) - ) + temp_fd, temp_path = tempfile.mkstemp(prefix="yasb-", dir=os.path.dirname(file_path)) try: with os.fdopen(temp_fd, "w", encoding="utf-8") as file_handle: yaml.safe_dump(data, file_handle, default_flow_style=False, sort_keys=False) @@ -115,9 +109,7 @@ def merge_settings(target: dict, source: dict) -> bool: def check_installed(request_id: str) -> dict: installed = ( - shutil.which("yasb") is not None - or shutil.which("yasb.exe") is not None - or os.path.isdir(get_config_dir()) + shutil.which("yasb") is not None or shutil.which("yasb.exe") is not None or os.path.isdir(get_config_dir()) ) return { @@ -147,11 +139,7 @@ def apply_config(request_id: str, args: dict, context: dict) -> dict: changed = merge_settings(updated_config, settings) if dry_run: - log( - f"Would update {config_path}" - if changed - else f"No changes for {config_path}" - ) + log(f"Would update {config_path}" if changed else f"No changes for {config_path}") return { "requestId": request_id, "success": True, diff --git a/plugins/yasb/test/test_yasb.py b/plugins/yasb/test/test_yasb.py index ad64d128..acca6c79 100644 --- a/plugins/yasb/test/test_yasb.py +++ b/plugins/yasb/test/test_yasb.py @@ -56,9 +56,7 @@ def test_check_installed_true_via_config_dir(self): config_dir = os.path.join(tmp_dir, ".config", "yasb") os.makedirs(config_dir, exist_ok=True) - with patch.dict(os.environ, {"USERPROFILE": tmp_dir}), patch( - "plugin.shutil.which", return_value=None - ): + with patch.dict(os.environ, {"USERPROFILE": tmp_dir}), patch("plugin.shutil.which", return_value=None): response = self.run_main( { "requestId": "req-2", @@ -103,9 +101,7 @@ def test_apply_merges_bars_without_overwriting_existing_config(self): os.makedirs(os.path.dirname(config_path), exist_ok=True) with open(config_path, "w", encoding="utf-8") as file_handle: - yaml.safe_dump( - initial, file_handle, default_flow_style=False, sort_keys=False - ) + yaml.safe_dump(initial, file_handle, default_flow_style=False, sort_keys=False) payload = { "requestId": "req-3", @@ -148,17 +144,13 @@ def test_apply_merges_bars_without_overwriting_existing_config(self): self.assertTrue(content["watch_config"]) self.assertFalse(content["debug"]) self.assertTrue(content["bars"]["status-bar"]["enabled"]) - self.assertEqual( - content["bars"]["status-bar"]["alignment"]["position"], "top" - ) + self.assertEqual(content["bars"]["status-bar"]["alignment"]["position"], "top") self.assertFalse(content["bars"]["status-bar"]["alignment"]["center"]) self.assertEqual( content["bars"]["status-bar"]["widgets"]["left"], ["workspaces", "active_window"], ) - self.assertEqual( - content["bars"]["status-bar"]["widgets"]["center"], ["date"] - ) + self.assertEqual(content["bars"]["status-bar"]["widgets"]["center"], ["date"]) self.assertEqual( content["bars"]["status-bar"]["widgets"]["right"], ["cpu", "memory", "volume", "battery"], @@ -258,9 +250,7 @@ def test_apply_backups_corrupted_yaml_before_replacing(self): backup_dir = os.path.dirname(config_path) backups = [ - name - for name in os.listdir(backup_dir) - if name.startswith("config.yaml.") and name.endswith(".bak") + name for name in os.listdir(backup_dir) if name.startswith("config.yaml.") and name.endswith(".bak") ] self.assertTrue(response["success"]) @@ -294,9 +284,7 @@ def test_main_returns_json_error_on_empty_input(self): self.assertIn("empty stdin", response["error"]) def test_unknown_command(self): - response = self.run_main( - {"requestId": "req-6", "command": "explode", "args": {}, "context": {}} - ) + response = self.run_main({"requestId": "req-6", "command": "explode", "args": {}, "context": {}}) self.assertFalse(response["success"]) self.assertIn("error", response) diff --git a/plugins/yazi/test/test_yazi.py b/plugins/yazi/test/test_yazi.py index 0806994a..87b60772 100644 --- a/plugins/yazi/test/test_yazi.py +++ b/plugins/yazi/test/test_yazi.py @@ -12,9 +12,7 @@ except ImportError: tomllib = None -PLUGIN = os.path.abspath( - os.path.join(os.path.dirname(__file__), "..", "src", "plugin.py") -) +PLUGIN = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "src", "plugin.py")) def run_plugin(payload: dict) -> dict: @@ -87,11 +85,7 @@ def test_apply_config_routing_and_write(): "command": "apply", "args": { "manager": {"show_hidden": True}, - "keymap": { - "prepend_keymap": [ - {"on": ["g", "h"], "run": "cd ~", "desc": "Go home"} - ] - }, + "keymap": {"prepend_keymap": [{"on": ["g", "h"], "run": "cd ~", "desc": "Go home"}]}, "theme": {"type": "catppuccin-mocha"}, }, "context": {"dryRun": False}, @@ -144,9 +138,7 @@ def test_idempotent_apply(): def test_unknown_command(): - res = run_plugin( - {"requestId": "5", "command": "explode", "args": {}, "context": {}} - ) + res = run_plugin({"requestId": "5", "command": "explode", "args": {}, "context": {}}) assert not res["success"] assert "error" in res diff --git a/plugins/zed/src/plugin.py b/plugins/zed/src/plugin.py index 17b30ffa..dcf96e9b 100644 --- a/plugins/zed/src/plugin.py +++ b/plugins/zed/src/plugin.py @@ -30,9 +30,7 @@ def log(message: str) -> None: sys.stderr.flush() -def response( - request_id: str, success: bool, changed: bool, error=None, data=None -) -> dict: +def response(request_id: str, success: bool, changed: bool, error=None, data=None) -> dict: result = { "requestId": request_id, "success": success, @@ -83,11 +81,7 @@ def strip_jsonc_comments(text: str) -> str: if char == "/" and next_char == "*": index += 2 while index < len(text): - if ( - text[index] == "*" - and index + 1 < len(text) - and text[index + 1] == "/" - ): + if text[index] == "*" and index + 1 < len(text) and text[index + 1] == "/": index += 2 break index += 1 @@ -117,10 +111,7 @@ def expand_path(path: str) -> str: def get_config_path(args: dict, context: dict) -> str: explicit_path = ( - args.get("configPath") - or args.get("config_path") - or context.get("configPath") - or context.get("config_path") + args.get("configPath") or args.get("config_path") or context.get("configPath") or context.get("config_path") ) if explicit_path: @@ -188,11 +179,7 @@ def desired_config_from_args(args: dict) -> dict: if "settings" in args and isinstance(args["settings"], dict): return normalize_config(copy.deepcopy(args["settings"])) - desired = { - key: copy.deepcopy(value) - for key, value in args.items() - if key not in NON_SETTING_ARG_KEYS - } + desired = {key: copy.deepcopy(value) for key, value in args.items() if key not in NON_SETTING_ARG_KEYS} return normalize_config(desired) @@ -225,10 +212,7 @@ def normalize_config(config: dict) -> dict: if isinstance(value, dict): normalized[key] = normalize_config(value) elif isinstance(value, list): - normalized[key] = [ - normalize_config(item) if isinstance(item, dict) else item - for item in value - ] + normalized[key] = [normalize_config(item) if isinstance(item, dict) else item for item in value] else: normalized[key] = normalize_scalar(key, value) @@ -274,9 +258,7 @@ def apply_config(args: dict, context: dict, request_id: str) -> dict: return response(request_id, success=True, changed=False) if dry_run: - log( - f"Would update {config_path} with keys: {', '.join(sorted(desired.keys()))}" - ) + log(f"Would update {config_path} with keys: {', '.join(sorted(desired.keys()))}") return response(request_id, success=True, changed=True) write_json(config_path, next_config) diff --git a/plugins/zed/test/test_zed.py b/plugins/zed/test/test_zed.py index 0372ac55..0d6319fd 100644 --- a/plugins/zed/test/test_zed.py +++ b/plugins/zed/test/test_zed.py @@ -197,9 +197,7 @@ def test_apply_backs_up_corrupt_settings_before_replacing(): ) backups = [ - name - for name in os.listdir(zed_dir) - if name.startswith("settings.json.corrupt-") and name.endswith(".bak") + name for name in os.listdir(zed_dir) if name.startswith("settings.json.corrupt-") and name.endswith(".bak") ] assert res["success"] diff --git a/plugins/zoxide/src/plugin.py b/plugins/zoxide/src/plugin.py index 45682314..00f1a55e 100644 --- a/plugins/zoxide/src/plugin.py +++ b/plugins/zoxide/src/plugin.py @@ -67,21 +67,10 @@ def build_init_line(shell: str, init_args: dict) -> str: def update_profile_content(existing_text: str, desired_line: str) -> tuple[str, bool]: current_lines = existing_text.splitlines() - matching_lines = [ - line - for line in current_lines - if "zoxide init" in line and not line.lstrip().startswith("#") - ] - updated_lines = [ - line - for line in current_lines - if "zoxide init" not in line or line.lstrip().startswith("#") - ] - - if ( - matching_lines == [desired_line] - and len(updated_lines) == len(current_lines) - 1 - ): + matching_lines = [line for line in current_lines if "zoxide init" in line and not line.lstrip().startswith("#")] + updated_lines = [line for line in current_lines if "zoxide init" not in line or line.lstrip().startswith("#")] + + if matching_lines == [desired_line] and len(updated_lines) == len(current_lines) - 1: return existing_text, False updated_lines.append(desired_line) @@ -132,9 +121,7 @@ def run_setx(var_name: str, value: str) -> None: def check_installed(_args: dict, request_id: str) -> dict: - installed = ( - shutil.which("zoxide.exe") is not None or shutil.which("zoxide") is not None - ) + installed = shutil.which("zoxide.exe") is not None or shutil.which("zoxide") is not None return { "requestId": request_id, "success": True, diff --git a/plugins/zoxide/test/test_zoxide.py b/plugins/zoxide/test/test_zoxide.py index 059dae24..8c3b8e1d 100644 --- a/plugins/zoxide/test/test_zoxide.py +++ b/plugins/zoxide/test/test_zoxide.py @@ -88,9 +88,7 @@ def test_apply_skips_setx_when_env_vars_match(): patch.object( plugin.subprocess, "run", - side_effect=AssertionError( - "setx should not be called when values already match" - ), + side_effect=AssertionError("setx should not be called when values already match"), ), ): result = plugin.apply_config( @@ -144,9 +142,7 @@ def test_apply_skips_setx_on_non_windows(): def test_apply_updates_powershell_init_line_when_flags_change(): - existing = ( - 'Write-Host "hello"\nInvoke-Expression (& { (zoxide init powershell) })\n' - ) + existing = 'Write-Host "hello"\nInvoke-Expression (& { (zoxide init powershell) })\n' opened = mock_open(read_data=existing) with ( @@ -155,9 +151,7 @@ def test_apply_updates_powershell_init_line_when_flags_change(): patch.object(plugin.Path, "exists", return_value=True), patch.object(plugin.Path, "mkdir") as mock_mkdir, ): - profile_path = Path( - "C:/Users/Test/Documents/PowerShell/Microsoft.PowerShell_profile.ps1" - ) + profile_path = Path("C:/Users/Test/Documents/PowerShell/Microsoft.PowerShell_profile.ps1") changed = plugin.update_profile_file( profile_path, plugin.build_init_line( @@ -176,14 +170,10 @@ def test_apply_updates_powershell_init_line_when_flags_change(): def test_apply_preserves_comment_lines_containing_zoxide_init(): - existing = ( - "# How to use zoxide init\nInvoke-Expression (& { (zoxide init powershell) })\n" - ) + existing = "# How to use zoxide init\nInvoke-Expression (& { (zoxide init powershell) })\n" updated, changed = plugin.update_profile_content( existing, - plugin.build_init_line( - "powershell", {"cmd": "z", "hook": "pwd", "no_cmd": False} - ), + plugin.build_init_line("powershell", {"cmd": "z", "hook": "pwd", "no_cmd": False}), ) assert changed is True @@ -236,9 +226,7 @@ def test_apply_dry_run_does_not_write_files_or_run_setx(): def test_apply_returns_changed_false_when_nothing_needs_updating(): - matching_line = plugin.build_init_line( - "powershell", {"cmd": None, "hook": "pwd", "no_cmd": False} - ) + matching_line = plugin.build_init_line("powershell", {"cmd": None, "hook": "pwd", "no_cmd": False}) existing = f"{matching_line}\n" opened = mock_open(read_data=existing) @@ -290,9 +278,7 @@ def test_apply_builds_correct_init_line_with_flags(): def test_apply_handles_missing_profile_file_by_creating_it(): - profile_path = Path( - "C:/Users/Test/Documents/PowerShell/Microsoft.PowerShell_profile.ps1" - ) + profile_path = Path("C:/Users/Test/Documents/PowerShell/Microsoft.PowerShell_profile.ps1") opened = mock_open() with ( @@ -302,9 +288,7 @@ def test_apply_handles_missing_profile_file_by_creating_it(): ): changed = plugin.update_profile_file( profile_path, - plugin.build_init_line( - "powershell", {"cmd": None, "hook": "pwd", "no_cmd": False} - ), + plugin.build_init_line("powershell", {"cmd": None, "hook": "pwd", "no_cmd": False}), dry_run=False, ) @@ -315,9 +299,7 @@ def test_apply_handles_missing_profile_file_by_creating_it(): def test_process_request_returns_error_for_unknown_command(): - result = plugin.process_request( - {"requestId": "req-7", "command": "explode", "args": {}} - ) + result = plugin.process_request({"requestId": "req-7", "command": "explode", "args": {}}) assert result["requestId"] == "req-7" assert result["success"] is False @@ -338,16 +320,10 @@ def test_main_handles_pretty_printed_json_request(): with ( patch("sys.stdin", StringIO(request)), patch("sys.stdout", new_callable=StringIO), - patch.object( - plugin.shutil, "which", side_effect=[None, "C:/Tools/zoxide"] - ) as mock_which, + patch.object(plugin.shutil, "which", side_effect=[None, "C:/Tools/zoxide"]) as mock_which, ): plugin.main() - output = ( - plugin.sys.stdout.getvalue() - if hasattr(plugin.sys.stdout, "getvalue") - else None - ) + output = plugin.sys.stdout.getvalue() if hasattr(plugin.sys.stdout, "getvalue") else None assert mock_which.call_count == 2