rex/app_launcher.py
Rama master · réplica de lectura del repositorio original.
"""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",
]