Merge pull request 'UI: Added signal handling for stopped and started vm.' (#750) from Qubasa-main into main

This commit is contained in:
clan-bot
2024-01-19 17:58:10 +00:00
6 changed files with 111 additions and 48 deletions

View File

@@ -38,11 +38,12 @@ FORMATTER = {
class CustomFormatter(logging.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: def format(self, record: logging.LogRecord) -> str:
if record.levelno == logging.DEBUG: return FORMATTER[record.levelno](record, self.log_locations).format(record)
return FORMATTER[record.levelno](record, True).format(record)
else:
return FORMATTER[record.levelno](record, False).format(record)
class ThreadFormatter(logging.Formatter): class ThreadFormatter(logging.Formatter):
@@ -75,7 +76,7 @@ def setup_logging(level: Any) -> None:
# Create and add your custom handler # Create and add your custom handler
default_handler.setLevel(level) default_handler.setLevel(level)
default_handler.setFormatter(CustomFormatter()) default_handler.setFormatter(CustomFormatter(level == logging.DEBUG))
main_logger.addHandler(default_handler) main_logger.addHandler(default_handler)
# Set logging level for other modules used by this module # Set logging level for other modules used by this module

View File

@@ -1,11 +1,10 @@
import os import shutil
from math import floor from math import floor
from pathlib import Path from pathlib import Path
from typing import NamedTuple
def get_term_filler(name: str) -> int: 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) filler = floor((width - len(name)) / 2)
return filler - 1 return filler - 1
@@ -16,16 +15,25 @@ def text_heading(heading: str) -> str:
return f"{'=' * filler} {heading} {'=' * filler}" return f"{'=' * filler} {heading} {'=' * filler}"
class CmdOut(NamedTuple): class CmdOut:
stdout: str def __init__(
stderr: str self,
cwd: Path stdout: str,
command: str stderr: str,
returncode: int cwd: Path,
msg: str | None = None 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: self.error_str = f"""
return f"""
{text_heading(heading="Command")} {text_heading(heading="Command")}
{self.command} {self.command}
{text_heading(heading="Stderr")} {text_heading(heading="Stderr")}
@@ -36,7 +44,10 @@ class CmdOut(NamedTuple):
Message: {self.msg} Message: {self.msg}
Working Directory: '{self.cwd}' Working Directory: '{self.cwd}'
Return Code: {self.returncode} Return Code: {self.returncode}
""" """
def __str__(self) -> str:
return self.error_str
class ClanError(Exception): class ClanError(Exception):

View File

@@ -61,7 +61,7 @@ def _init_proc(
func: Callable, func: Callable,
out_file: Path, out_file: Path,
proc_name: str, proc_name: str,
on_except: Callable[[Exception, mp.process.BaseProcess], None], on_except: Callable[[Exception, mp.process.BaseProcess], None] | None,
**kwargs: Any, **kwargs: Any,
) -> None: ) -> None:
# Create a new process group # Create a new process group
@@ -89,7 +89,8 @@ def _init_proc(
func(**kwargs) func(**kwargs)
except Exception as ex: except Exception as ex:
traceback.print_exc() traceback.print_exc()
on_except(ex, mp.current_process()) if on_except is not None:
on_except(ex, mp.current_process())
finally: finally:
pid = os.getpid() pid = os.getpid()
gpid = os.getpgid(pid=pid) gpid = os.getpgid(pid=pid)
@@ -99,8 +100,8 @@ def _init_proc(
def spawn( def spawn(
*, *,
log_path: Path, log_dir: Path,
on_except: Callable[[Exception, mp.process.BaseProcess], None], on_except: Callable[[Exception, mp.process.BaseProcess], None] | None,
func: Callable, func: Callable,
**kwargs: Any, **kwargs: Any,
) -> MPProcess: ) -> MPProcess:
@@ -108,13 +109,13 @@ def spawn(
if mp.get_start_method(allow_none=True) is None: if mp.get_start_method(allow_none=True) is None:
mp.set_start_method(method="forkserver") mp.set_start_method(method="forkserver")
if not log_path.is_dir(): if not log_dir.is_dir():
raise ClanError(f"Log path {log_path} is not a directory") raise ClanError(f"Log path {log_dir} is not a directory")
log_path.mkdir(parents=True, exist_ok=True) log_dir.mkdir(parents=True, exist_ok=True)
# Set names # Set names
proc_name = f"MPExec:{func.__name__}" proc_name = f"MPExec:{func.__name__}"
out_file = log_path / "out.log" out_file = log_dir / "out.log"
# Start the process # Start the process
proc = mp.Process( proc = mp.Process(

View File

@@ -1,19 +1,11 @@
import multiprocessing as mp from collections.abc import Callable
from typing import Any from typing import Any
from clan_cli.errors import ClanError
from gi.repository import Gio 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 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: class VMS:
""" """
This is a singleton. This is a singleton.
@@ -45,6 +37,14 @@ class VMS:
cls.list_store.append(vm) cls.list_store.append(vm)
return cls._instance 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]: def get_running_vms(self) -> list[VM]:
return list(filter(lambda vm: vm.is_running(), self.list_store)) return list(filter(lambda vm: vm.is_running(), self.list_store))

View File

@@ -1,9 +1,11 @@
import multiprocessing as mp
import sys import sys
import tempfile
import weakref import weakref
from enum import StrEnum from enum import StrEnum
from pathlib import Path from pathlib import Path
from typing import ClassVar
import gi
from clan_cli import vms from clan_cli import vms
from clan_cli.errors import ClanError from clan_cli.errors import ClanError
from clan_cli.history.add import HistoryEntry 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 .errors.show_error import show_error_dialog
from .executor import MPProcess, spawn from .executor import MPProcess, spawn
gi.require_version("Gtk", "4.0")
import threading
from gi.repository import GLib
class VMStatus(StrEnum): class VMStatus(StrEnum):
RUNNING = "Running" RUNNING = "Running"
STOPPED = "Stopped" STOPPED = "Stopped"
def on_except(error: Exception, proc: mp.process.BaseProcess) -> None:
show_error_dialog(ClanError(str(error)))
class VM(GObject.Object): 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__( def __init__(
self, self,
icon: Path, icon: Path,
@@ -37,6 +46,9 @@ class VM(GObject.Object):
self.data = data self.data = data
self.process = process self.process = process
self.status = status 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) self._finalizer = weakref.finalize(self, self.stop)
def start(self) -> None: def start(self) -> None:
@@ -46,14 +58,23 @@ class VM(GObject.Object):
vm = vms.run.inspect_vm( vm = vms.run.inspect_vm(
flake_url=self.data.flake.flake_url, flake_attr=self.data.flake.flake_attr flake_url=self.data.flake.flake_url, flake_attr=self.data.flake.flake_attr
) )
log_path = Path(".")
self.process = spawn( self.process = spawn(
on_except=on_except, on_except=None,
log_path=log_path, log_dir=Path(str(self.log_dir.name)),
func=vms.run.run_vm, func=vms.run.run_vm,
vm=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: def is_running(self) -> bool:
if self.process is not None: if self.process is not None:
@@ -63,6 +84,9 @@ class VM(GObject.Object):
def get_id(self) -> str: def get_id(self) -> str:
return self.data.flake.flake_url + self.data.flake.flake_attr 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: def stop(self) -> None:
if self.process is None: if self.process is None:
print("VM is already stopped", file=sys.stderr) print("VM is already stopped", file=sys.stderr)
@@ -71,6 +95,11 @@ class VM(GObject.Object):
self.process.kill_group() self.process.kill_group()
self.process = None 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]: def get_initial_vms() -> list[VM]:
vm_list = [] vm_list = []

View File

@@ -64,17 +64,38 @@ class ClanList(Gtk.Box):
return row 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) 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: def on_row_toggle(self, vm: VM, row: Adw.SwitchRow, state: bool) -> None:
print("Toggled", vm.data.flake.flake_attr, "active:", row.get_active()) print("Toggled", vm.data.flake.flake_attr, "active:", row.get_active())
if row.get_active(): if row.get_active():
vm.start() vm.start_async()
if not row.get_active(): if not row.get_active():
vm.stop() vm.stop_async()