Ctrl K
ComunidadGuías…

rex/app_launcher.py

Rama master · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
"""Application launcher for OS-level app launching.

Provides a safe, registry-backed mechanism for launching desktop applications
by name. Applications must be explicitly registered before they can be launched.

Usage::

    launcher = AppLauncher()
    launcher.register("notepad", "notepad.exe")
    result = launcher.launch("notepad")
    print(result.pid)
"""

from __future__ import annotations

import logging
import subprocess
from dataclasses import dataclass

logger = logging.getLogger(__name__)


class AppNotRegisteredError(Exception):
    """Raised when an app name is not in the registry."""


class AppLaunchError(Exception):
    """Raised when an application fails to launch."""


@dataclass
class LaunchResult:
    """Result of an application launch attempt."""

    success: bool
    app_name: str
    pid: int | None = None
    error: str | None = None


class AppLauncher:
    """Registry-backed application launcher.

    Applications must be registered by name before they can be launched.
    Launching is non-blocking (uses ``subprocess.Popen``).

    Args:
        apps: Optional pre-populated mapping of name -> executable path.
    """

    def __init__(self, apps: dict[str, str] | None = None) -> None:
        self._registry: dict[str, str] = dict(apps or {})

    # ------------------------------------------------------------------
    # Registry management
    # ------------------------------------------------------------------

    def register(self, name: str, executable: str) -> None:
        """Register an application by name.

        Args:
            name: Logical name for the application (e.g. ``"notepad"``).
            executable: Path or command name to execute (e.g. ``"notepad.exe"``).

        Raises:
            ValueError: If name or executable is empty.
        """
        if not name or not name.strip():
            raise ValueError("App name must not be empty")
        if not executable or not executable.strip():
            raise ValueError("Executable must not be empty")
        self._registry[name.strip()] = executable.strip()
        logger.debug("Registered app %r -> %r", name, executable)

    def unregister(self, name: str) -> bool:
        """Remove an application from the registry.

        Args:
            name: Logical name to remove.

        Returns:
            ``True`` if the app was registered and removed, ``False`` otherwise.
        """
        if name in self._registry:
            del self._registry[name]
            logger.debug("Unregistered app %r", name)
            return True
        return False

    def list_apps(self) -> list[str]:
        """Return the names of all registered applications."""
        return sorted(self._registry.keys())

    def is_registered(self, name: str) -> bool:
        """Return ``True`` if *name* is a registered application."""
        return name in self._registry

    # ------------------------------------------------------------------
    # Launch
    # ------------------------------------------------------------------

    def launch(self, name: str, args: list[str] | None = None) -> LaunchResult:
        """Launch a registered application.

        The launch is non-blocking. The returned :class:`LaunchResult` contains
        the PID of the launched process.

        Args:
            name: Logical name of the application to launch.
            args: Optional list of arguments to pass to the executable.

        Returns:
            :class:`LaunchResult` with ``success=True`` and ``pid`` set on
            success, or ``success=False`` and ``error`` set on failure.

        Raises:
            AppNotRegisteredError: If *name* is not in the registry.
        """
        if name not in self._registry:
            raise AppNotRegisteredError(
                f"App {name!r} is not registered. "
                f"Register it first with launcher.register(name, executable). "
                f"Known apps: {sorted(self._registry.keys())}"
            )

        executable = self._registry[name]
        argv = [executable] + (args or [])

        try:
            proc = subprocess.Popen(  # noqa: S603
                argv,
                shell=False,
                stdout=subprocess.DEVNULL,
                stderr=subprocess.DEVNULL,
            )
            pid = proc.pid
            logger.info("Launched app %r (pid=%d)", name, pid)
            return LaunchResult(success=True, app_name=name, pid=pid)
        except FileNotFoundError:
            error = f"Executable not found: {executable!r}"
            logger.warning("Failed to launch %r: %s", name, error)
            return LaunchResult(success=False, app_name=name, error=error)
        except PermissionError as exc:
            error = f"Permission denied launching {executable!r}: {exc}"
            logger.warning("Failed to launch %r: %s", name, error)
            return LaunchResult(success=False, app_name=name, error=error)
        except Exception as exc:  # noqa: BLE001
            error = f"Launch failed: {exc}"
            logger.warning("Failed to launch %r: %s", name, error)
            return LaunchResult(success=False, app_name=name, error=error)


# ---------------------------------------------------------------------------
# Module-level singleton
# ---------------------------------------------------------------------------

_launcher: AppLauncher | None = None


def get_app_launcher() -> AppLauncher:
    """Return the module-level :class:`AppLauncher` singleton."""
    global _launcher  # noqa: PLW0603
    if _launcher is None:
        _launcher = AppLauncher()
    return _launcher


def set_app_launcher(launcher: AppLauncher | None) -> None:
    """Replace the module-level singleton (for testing)."""
    global _launcher  # noqa: PLW0603
    _launcher = launcher


__all__ = [
    "AppLaunchError",
    "AppLauncher",
    "AppNotRegisteredError",
    "LaunchResult",
    "get_app_launcher",
    "set_app_launcher",
]