diff --git a/.gitignore b/.gitignore index 222f9bef5..45b863ff6 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ .direnv ***/.hypothesis +out.log .coverage.* **/qubeclan **/testdir diff --git a/checks/impure/flake-module.nix b/checks/impure/flake-module.nix index fa1b77c03..18f3e7ada 100644 --- a/checks/impure/flake-module.nix +++ b/checks/impure/flake-module.nix @@ -13,7 +13,7 @@ ]}" ROOT=$(git rev-parse --show-toplevel) cd "$ROOT/pkgs/clan-cli" - nix develop "$ROOT#clan-cli" -c bash -c "TMPDIR=/tmp python -m pytest -m impure -s ./tests $@" + nix develop "$ROOT#clan-cli" -c bash -c "TMPDIR=/tmp python -m pytest -m impure ./tests $@" ''; runMockApi = pkgs.writeShellScriptBin "run-mock-api" '' diff --git a/pkgs/clan-cli/clan_cli/clan_uri.py b/pkgs/clan-cli/clan_cli/clan_uri.py index f592b96d3..29a7da6a8 100644 --- a/pkgs/clan-cli/clan_cli/clan_uri.py +++ b/pkgs/clan-cli/clan_cli/clan_uri.py @@ -16,8 +16,9 @@ def url_ok(url: str) -> None: try: # Open the URL and get the response object res = urllib.request.urlopen(req) + # Return True if the status code is 200 (OK) - if not res.status_code == 200: + if not res.getcode() == 200: raise ClanError(f"URL has status code: {res.status_code}") except urllib.error.URLError as ex: raise ClanError(f"URL error: {ex}") diff --git a/pkgs/clan-cli/clan_cli/flakes/inspect.py b/pkgs/clan-cli/clan_cli/flakes/inspect.py index 35da21ca4..a2c5f4e6c 100644 --- a/pkgs/clan-cli/clan_cli/flakes/inspect.py +++ b/pkgs/clan-cli/clan_cli/flakes/inspect.py @@ -38,7 +38,7 @@ def inspect_flake(flake_url: str | Path, flake_attr: str) -> FlakeConfig: ] ) - proc = subprocess.run(cmd, text=True, capture_output=True) + proc = subprocess.run(cmd, text=True, stdout=subprocess.PIPE) assert proc.stdout is not None if proc.returncode != 0: raise ClanError( @@ -47,8 +47,6 @@ command: {shlex.join(cmd)} exit code: {proc.returncode} stdout: {proc.stdout} -stderr: -{proc.stderr} """ ) res = proc.stdout.strip() diff --git a/pkgs/clan-cli/clan_cli/machines/list.py b/pkgs/clan-cli/clan_cli/machines/list.py index b16a3c640..18e7a8d37 100644 --- a/pkgs/clan-cli/clan_cli/machines/list.py +++ b/pkgs/clan-cli/clan_cli/machines/list.py @@ -22,7 +22,7 @@ def list_machines(flake_url: Path | str) -> list[str]: "--json", ] ) - proc = subprocess.run(cmd, text=True, capture_output=True) + proc = subprocess.run(cmd, text=True, stdout=subprocess.PIPE) assert proc.stdout is not None if proc.returncode != 0: raise ClanError( @@ -31,8 +31,6 @@ command: {shlex.join(cmd)} exit code: {proc.returncode} stdout: {proc.stdout} -stderr: -{proc.stderr} """ ) res = proc.stdout.strip() diff --git a/pkgs/clan-cli/clan_cli/vms/run.py b/pkgs/clan-cli/clan_cli/vms/run.py index a08b6d01e..9fbdba796 100644 --- a/pkgs/clan-cli/clan_cli/vms/run.py +++ b/pkgs/clan-cli/clan_cli/vms/run.py @@ -18,8 +18,29 @@ from .inspect import VmConfig, inspect_vm log = logging.getLogger(__name__) +def get_qemu_version() -> list[int]: + # Run the command and capture the output + output = subprocess.check_output(["qemu-kvm", "--version"]) + # Decode the output from bytes to string + output_str = output.decode("utf-8") + # Split the output by newline and get the first line + first_line = output_str.split("\n")[0] + # Split the first line by space and get the third element + version = first_line.split(" ")[3] + + # Split the version by dot and convert each part to integer + version_list = [int(x) for x in version.split(".")] + # Return the version as a list of integers + return version_list + + def graphics_options(vm: VmConfig) -> list[str]: + common: list[str] = [] + + # Check if the version is greater than 8.1.3 to enable virtio audio + # if get_qemu_version() > [8, 1, 3]: common = ["-audio", "driver=pa,model=virtio"] + if vm.wayland: # fmt: off return [ diff --git a/pkgs/clan-cli/default.nix b/pkgs/clan-cli/default.nix index 828cdf32a..d2d32c716 100644 --- a/pkgs/clan-cli/default.nix +++ b/pkgs/clan-cli/default.nix @@ -133,7 +133,7 @@ python3.pkgs.buildPythonApplication { cd ./src export NIX_STATE_DIR=$TMPDIR/nix IN_NIX_SANDBOX=1 - ${checkPython}/bin/python -m pytest -m "not impure and not with_core" -s ./tests + ${checkPython}/bin/python -m pytest -m "not impure and not with_core" ./tests touch $out ''; # separate the tests that can never be cached @@ -144,7 +144,7 @@ python3.pkgs.buildPythonApplication { export CLAN_CORE=${clan-core-path} export NIX_STATE_DIR=$TMPDIR/nix IN_NIX_SANDBOX=1 - ${checkPython}/bin/python -m pytest -m "not impure and with_core" -s ./tests + ${checkPython}/bin/python -m pytest -m "not impure and with_core" ./tests touch $out ''; diff --git a/pkgs/clan-cli/enter_nix_sandbox.sh b/pkgs/clan-cli/enter_nix_sandbox.sh deleted file mode 100755 index a2ba747ab..000000000 --- a/pkgs/clan-cli/enter_nix_sandbox.sh +++ /dev/null @@ -1,7 +0,0 @@ -#!/usr/bin/env bash - -set -xeuo pipefail - -PID_NIX=$(pgrep --full "python -m pytest" | cut -d " " -f2 | head -n1) - -sudo cntr attach "$PID_NIX" diff --git a/pkgs/clan-vm-manager/clan-vm-manager.code-workspace b/pkgs/clan-vm-manager/clan-vm-manager.code-workspace index 307d1d20d..e9cebdd9e 100644 --- a/pkgs/clan-vm-manager/clan-vm-manager.code-workspace +++ b/pkgs/clan-vm-manager/clan-vm-manager.code-workspace @@ -8,17 +8,18 @@ }, { "path": "../clan-cli/tests" - } + }, ], "settings": { "python.linting.mypyEnabled": true, "files.exclude": { - "**/.direnv": true, - "**/.mypy_cache": true, - "**/.ruff_cache": true, - "**/.hypothesis": true, "**/__pycache__": true, - "**/.reports": true + "**/.direnv": true, + "**/.hypothesis": true, + "**/.mypy_cache": true, + "**/.reports": true, + "**/.ruff_cache": true, + "**/result": true }, "search.exclude": { "**/.direnv": true, @@ -29,4 +30,4 @@ "**/.reports": true } } -} \ No newline at end of file +} diff --git a/pkgs/clan-vm-manager/clan_vm_manager/app.py b/pkgs/clan-vm-manager/clan_vm_manager/app.py index aecf04af2..1e57b5b37 100644 --- a/pkgs/clan-vm-manager/clan_vm_manager/app.py +++ b/pkgs/clan-vm-manager/clan_vm_manager/app.py @@ -1,14 +1,18 @@ #!/usr/bin/env python3 import argparse from dataclasses import dataclass +from pathlib import Path import gi +from clan_cli import vms gi.require_version("Gtk", "3.0") + from clan_cli.clan_uri import ClanURI from gi.repository import Gio, Gtk from .constants import constants +from .executor import ProcessManager, spawn from .interfaces import Callbacks, InitialJoinValues from .windows.join import JoinWindow from .windows.overview import OverviewWindow @@ -33,8 +37,15 @@ class Application(Gtk.Application): ) self.init_style() self.windows = windows + self.proc_manager = ProcessManager() initial = windows.__dict__[config.initial_window] - self.cbs = Callbacks(show_list=self.show_list, show_join=self.show_join) + self.cbs = Callbacks( + show_list=self.show_list, + show_join=self.show_join, + spawn_vm=self.spawn_vm, + stop_vm=self.stop_vm, + running_vms=self.running_vms, + ) if issubclass(initial, JoinWindow): # see JoinWindow constructor self.window = initial( @@ -46,6 +57,37 @@ class Application(Gtk.Application): # see OverviewWindow constructor self.window = initial(cbs=self.cbs) + # Connect to the shutdown signal + self.connect("shutdown", self.on_shutdown) + + def on_shutdown(self, app: Gtk.Application) -> None: + print("Shutting down") + self.proc_manager.kill_all() + + def spawn_vm(self, url: str, attr: str) -> None: + print(f"spawn_vm {url}") + + # TODO: We should use VMConfig from the history file + vm = vms.run.inspect_vm(flake_url=url, flake_attr=attr) + log_path = Path(".") + + # TODO: We only use the url as the ident. This is not unique as the flake_attr is missing. + # when we migrate everything to use the ClanURI class we can use the full url as the ident + self.proc_manager.spawn( + ident=url, + wait_stdin_con=False, + log_path=log_path, + func=vms.run.run_vm, + vm=vm, + ) + + def stop_vm(self, url: str, attr: str) -> None: + print(f"stop_vm {url}") + self.proc_manager.kill(url) + + def running_vms(self) -> list[str]: + return list(self.proc_manager.procs.keys()) + def show_list(self) -> None: prev = self.window self.window = self.windows.__dict__["overview"](cbs=self.cbs) @@ -125,10 +167,6 @@ def dummy_f(msg: str) -> None: def show_run_vm(parser: argparse.ArgumentParser) -> None: - from pathlib import Path - - from .executor import spawn - log_path = Path(".").resolve() proc = spawn(wait_stdin_con=True, log_path=log_path, func=dummy_f, msg="Hello") input("Press enter to kill process: ") diff --git a/pkgs/clan-vm-manager/clan_vm_manager/executor.py b/pkgs/clan-vm-manager/clan_vm_manager/executor.py index 0c85ba0f1..61bac998f 100644 --- a/pkgs/clan-vm-manager/clan_vm_manager/executor.py +++ b/pkgs/clan-vm-manager/clan_vm_manager/executor.py @@ -2,34 +2,44 @@ import os import signal import sys import traceback +import weakref from pathlib import Path from typing import Any import gi +from clan_cli.errors import ClanError gi.require_version("GdkPixbuf", "2.0") +import dataclasses import multiprocessing as mp from collections.abc import Callable -OUT_FILE: Path | None = None -IN_FILE: Path | None = None + +# Kill the new process and all its children by sending a SIGTERM signal to the process group +def _kill_group(proc: mp.Process) -> None: + pid = proc.pid + assert pid is not None + if proc.is_alive(): + print( + f"Killing process group pid={pid}", + file=sys.stderr, + ) + os.killpg(pid, signal.SIGTERM) + else: + print(f"Process {proc.name} with pid {pid} is already dead", file=sys.stderr) +@dataclasses.dataclass(frozen=True) class MPProcess: - def __init__( - self, *, name: str, proc: mp.Process, out_file: Path, in_file: Path - ) -> None: - self.name = name - self.proc = proc - self.out_file = out_file - self.in_file = in_file + name: str + proc: mp.Process + out_file: Path + in_file: Path # Kill the new process and all its children by sending a SIGTERM signal to the process group def kill_group(self) -> None: - pid = self.proc.pid - assert pid is not None - os.killpg(pid, signal.SIGTERM) + _kill_group(proc=self.proc) def _set_proc_name(name: str) -> None: @@ -51,23 +61,6 @@ def _set_proc_name(name: str) -> None: prctl(15, name.encode(), 0, 0, 0) -def _signal_handler(signum: int, frame: Any) -> None: - signame = signal.strsignal(signum) - print("Signal received:", signame) - - # Delete files - if OUT_FILE is not None: - OUT_FILE.unlink() - if IN_FILE is not None: - IN_FILE.unlink() - - # Restore the default handler - signal.signal(signal.SIGTERM, signal.SIG_DFL) - - # Re-raise the signal - os.kill(os.getpid(), signum) - - def _init_proc( func: Callable, out_file: Path, @@ -76,39 +69,29 @@ def _init_proc( proc_name: str, **kwargs: Any, ) -> None: - # Set the global variables - global OUT_FILE, IN_FILE - OUT_FILE = out_file - IN_FILE = in_file - # Create a new process group os.setsid() # Open stdout and stderr - out_fd = os.open(str(out_file), flags=os.O_RDWR | os.O_CREAT | os.O_TRUNC) - os.dup2(out_fd, sys.stdout.fileno()) - os.dup2(out_fd, sys.stderr.fileno()) + with open(out_file, "w") as out_fd: + os.dup2(out_fd.fileno(), sys.stdout.fileno()) + os.dup2(out_fd.fileno(), sys.stderr.fileno()) # Print some information pid = os.getpid() gpid = os.getpgid(pid=pid) - print(f"Started new process pid={pid} gpid={gpid}") - - # Register the signal handler for SIGINT - signal.signal(signal.SIGTERM, _signal_handler) + print(f"Started new process pid={pid} gpid={gpid}", file=sys.stderr) # Set the process name _set_proc_name(proc_name) # Open stdin - flags = None if wait_stdin_connect: print(f"Waiting for stdin connection on file {in_file}", file=sys.stderr) - flags = os.O_RDONLY + with open(in_file) as in_fd: + os.dup2(in_fd.fileno(), sys.stdin.fileno()) else: - flags = os.O_RDONLY | os.O_NONBLOCK - in_fd = os.open(str(in_file), flags=flags) - os.dup2(in_fd, sys.stdin.fileno()) + sys.stdin.close() # Execute the main function print(f"Executing function {func.__name__} now", file=sys.stderr) @@ -116,9 +99,10 @@ def _init_proc( func(**kwargs) except Exception: traceback.print_exc() + finally: pid = os.getpid() gpid = os.getpgid(pid=pid) - print(f"Killing process group pid={pid} gpid={gpid}") + print(f"Killing process group pid={pid} gpid={gpid}", file=sys.stderr) os.killpg(gpid, signal.SIGTERM) @@ -127,7 +111,13 @@ def spawn( ) -> MPProcess: # Decouple the process from the parent if mp.get_start_method(allow_none=True) is None: - mp.set_start_method(method="spawn") + mp.set_start_method(method="forkserver") + print("Set mp start method to forkserver", file=sys.stderr) + + if not log_path.is_dir(): + raise ClanError(f"Log path {log_path} is not a directory") + if not log_path.exists(): + log_path.mkdir(parents=True) # Set names proc_name = f"MPExec:{func.__name__}" @@ -152,6 +142,7 @@ def spawn( assert proc.pid is not None print(f"Started process '{proc_name}'") print(f"Arguments: {kwargs}") + if wait_stdin_con: cmd = f"cat - > {in_file}" print(f"Connect to stdin with : {cmd}") @@ -165,4 +156,41 @@ def spawn( out_file=out_file, in_file=in_file, ) + return mp_proc + + +# Processes are killed when the ProcessManager is garbage collected +class ProcessManager: + def __init__(self) -> None: + self.procs: dict[str, MPProcess] = dict() + self._finalizer = weakref.finalize(self, self.kill_all) + + def spawn( + self, + *, + ident: str, + wait_stdin_con: bool, + log_path: Path, + func: Callable, + **kwargs: Any, + ) -> MPProcess: + proc = spawn( + wait_stdin_con=wait_stdin_con, log_path=log_path, func=func, **kwargs + ) + if ident in self.procs: + raise ClanError(f"Process with id {ident} already exists") + self.procs[ident] = proc + return proc + + def kill_all(self) -> None: + print("Killing all processes", file=sys.stderr) + for proc in self.procs.values(): + proc.kill_group() + + def kill(self, ident: str) -> None: + if ident not in self.procs: + raise ClanError(f"Process with id {ident} does not exist") + proc = self.procs[ident] + proc.kill_group() + del self.procs[ident] diff --git a/pkgs/clan-vm-manager/clan_vm_manager/interfaces.py b/pkgs/clan-vm-manager/clan_vm_manager/interfaces.py index 0284456b5..6aa94c278 100644 --- a/pkgs/clan-vm-manager/clan_vm_manager/interfaces.py +++ b/pkgs/clan-vm-manager/clan_vm_manager/interfaces.py @@ -13,3 +13,6 @@ class InitialJoinValues: class Callbacks: show_list: Callable[[], None] show_join: Callable[[], None] + spawn_vm: Callable[[str, str], None] + stop_vm: Callable[[str, str], None] + running_vms: Callable[[], list[str]] diff --git a/pkgs/clan-vm-manager/clan_vm_manager/models.py b/pkgs/clan-vm-manager/clan_vm_manager/models.py index c1b726fda..2605a6797 100644 --- a/pkgs/clan-vm-manager/clan_vm_manager/models.py +++ b/pkgs/clan-vm-manager/clan_vm_manager/models.py @@ -19,6 +19,7 @@ class VMBase: name: str url: str status: bool + _flake_attr: str @staticmethod def name_to_type_map() -> OrderedDict[str, type]: @@ -28,6 +29,7 @@ class VMBase: "Name": str, "URL": str, "Online": bool, + "_FlakeAttr": str, } ) @@ -42,13 +44,10 @@ class VMBase: "Name": self.name, "URL": self.url, "Online": self.status, + "_FlakeAttr": self._flake_attr, } ) - def run(self) -> None: - print(f"Running VM {self.name}") - # vm = vms.run.inspect_vm(flake_url=self.url, flake_attr="defaultVM") - @dataclass(frozen=True) class VM: @@ -60,7 +59,9 @@ class VM: # start/end indexes can be used optionally for pagination -def get_initial_vms(start: int = 0, end: int | None = None) -> list[VM]: +def get_initial_vms( + running_vms: list[str], start: int = 0, end: int | None = None +) -> list[VM]: vm_list = [] # Execute `clan flakes add ` to democlan for this to work @@ -69,11 +70,16 @@ def get_initial_vms(start: int = 0, end: int | None = None) -> list[VM]: if entry.flake.icon is not None: icon = entry.flake.icon + status = False + if entry.flake.flake_url in running_vms: + status = True + base = VMBase( icon=icon, name=entry.flake.clan_name, url=entry.flake.flake_url, - status=False, + status=status, + _flake_attr=entry.flake.flake_attr, ) vm_list.append(VM(base=base)) diff --git a/pkgs/clan-vm-manager/clan_vm_manager/ui/clan_select_list.py b/pkgs/clan-vm-manager/clan_vm_manager/ui/clan_select_list.py index 42b393e7f..6b7b26df2 100644 --- a/pkgs/clan-vm-manager/clan_vm_manager/ui/clan_select_list.py +++ b/pkgs/clan-vm-manager/clan_vm_manager/ui/clan_select_list.py @@ -2,7 +2,8 @@ from collections.abc import Callable from gi.repository import GdkPixbuf, Gtk -from ..models import VMBase, get_initial_vms +from ..interfaces import Callbacks +from ..models import VMBase class ClanEditForm(Gtk.ListBox): @@ -55,11 +56,7 @@ class ClanEdit(Gtk.Box): self.show_list = remount_list self.selected = selected_vm - button_hooks = { - "on_save_clicked": self.on_save, - } - - self.toolbar = ClanEditToolbar(**button_hooks) + self.toolbar = ClanEditToolbar(on_save_clicked=self.on_save) self.add(self.toolbar) self.add(ClanEditForm(selected=self.selected)) @@ -88,8 +85,9 @@ class ClanList(Gtk.Box): remount_list: Callable[[], None], remount_edit: Callable[[], None], set_selected: Callable[[VMBase | None], None], - show_join: Callable[[], None], + cbs: Callbacks, selected_vm: VMBase | None, + vms: list[VMBase], show_toolbar: bool = True, ) -> None: super().__init__(orientation=Gtk.Orientation.VERTICAL, expand=True) @@ -98,35 +96,45 @@ class ClanList(Gtk.Box): self.remount_list_view = remount_list self.set_selected = set_selected self.show_toolbar = show_toolbar - self.show_join = show_join + self.cbs = cbs self.selected_vm: VMBase | None = selected_vm - button_hooks = { - "on_start_clicked": self.on_start_clicked, - "on_stop_clicked": self.on_stop_clicked, - "on_edit_clicked": self.on_edit_clicked, - "on_join_clicked": self.on_join_clicked, - } if show_toolbar: - self.toolbar = ClanListToolbar(**button_hooks) + self.toolbar = ClanListToolbar( + on_start_clicked=self.on_start_clicked, + on_stop_clicked=self.on_stop_clicked, + on_edit_clicked=self.on_edit_clicked, + on_join_clicked=self.on_join_clicked, + ) self.toolbar.set_is_selected(self.selected_vm is not None) self.add(self.toolbar) - self.list_hooks = { - "on_select_row": self.on_select_vm, - } - self.add(ClanListView(**self.list_hooks, selected_vm=selected_vm)) + self.add( + ClanListView( + vms=vms, + on_select_row=self.on_select_vm, + selected_vm=selected_vm, + on_double_click=self.on_double_click, + ) + ) + + def on_double_click(self, vm: VMBase) -> None: + print(f"on_double_click: {vm.name}") + self.on_start_clicked(self) def on_start_clicked(self, widget: Gtk.Widget) -> None: print("Start clicked") if self.selected_vm: - self.selected_vm.run() + self.cbs.spawn_vm(self.selected_vm.url, self.selected_vm._flake_attr) # Call this to reload self.remount_list_view() def on_stop_clicked(self, widget: Gtk.Widget) -> None: print("Stop clicked") + if self.selected_vm: + self.cbs.stop_vm(self.selected_vm.url, self.selected_vm._flake_attr) + self.remount_list_view() def on_join_clicked(self, widget: Gtk.Widget) -> None: print("Join clicked") @@ -208,10 +216,13 @@ class ClanListView(Gtk.Box): *, on_select_row: Callable[[VMBase], None], selected_vm: VMBase | None, + vms: list[VMBase], + on_double_click: Callable[[VMBase], None], ) -> None: super().__init__(expand=True) - self.vms: list[VMBase] = [vm.base for vm in get_initial_vms()] + self.vms: list[VMBase] = vms self.on_select_row = on_select_row + self.on_double_click = on_double_click store_types = VMBase.name_to_type_map().values() self.list_store = Gtk.ListStore(*store_types) @@ -264,7 +275,7 @@ class ClanListView(Gtk.Box): model, row = selection.get_selected() if row is not None: vm = VMBase(*model[row]) - vm.run() + self.on_double_click(vm) def setColRenderers(tree_view: Gtk.TreeView) -> None: diff --git a/pkgs/clan-vm-manager/clan_vm_manager/windows/overview.py b/pkgs/clan-vm-manager/clan_vm_manager/windows/overview.py index d73906258..e0a65b681 100644 --- a/pkgs/clan-vm-manager/clan_vm_manager/windows/overview.py +++ b/pkgs/clan-vm-manager/clan_vm_manager/windows/overview.py @@ -2,7 +2,7 @@ from typing import Any import gi -from ..models import VMBase +from ..models import VMBase, get_initial_vms gi.require_version("Gtk", "3.0") @@ -19,18 +19,20 @@ class OverviewWindow(Gtk.ApplicationWindow): self.set_title("cLAN Manager") self.connect("delete-event", self.on_quit) self.set_default_size(800, 600) + self.cbs = cbs vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6, expand=True) self.add(vbox) self.stack = Gtk.Stack() - self.list_hooks = { - "remount_list": self.remount_list_view, - "remount_edit": self.remount_edit_view, - "set_selected": self.set_selected, - "show_join": cbs.show_join, - } - clan_list = ClanList(**self.list_hooks, selected_vm=None) # type: ignore + clan_list = ClanList( + vms=[vm.base for vm in get_initial_vms(self.cbs.running_vms())], + cbs=self.cbs, + remount_list=self.remount_list_view, + remount_edit=self.remount_edit_view, + set_selected=self.set_selected, + selected_vm=None, + ) # Add named stacks self.stack.add_titled(clan_list, "list", "List") self.stack.add_titled( @@ -59,7 +61,14 @@ class OverviewWindow(Gtk.ApplicationWindow): if widget: widget.destroy() - clan_list = ClanList(**self.list_hooks, selected_vm=self.selected_vm) # type: ignore + clan_list = ClanList( + vms=[vm.base for vm in get_initial_vms(self.cbs.running_vms())], + cbs=self.cbs, + remount_list=self.remount_list_view, + remount_edit=self.remount_edit_view, + set_selected=self.set_selected, + selected_vm=self.selected_vm, + ) self.stack.add_titled(clan_list, "list", "List") self.show_all() self.stack.set_visible_child_name("list")