diff --git a/pyproject.toml b/pyproject.toml index c4ddeee..c4f2937 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -75,6 +75,7 @@ ignore = ["E501", "PLR0913", "PLR2004"] [tool.ruff.per-file-ignores] "__init__.py" = ["F401"] "tests/**/*.py" = ["S101", "PLR2004"] +"src/models/entities.py" = ["A003"] [tool.mypy] python_version = "3.8" diff --git a/src/lib/exceptions.py b/src/lib/exceptions.py index 9bb55d5..0b06af7 100644 --- a/src/lib/exceptions.py +++ b/src/lib/exceptions.py @@ -6,6 +6,8 @@ and troubleshooting guidance. """ +from typing import Optional + class VisionError(Exception): """ @@ -17,7 +19,7 @@ class VisionError(Exception): - Suggested fixes """ - def __init__(self, message: str, troubleshooting: str = None): + def __init__(self, message: str, troubleshooting: Optional[str] = None) -> None: """ Initialize VisionError. @@ -48,7 +50,7 @@ def _default_troubleshooting(self) -> str: class ScreenshotCaptureError(VisionError): """Screenshot capture failed.""" - def __init__(self, message: str, troubleshooting: str = None): + def __init__(self, message: str, troubleshooting: Optional[str] = None) -> None: if troubleshooting is None: troubleshooting = ( "• Verify screenshot tool is installed:\n" @@ -65,7 +67,9 @@ def __init__(self, message: str, troubleshooting: str = None): class DisplayNotAvailableError(VisionError): """No display available (headless environment).""" - def __init__(self, message: str = "No display available", troubleshooting: str = None): + def __init__( + self, message: str = "No display available", troubleshooting: Optional[str] = None + ) -> None: if troubleshooting is None: troubleshooting = ( "• Verify you're running in a graphical environment (not SSH without X forwarding)\n" @@ -81,7 +85,7 @@ def __init__(self, message: str = "No display available", troubleshooting: str = class MonitorNotFoundError(VisionError): """Specified monitor not found.""" - def __init__(self, monitor_id: int, available_count: int): + def __init__(self, monitor_id: int, available_count: int) -> None: message = f"Monitor {monitor_id} not found. Available monitors: {available_count}" troubleshooting = ( f"• List available monitors: claude-vision --list-monitors\n" @@ -95,7 +99,7 @@ def __init__(self, monitor_id: int, available_count: int): class InvalidRegionError(VisionError): """Capture region out of bounds or invalid.""" - def __init__(self, message: str, troubleshooting: str = None): + def __init__(self, message: str, troubleshooting: Optional[str] = None) -> None: if troubleshooting is None: troubleshooting = ( "• Verify region coordinates are within screen bounds\n" @@ -111,7 +115,7 @@ def __init__(self, message: str, troubleshooting: str = None): class ImageProcessingError(VisionError): """Image processing operation failed.""" - def __init__(self, message: str, troubleshooting: str = None): + def __init__(self, message: str, troubleshooting: Optional[str] = None) -> None: if troubleshooting is None: troubleshooting = ( "• Verify PIL/Pillow is installed: pip install Pillow\n" @@ -127,7 +131,7 @@ def __init__(self, message: str, troubleshooting: str = None): class RegionSelectionCancelledError(VisionError): """User cancelled region selection.""" - def __init__(self): + def __init__(self) -> None: message = "Region selection cancelled by user" troubleshooting = ( "• Region selection was cancelled\n" @@ -141,7 +145,9 @@ def __init__(self): class SelectionToolNotFoundError(VisionError): """Graphical selection tool not installed.""" - def __init__(self, message: str = "Graphical selection tool not found", troubleshooting: str = None): + def __init__( + self, message: str = "Graphical selection tool not found", troubleshooting: Optional[str] = None + ) -> None: if troubleshooting is None: troubleshooting = ( "• Install region selection tool:\n" @@ -158,7 +164,7 @@ def __init__(self, message: str = "Graphical selection tool not found", troubles class AuthenticationError(VisionError): """OAuth authentication failed.""" - def __init__(self, message: str, troubleshooting: str = None): + def __init__(self, message: str, troubleshooting: Optional[str] = None) -> None: if troubleshooting is None: troubleshooting = ( "• Verify Claude Code OAuth token is valid\n" @@ -173,7 +179,9 @@ def __init__(self, message: str, troubleshooting: str = None): class APIError(VisionError): """Claude API call failed.""" - def __init__(self, message: str, status_code: int = None, troubleshooting: str = None): + def __init__( + self, message: str, status_code: Optional[int] = None, troubleshooting: Optional[str] = None + ) -> None: self.status_code = status_code if troubleshooting is None: troubleshooting = ( @@ -190,14 +198,14 @@ def __init__(self, message: str, status_code: int = None, troubleshooting: str = class PayloadTooLargeError(VisionError): """Screenshot payload exceeds API limits.""" - def __init__(self, size_mb: float, limit_mb: float = 5.0): + def __init__(self, size_mb: float, limit_mb: float = 5.0) -> None: message = f"Screenshot too large: {size_mb:.2f} MB (limit: {limit_mb} MB)" troubleshooting = ( - f"• Reduce screenshot quality in config (current may be too high)\n" - f"• Reduce max_size_mb in config to trigger more aggressive compression\n" - f"• Use /vision.area to capture smaller region instead of full screen\n" - f"• Lower screen resolution if possible\n" - f"• Edit config: claude-vision --validate-config" + "• Reduce screenshot quality in config (current may be too high)\n" + "• Reduce max_size_mb in config to trigger more aggressive compression\n" + "• Use /vision.area to capture smaller region instead of full screen\n" + "• Lower screen resolution if possible\n" + "• Edit config: claude-vision --validate-config" ) super().__init__(message, troubleshooting) @@ -205,7 +213,7 @@ def __init__(self, size_mb: float, limit_mb: float = 5.0): class OAuthConfigNotFoundError(VisionError): """Claude Code OAuth config not found.""" - def __init__(self, message: str = None, troubleshooting: str = None): + def __init__(self, message: Optional[str] = None, troubleshooting: Optional[str] = None) -> None: if message is None: message = "Claude Code OAuth configuration not found" if troubleshooting is None: @@ -223,7 +231,7 @@ def __init__(self, message: str = None, troubleshooting: str = None): class ConfigurationError(VisionError): """Configuration invalid or load/save failed.""" - def __init__(self, message: str, troubleshooting: str = None): + def __init__(self, message: str, troubleshooting: Optional[str] = None) -> None: if troubleshooting is None: troubleshooting = ( "• Validate config: claude-vision --validate-config\n" @@ -239,7 +247,7 @@ def __init__(self, message: str, troubleshooting: str = None): class SessionAlreadyActiveError(VisionError): """Monitoring session already active.""" - def __init__(self, session_id: str = None): + def __init__(self, session_id: Optional[str] = None) -> None: message = f"Monitoring session already active{f': {session_id}' if session_id else ''}" troubleshooting = ( "• Stop current session: /vision.stop\n" @@ -253,7 +261,7 @@ def __init__(self, session_id: str = None): class SessionNotFoundError(VisionError): """Monitoring session not found.""" - def __init__(self, session_id: str = None): + def __init__(self, session_id: Optional[str] = None) -> None: message = f"Monitoring session not found{f': {session_id}' if session_id else ''}" troubleshooting = ( "• No active monitoring session to stop\n" @@ -268,7 +276,7 @@ def __init__(self, session_id: str = None): class TempFileError(VisionError): """Temporary file operation failed.""" - def __init__(self, message: str, troubleshooting: str = None): + def __init__(self, message: str, troubleshooting: Optional[str] = None) -> None: if troubleshooting is None: troubleshooting = ( "• Check disk space: df -h\n" @@ -284,7 +292,7 @@ def __init__(self, message: str, troubleshooting: str = None): class VisionCommandError(VisionError): """High-level command execution failed.""" - def __init__(self, message: str, troubleshooting: str = None): + def __init__(self, message: str, troubleshooting: Optional[str] = None) -> None: if troubleshooting is None: troubleshooting = ( "• Run diagnostics: claude-vision --doctor\n" diff --git a/src/models/entities.py b/src/models/entities.py index 06391c4..98b3975 100644 --- a/src/models/entities.py +++ b/src/models/entities.py @@ -8,7 +8,7 @@ from dataclasses import dataclass, field from datetime import datetime from pathlib import Path -from typing import Optional, List +from typing import List, Optional, Tuple from uuid import UUID @@ -62,7 +62,7 @@ class Screenshot: format: str # 'jpeg' | 'png' | 'webp' original_size_bytes: int optimized_size_bytes: int - resolution: tuple[int, int] # (width, height) + resolution: Tuple[int, int] # (width, height) source_monitor: int capture_method: str # 'scrot' | 'grim' | 'import' privacy_zones_applied: bool diff --git a/tests/unit/test_screenshot_capture_factory.py b/tests/unit/test_screenshot_capture_factory.py index 86a1900..16d6ea3 100644 --- a/tests/unit/test_screenshot_capture_factory.py +++ b/tests/unit/test_screenshot_capture_factory.py @@ -2,6 +2,10 @@ from __future__ import annotations +import builtins +import sys +from types import ModuleType + import pytest from src.lib.desktop_detector import DesktopType @@ -16,6 +20,13 @@ def __init__(self, temp_manager=None, **kwargs): self.kwargs = kwargs +def _install_fake_capture_module(monkeypatch: pytest.MonkeyPatch, module_name: str, class_name: str) -> None: + fake_module = ModuleType(module_name) + fake_class = type(class_name, (), {"__init__": _DummyCapture.__init__}) + setattr(fake_module, class_name, fake_class) + monkeypatch.setitem(sys.modules, module_name, fake_module) + + def test_get_tool_from_name_maps_supported_values() -> None: assert ScreenshotCaptureFactory._get_tool_from_name("scrot") == ScreenshotTool.SCROT assert ScreenshotCaptureFactory._get_tool_from_name("grim") == ScreenshotTool.GRIM @@ -133,3 +144,91 @@ def test_create_screenshot_capture_convenience_function_delegates(monkeypatch: p result = create_screenshot_capture(image_format="png", quality=88, preferred_tool="grim") assert result == {"image_format": "png", "quality": 88, "preferred_tool": "grim"} + + +def test_create_implementation_scrot_branch(monkeypatch: pytest.MonkeyPatch) -> None: + _install_fake_capture_module( + monkeypatch, + "src.services.screenshot_capture.x11_capture", + "X11ScreenshotCapture", + ) + + instance = ScreenshotCaptureFactory._create_implementation( + tool=ScreenshotTool.SCROT, + desktop_type=DesktopType.X11, + temp_manager=object(), + image_format="png", + quality=85, + ) + + assert instance.kwargs["image_format"] == "png" + assert instance.kwargs["quality"] == 85 + + +def test_create_implementation_grim_branch(monkeypatch: pytest.MonkeyPatch) -> None: + _install_fake_capture_module( + monkeypatch, + "src.services.screenshot_capture.wayland_capture", + "WaylandScreenshotCapture", + ) + + instance = ScreenshotCaptureFactory._create_implementation( + tool=ScreenshotTool.GRIM, + desktop_type=DesktopType.WAYLAND, + temp_manager=object(), + image_format="jpeg", + quality=70, + ) + + assert instance.kwargs["image_format"] == "jpeg" + assert instance.kwargs["quality"] == 70 + + +def test_create_implementation_import_branch(monkeypatch: pytest.MonkeyPatch) -> None: + _install_fake_capture_module( + monkeypatch, + "src.services.screenshot_capture.imagemagick_capture", + "ImageMagickScreenshotCapture", + ) + + instance = ScreenshotCaptureFactory._create_implementation( + tool=ScreenshotTool.IMPORT, + desktop_type=DesktopType.X11, + temp_manager=object(), + image_format="webp", + quality=60, + ) + + assert instance.kwargs["image_format"] == "webp" + assert instance.kwargs["quality"] == 60 + + +def test_create_implementation_unsupported_tool_raises() -> None: + with pytest.raises(ScreenshotCaptureError, match="Unsupported screenshot tool"): + ScreenshotCaptureFactory._create_implementation( + tool=ScreenshotTool.UNKNOWN, + desktop_type=DesktopType.X11, + temp_manager=object(), + image_format="png", + quality=90, + ) + + +def test_create_implementation_import_error_is_wrapped(monkeypatch: pytest.MonkeyPatch) -> None: + original_import = builtins.__import__ + + def _raising_import(name, *args, **kwargs): + if name == "src.services.screenshot_capture.x11_capture": + raise ImportError("missing module") + return original_import(name, *args, **kwargs) + + monkeypatch.setattr(builtins, "__import__", _raising_import) + + with pytest.raises(ScreenshotCaptureError, match="Failed to import screenshot capture implementation"): + ScreenshotCaptureFactory._create_implementation( + tool=ScreenshotTool.SCROT, + desktop_type=DesktopType.X11, + temp_manager=object(), + image_format="png", + quality=90, + )