Source code for ocom.core.tool

"""Base tool abstraction for network/privacy tools."""

from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from enum import Enum
from typing import TYPE_CHECKING, ClassVar

from ocom.core.process import ProcessManager

if TYPE_CHECKING:
    import asyncio
    from collections.abc import Callable

__all__ = ["BaseTool", "ToolConfig", "ToolStatus"]


[docs] class ToolStatus(Enum): """Status of a network tool.""" UNAVAILABLE = "unavailable" # Tool not installed on system STOPPED = "stopped" # Installed but not running STARTING = "starting" # In process of starting RUNNING = "running" # Currently active STOPPING = "stopping" # In process of stopping ERROR = "error" # Failed state @property def is_transitioning(self) -> bool: """Check if status is a transitional state.""" return self in {ToolStatus.STARTING, ToolStatus.STOPPING} @property def can_start(self) -> bool: """Check if tool can be started from this state.""" return self in {ToolStatus.STOPPED, ToolStatus.ERROR} @property def can_stop(self) -> bool: """Check if tool can be stopped from this state.""" return self == ToolStatus.RUNNING
[docs] @dataclass class ToolConfig: """Configuration for a specific tool instance.""" enabled: bool = True config_file: str | None = None # Selected config file (e.g., .ovpn) config_dirs: list[str] = field(default_factory=list) # Directories to scan extra_args: list[str] = field(default_factory=list) # Additional CLI arguments options: dict[str, str | bool | int] = field( default_factory=dict ) # Tool-specific options
[docs] class BaseTool(ABC): """Abstract base class for all network/privacy tools. Subclasses must implement all abstract methods to integrate a new tool into the ocom TUI. """ # Class attributes to be overridden by subclasses name: str = "Unknown Tool" description: str = "" command: str = "" # Primary CLI command to check availability requires_sudo: bool = False supports_configs: bool = False # Whether tool uses config files (like .ovpn) config_extensions: ClassVar[list[str]] = [] # File extensions (e.g., [".ovpn"]) install_url: str = "" # URL to installation documentation conflicts_with: ClassVar[list[str]] = [] # Conflicting tool names (auto-stopped) def __init__(self) -> None: """Initialize the tool in the UNAVAILABLE state.""" self._status: ToolStatus = ToolStatus.UNAVAILABLE self._process: asyncio.subprocess.Process | None = None self._error_message: str | None = None self._current_config: str | None = None self._output_callback: Callable[[str, str], None] | None = None def set_output_callback(self, callback: Callable[[str, str], None] | None) -> None: """Set callback for tool output. Args: callback: Function called with (tool_name, message) for each output line. """ self._output_callback = callback def _emit_output(self, message: str) -> None: """Emit output to the registered callback. Args: message: The output message. """ if self._output_callback: self._output_callback(self.name, message) @property def status(self) -> ToolStatus: """Current tool status.""" return self._status @property def error_message(self) -> str | None: """Error message if status is ERROR.""" return self._error_message @property def current_config(self) -> str | None: """Currently active config file, if any.""" return self._current_config
[docs] async def check_available(self) -> bool: """Check if the tool is installed on the system. Default implementation checks if self.command exists in PATH. Returns: True if the tool is available, False otherwise. """ if ProcessManager.find_command(self.command): self._status = ToolStatus.STOPPED return True self._status = ToolStatus.UNAVAILABLE return False
[docs] @abstractmethod async def start(self, config: ToolConfig) -> bool: """Start the tool with the given configuration. Args: config: Tool configuration including selected config file and options. Returns: True if started successfully, False otherwise. """
[docs] @abstractmethod async def stop(self) -> bool: """Stop the tool. Returns: True if stopped successfully, False otherwise. """
[docs] @abstractmethod async def refresh_status(self) -> ToolStatus: """Refresh and return the current status. This is called periodically to update the UI. Returns: Current ToolStatus. """
[docs] def get_config_files(self, config: ToolConfig) -> list[str]: # ruff: ignore[no-self-use] """Get list of available config files. Override this for tools that use config files. Args: config: Tool configuration with directories to scan. Returns: List of config file paths. """ # Base hook: the default implementation ignores config; subclasses that # support config files override this and read it. _ = config return []
[docs] def get_status_text(self) -> str: """Get human-readable status text for display. Can be overridden for tool-specific status details. Returns: A human-readable string describing the current status. """ if self._status == ToolStatus.ERROR and self._error_message: return f"Error: {self._error_message}" if self._status == ToolStatus.RUNNING and self._current_config: return self._current_config return self._status.value.capitalize()