diff --git a/pkgs/clan-cli/clan_cli/custom_logger.py b/pkgs/clan-cli/clan_cli/custom_logger.py index 271bcb75e..ea8a6609d 100644 --- a/pkgs/clan-cli/clan_cli/custom_logger.py +++ b/pkgs/clan-cli/clan_cli/custom_logger.py @@ -38,11 +38,12 @@ FORMATTER = { class CustomFormatter(logging.Formatter): + def __init__(self, log_locations: bool) -> None: + super().__init__() + self.log_locations = log_locations + def format(self, record: logging.LogRecord) -> str: - if record.levelno == logging.DEBUG: - return FORMATTER[record.levelno](record, True).format(record) - else: - return FORMATTER[record.levelno](record, False).format(record) + return FORMATTER[record.levelno](record, self.log_locations).format(record) class ThreadFormatter(logging.Formatter): @@ -75,7 +76,7 @@ def setup_logging(level: Any) -> None: # Create and add your custom handler default_handler.setLevel(level) - default_handler.setFormatter(CustomFormatter()) + default_handler.setFormatter(CustomFormatter(level == logging.DEBUG)) main_logger.addHandler(default_handler) # Set logging level for other modules used by this module diff --git a/pkgs/clan-cli/clan_cli/errors.py b/pkgs/clan-cli/clan_cli/errors.py index f1ee055b3..0f01801ad 100644 --- a/pkgs/clan-cli/clan_cli/errors.py +++ b/pkgs/clan-cli/clan_cli/errors.py @@ -1,11 +1,10 @@ -import os +import shutil from math import floor from pathlib import Path -from typing import NamedTuple def get_term_filler(name: str) -> int: - width, height = os.get_terminal_size() + width, height = shutil.get_terminal_size() filler = floor((width - len(name)) / 2) return filler - 1 @@ -16,16 +15,25 @@ def text_heading(heading: str) -> str: return f"{'=' * filler} {heading} {'=' * filler}" -class CmdOut(NamedTuple): - stdout: str - stderr: str - cwd: Path - command: str - returncode: int - msg: str | None = None +class CmdOut: + def __init__( + self, + stdout: str, + stderr: str, + cwd: Path, + command: str, + returncode: int, + msg: str | None, + ) -> None: + super().__init__() + self.stdout = stdout + self.stderr = stderr + self.cwd = cwd + self.command = command + self.returncode = returncode + self.msg = msg - def __str__(self) -> str: - return f""" + self.error_str = f""" {text_heading(heading="Command")} {self.command} {text_heading(heading="Stderr")} @@ -36,7 +44,10 @@ class CmdOut(NamedTuple): Message: {self.msg} Working Directory: '{self.cwd}' Return Code: {self.returncode} -""" + """ + + def __str__(self) -> str: + return self.error_str class ClanError(Exception): diff --git a/pkgs/clan-vm-manager/clan_vm_manager/executor.py b/pkgs/clan-vm-manager/clan_vm_manager/executor.py index 4cd02de3c..1c33a2462 100644 --- a/pkgs/clan-vm-manager/clan_vm_manager/executor.py +++ b/pkgs/clan-vm-manager/clan_vm_manager/executor.py @@ -61,7 +61,7 @@ def _init_proc( func: Callable, out_file: Path, proc_name: str, - on_except: Callable[[Exception, mp.process.BaseProcess], None], + on_except: Callable[[Exception, mp.process.BaseProcess], None] | None, **kwargs: Any, ) -> None: # Create a new process group @@ -89,7 +89,8 @@ def _init_proc( func(**kwargs) except Exception as ex: traceback.print_exc() - on_except(ex, mp.current_process()) + if on_except is not None: + on_except(ex, mp.current_process()) finally: pid = os.getpid() gpid = os.getpgid(pid=pid) @@ -99,8 +100,8 @@ def _init_proc( def spawn( *, - log_path: Path, - on_except: Callable[[Exception, mp.process.BaseProcess], None], + log_dir: Path, + on_except: Callable[[Exception, mp.process.BaseProcess], None] | None, func: Callable, **kwargs: Any, ) -> MPProcess: @@ -108,13 +109,13 @@ def spawn( if mp.get_start_method(allow_none=True) is None: mp.set_start_method(method="forkserver") - if not log_path.is_dir(): - raise ClanError(f"Log path {log_path} is not a directory") - log_path.mkdir(parents=True, exist_ok=True) + if not log_dir.is_dir(): + raise ClanError(f"Log path {log_dir} is not a directory") + log_dir.mkdir(parents=True, exist_ok=True) # Set names proc_name = f"MPExec:{func.__name__}" - out_file = log_path / "out.log" + out_file = log_dir / "out.log" # Start the process proc = mp.Process( diff --git a/pkgs/clan-vm-manager/clan_vm_manager/model/use_vms.py b/pkgs/clan-vm-manager/clan_vm_manager/model/use_vms.py index 9403125d9..4f08a81b3 100644 --- a/pkgs/clan-vm-manager/clan_vm_manager/model/use_vms.py +++ b/pkgs/clan-vm-manager/clan_vm_manager/model/use_vms.py @@ -1,19 +1,11 @@ -import multiprocessing as mp +from collections.abc import Callable from typing import Any -from clan_cli.errors import ClanError from gi.repository import Gio -from clan_vm_manager.errors.show_error import show_error_dialog from clan_vm_manager.models import VM, get_initial_vms -# https://amolenaar.pages.gitlab.gnome.org/pygobject-docs/Adw-1/class-ToolbarView.html -# Will be executed in the context of the child process -def on_except(error: Exception, proc: mp.process.BaseProcess) -> None: - show_error_dialog(ClanError(str(error))) - - class VMS: """ This is a singleton. @@ -45,6 +37,14 @@ class VMS: cls.list_store.append(vm) return cls._instance + def handle_vm_stopped(self, func: Callable[[VM, VM], None]) -> None: + for vm in self.list_store: + vm.connect("vm_stopped", func) + + def handle_vm_started(self, func: Callable[[VM, VM], None]) -> None: + for vm in self.list_store: + vm.connect("vm_started", func) + def get_running_vms(self) -> list[VM]: return list(filter(lambda vm: vm.is_running(), self.list_store)) diff --git a/pkgs/clan-vm-manager/clan_vm_manager/models.py b/pkgs/clan-vm-manager/clan_vm_manager/models.py index 802abf071..f90c6635b 100644 --- a/pkgs/clan-vm-manager/clan_vm_manager/models.py +++ b/pkgs/clan-vm-manager/clan_vm_manager/models.py @@ -1,9 +1,11 @@ -import multiprocessing as mp import sys +import tempfile import weakref from enum import StrEnum from pathlib import Path +from typing import ClassVar +import gi from clan_cli import vms from clan_cli.errors import ClanError from clan_cli.history.add import HistoryEntry @@ -15,17 +17,24 @@ from clan_vm_manager import assets from .errors.show_error import show_error_dialog from .executor import MPProcess, spawn +gi.require_version("Gtk", "4.0") +import threading + +from gi.repository import GLib + class VMStatus(StrEnum): RUNNING = "Running" STOPPED = "Stopped" -def on_except(error: Exception, proc: mp.process.BaseProcess) -> None: - show_error_dialog(ClanError(str(error))) - - class VM(GObject.Object): + # Define a custom signal with the name "vm_stopped" and a string argument for the message + __gsignals__: ClassVar = { + "vm_started": (GObject.SignalFlags.RUN_FIRST, None, [GObject.Object]), + "vm_stopped": (GObject.SignalFlags.RUN_FIRST, None, [GObject.Object]), + } + def __init__( self, icon: Path, @@ -37,6 +46,9 @@ class VM(GObject.Object): self.data = data self.process = process self.status = status + self.log_dir = tempfile.TemporaryDirectory( + prefix="clan_vm-", suffix=f"-{self.data.flake.flake_attr}" + ) self._finalizer = weakref.finalize(self, self.stop) def start(self) -> None: @@ -46,14 +58,23 @@ class VM(GObject.Object): vm = vms.run.inspect_vm( flake_url=self.data.flake.flake_url, flake_attr=self.data.flake.flake_attr ) - log_path = Path(".") - self.process = spawn( - on_except=on_except, - log_path=log_path, + on_except=None, + log_dir=Path(str(self.log_dir.name)), func=vms.run.run_vm, vm=vm, ) + self.emit("vm_started", self) + GLib.timeout_add(50, self.vm_stopped_task) + + def start_async(self) -> None: + threading.Thread(target=self.start).start() + + def vm_stopped_task(self) -> bool: + if not self.is_running(): + self.emit("vm_stopped", self) + return GLib.SOURCE_REMOVE + return GLib.SOURCE_CONTINUE def is_running(self) -> bool: if self.process is not None: @@ -63,6 +84,9 @@ class VM(GObject.Object): def get_id(self) -> str: return self.data.flake.flake_url + self.data.flake.flake_attr + def stop_async(self) -> None: + threading.Thread(target=self.stop).start() + def stop(self) -> None: if self.process is None: print("VM is already stopped", file=sys.stderr) @@ -71,6 +95,11 @@ class VM(GObject.Object): self.process.kill_group() self.process = None + def read_log(self) -> str: + if self.process is None: + return "" + return self.process.out_file.read_text() + def get_initial_vms() -> list[VM]: vm_list = [] diff --git a/pkgs/clan-vm-manager/clan_vm_manager/views/list.py b/pkgs/clan-vm-manager/clan_vm_manager/views/list.py index ab511f044..be8a01b4b 100644 --- a/pkgs/clan-vm-manager/clan_vm_manager/views/list.py +++ b/pkgs/clan-vm-manager/clan_vm_manager/views/list.py @@ -64,17 +64,38 @@ class ClanList(Gtk.Box): return row - list_store = VMS.use().list_store + vms = VMS.use() - boxed_list.bind_model(list_store, create_widget_func=create_widget) + # TODO: Move this up to create_widget and connect every VM signal to its corresponding switch + vms.handle_vm_stopped(self.stopped_vm) + vms.handle_vm_started(self.started_vm) + + boxed_list.bind_model(vms.list_store, create_widget_func=create_widget) self.append(boxed_list) + def started_vm(self, vm: VM, _vm: VM) -> None: + print("VM started", vm.data.flake.flake_attr) + + def stopped_vm(self, vm: VM, _vm: VM) -> None: + print("VM stopped", vm.data.flake.flake_attr) + + def show_error_dialog(self, error: str) -> None: + dialog = Gtk.MessageDialog( + parent=self.get_toplevel(), + modal=True, + message_type=Gtk.MessageType.ERROR, + buttons=Gtk.ButtonsType.OK, + text=error, + ) + dialog.run() + dialog.destroy() + def on_row_toggle(self, vm: VM, row: Adw.SwitchRow, state: bool) -> None: print("Toggled", vm.data.flake.flake_attr, "active:", row.get_active()) if row.get_active(): - vm.start() + vm.start_async() if not row.get_active(): - vm.stop() + vm.stop_async()