Merge pull request 'UI: Added signal handling for stopped and started vm.' (#750) from Qubasa-main into main
All checks were successful
checks / test (push) Successful in 30s
checks-impure / test (push) Successful in 1m18s

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

View File

@ -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

View File

@ -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):

View File

@ -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(

View File

@ -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))

View File

@ -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 = []

View File

@ -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()