Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
52 changes: 30 additions & 22 deletions src/lib/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
and troubleshooting guidance.
"""

from typing import Optional


class VisionError(Exception):
"""
Expand All @@ -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.

Expand Down Expand Up @@ -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"
Expand All @@ -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"
Expand All @@ -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"
Expand All @@ -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"
Expand All @@ -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"
Expand All @@ -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"
Expand All @@ -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"
Expand All @@ -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"
Expand All @@ -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 = (
Expand All @@ -190,22 +198,22 @@ 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)


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:
Expand All @@ -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"
Expand All @@ -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"
Expand All @@ -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"
Expand All @@ -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"
Expand All @@ -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"
Expand Down
4 changes: 2 additions & 2 deletions src/models/entities.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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
Expand Down
99 changes: 99 additions & 0 deletions tests/unit/test_screenshot_capture_factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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,
)
Loading