Merge pull request 'clan-vm-manager: Fix incorrect use of all Glib.idle_add uses' (#892) from Qubasa-main into main

This commit is contained in:
clan-bot
2024-03-03 08:49:07 +00:00
6 changed files with 133 additions and 114 deletions

View File

@@ -35,14 +35,14 @@ class HistoryEntry:
self.flake = FlakeConfig(**self.flake) self.flake = FlakeConfig(**self.flake)
def merge_dicts(d1: dict, d2: dict) -> dict: def _merge_dicts(d1: dict, d2: dict) -> dict:
# create a new dictionary that copies d1 # create a new dictionary that copies d1
merged = dict(d1) merged = dict(d1)
# iterate over the keys and values of d2 # iterate over the keys and values of d2
for key, value in d2.items(): for key, value in d2.items():
# if the key is in d1 and both values are dictionaries, merge them recursively # if the key is in d1 and both values are dictionaries, merge them recursively
if key in d1 and isinstance(d1[key], dict) and isinstance(value, dict): if key in d1 and isinstance(d1[key], dict) and isinstance(value, dict):
merged[key] = merge_dicts(d1[key], value) merged[key] = _merge_dicts(d1[key], value)
# otherwise, update the value of the key in the merged dictionary # otherwise, update the value of the key in the merged dictionary
else: else:
merged[key] = value merged[key] = value
@@ -59,7 +59,7 @@ def list_history() -> list[HistoryEntry]:
parsed = read_history_file() parsed = read_history_file()
for i, p in enumerate(parsed.copy()): for i, p in enumerate(parsed.copy()):
# Everything from the settings dict is merged into the flake dict, and can override existing values # Everything from the settings dict is merged into the flake dict, and can override existing values
parsed[i] = merge_dicts(p, p.get("settings", {})) parsed[i] = _merge_dicts(p, p.get("settings", {}))
logs = [HistoryEntry(**p) for p in parsed] logs = [HistoryEntry(**p) for p in parsed]
except (json.JSONDecodeError, TypeError) as ex: except (json.JSONDecodeError, TypeError) as ex:
raise ClanError(f"History file at {user_history_file()} is corrupted") from ex raise ClanError(f"History file at {user_history_file()} is corrupted") from ex
@@ -76,40 +76,47 @@ def new_history_entry(url: str, machine: str) -> HistoryEntry:
) )
def add_history(uri: ClanURI, *, all_machines: bool) -> list[HistoryEntry]: def add_all_to_history(uri: ClanURI) -> list[HistoryEntry]:
history = list_history()
new_entries: list[HistoryEntry] = []
for machine in list_machines(uri.get_internal()):
new_entry = _add_maschine_to_history_list(uri.get_internal(), machine, history)
new_entries.append(new_entry)
write_history_file(history)
return new_entries
def add_history(uri: ClanURI) -> HistoryEntry:
user_history_file().parent.mkdir(parents=True, exist_ok=True) user_history_file().parent.mkdir(parents=True, exist_ok=True)
history = list_history() history = list_history()
if not all_machines: new_entry = _add_maschine_to_history_list(
add_maschine_to_history(uri.get_internal(), uri.params.flake_attr, history) uri.get_internal(), uri.params.flake_attr, history
)
if all_machines:
for machine in list_machines(uri.get_internal()):
add_maschine_to_history(uri.get_internal(), machine, history)
write_history_file(history) write_history_file(history)
return history return new_entry
def add_maschine_to_history( def _add_maschine_to_history_list(
uri_path: str, uri_machine: str, logs: list[HistoryEntry] uri_path: str, uri_machine: str, entries: list[HistoryEntry]
) -> None: ) -> HistoryEntry:
found = False for new_entry in entries:
for entry in logs:
if ( if (
entry.flake.flake_url == str(uri_path) new_entry.flake.flake_url == str(uri_path)
and entry.flake.flake_attr == uri_machine and new_entry.flake.flake_attr == uri_machine
): ):
found = True new_entry.last_used = datetime.datetime.now().isoformat()
entry.last_used = datetime.datetime.now().isoformat() return new_entry
if not found: new_entry = new_history_entry(uri_path, uri_machine)
history = new_history_entry(uri_path, uri_machine) entries.append(new_entry)
logs.append(history) return new_entry
def add_history_command(args: argparse.Namespace) -> None: def add_history_command(args: argparse.Namespace) -> None:
add_history(args.uri, all_machines=args.all) if args.all:
add_all_to_history(args.uri)
else:
add_history(args.uri)
# takes a (sub)parser and configures it # takes a (sub)parser and configures it

View File

@@ -95,6 +95,10 @@ class GKVStore(GObject.GObject, Gio.ListModel, Generic[K, V]):
# # # #
######################### #########################
def insert(self, position: int, item: V) -> None: def insert(self, position: int, item: V) -> None:
log.warning("Inserting is O(n) in GKVStore. Better use append")
log.warning(
"This functions may have incorrect items_changed signal behavior. Please test it"
)
key = self.key_gen(item) key = self.key_gen(item)
if key in self._items: if key in self._items:
raise ValueError("Key already exists in the dictionary") raise ValueError("Key already exists in the dictionary")
@@ -141,12 +145,12 @@ class GKVStore(GObject.GObject, Gio.ListModel, Generic[K, V]):
log.warning("Updating an existing key in GKVStore is O(n)") log.warning("Updating an existing key in GKVStore is O(n)")
position = self.keys().index(key) position = self.keys().index(key)
self._items[key] = value self._items[key] = value
self.items_changed(position, 0, 1) self.items_changed(position, 1, 1)
else: else:
# Add the new key-value pair # Add the new key-value pair
position = max(len(self._items) - 1, 0)
self._items[key] = value self._items[key] = value
self._items.move_to_end(key) self._items.move_to_end(key)
position = max(len(self._items) - 1, 0)
self.items_changed(position, 0, 1) self.items_changed(position, 0, 1)
# O(n) operation # O(n) operation

View File

@@ -4,11 +4,10 @@ from collections.abc import Callable
from typing import Any, ClassVar from typing import Any, ClassVar
import gi import gi
from clan_cli import ClanError
from clan_cli.clan_uri import ClanURI from clan_cli.clan_uri import ClanURI
from clan_cli.history.add import add_history from clan_cli.history.add import HistoryEntry, add_history
from clan_vm_manager.errors.show_error import show_error_dialog from clan_vm_manager.models.use_vms import VMs
gi.require_version("Gtk", "4.0") gi.require_version("Gtk", "4.0")
gi.require_version("Adw", "1") gi.require_version("Adw", "1")
@@ -25,29 +24,33 @@ class JoinValue(GObject.Object):
} }
url: ClanURI url: ClanURI
entry: HistoryEntry | None
def __init__( def _join_finished(self) -> bool:
self, url: ClanURI, on_join: Callable[["JoinValue", Any], None] self.emit("join_finished", self)
) -> None: return GLib.SOURCE_REMOVE
def __init__(self, url: ClanURI) -> None:
super().__init__() super().__init__()
self.url = url self.url = url
self.connect("join_finished", on_join) self.entry = None
def __join(self) -> None: def __join(self) -> None:
add_history(self.url, all_machines=False) new_entry = add_history(self.url)
GLib.idle_add(self.emit, "join_finished", self) self.entry = new_entry
GLib.idle_add(self._join_finished)
def join(self) -> None: def join(self) -> None:
threading.Thread(target=self.__join).start() threading.Thread(target=self.__join).start()
class Join: class JoinList:
""" """
This is a singleton. This is a singleton.
It is initialized with the first call of use() It is initialized with the first call of use()
""" """
_instance: "None | Join" = None _instance: "None | JoinList" = None
list_store: Gio.ListStore list_store: Gio.ListStore
# Make sure the VMS class is used as a singleton # Make sure the VMS class is used as a singleton
@@ -55,38 +58,39 @@ class Join:
raise RuntimeError("Call use() instead") raise RuntimeError("Call use() instead")
@classmethod @classmethod
def use(cls: Any) -> "Join": def use(cls: Any) -> "JoinList":
if cls._instance is None: if cls._instance is None:
cls._instance = cls.__new__(cls) cls._instance = cls.__new__(cls)
cls.list_store = Gio.ListStore.new(JoinValue) cls.list_store = Gio.ListStore.new(JoinValue)
return cls._instance return cls._instance
def push(self, url: ClanURI, on_join: Callable[[JoinValue], None]) -> None: def is_empty(self) -> bool:
return self.list_store.get_n_items() == 0
def push(
self, value: JoinValue, after_join: Callable[[JoinValue, JoinValue], None]
) -> None:
""" """
Add a join request. Add a join request.
This method can add multiple join requests if called subsequently for each request. This method can add multiple join requests if called subsequently for each request.
""" """
if url.get_id() in [item.url.get_id() for item in self.list_store]: if value.url.get_id() in [item.url.get_id() for item in self.list_store]:
log.info(f"Join request already exists: {url}") log.info(f"Join request already exists: {value.url}. Ignoring.")
return return
def after_join(item: JoinValue, _: Any) -> None: value.connect("join_finished", self._on_join_finished)
self.discard(item) value.connect("join_finished", after_join)
print("Refreshed list after join")
on_join(item)
self.list_store.append(JoinValue(url, after_join)) self.list_store.append(value)
def join(self, item: JoinValue) -> None: def _on_join_finished(self, _source: GObject.Object, value: JoinValue) -> None:
try: log.info(f"Join finished: {value.url}")
log.info(f"trying to join: {item.url}") self.discard(value)
item.join() VMs.use().push_history_entry(value.entry)
except ClanError as e:
show_error_dialog(e)
def discard(self, item: JoinValue) -> None: def discard(self, value: JoinValue) -> None:
(has, idx) = self.list_store.find(item) (has, idx) = self.list_store.find(value)
if has: if has:
self.list_store.remove(idx) self.list_store.remove(idx)

View File

@@ -18,6 +18,8 @@ from clan_cli.errors import ClanError
from clan_cli.history.add import HistoryEntry from clan_cli.history.add import HistoryEntry
from clan_cli.machines.machines import Machine from clan_cli.machines.machines import Machine
from clan_vm_manager import assets
from .executor import MPProcess, spawn from .executor import MPProcess, spawn
from .gkvstore import GKVStore from .gkvstore import GKVStore
@@ -35,6 +37,10 @@ class VM(GObject.Object):
"vm_status_changed": (GObject.SignalFlags.RUN_FIRST, None, [GObject.Object]) "vm_status_changed": (GObject.SignalFlags.RUN_FIRST, None, [GObject.Object])
} }
def vm_status_changed(self) -> bool:
self.emit("vm_status_changed", self)
return GLib.SOURCE_REMOVE
def __init__( def __init__(
self, self,
icon: Path, icon: Path,
@@ -75,12 +81,12 @@ class VM(GObject.Object):
self.switch_handler_id: int = self.switch.connect( self.switch_handler_id: int = self.switch.connect(
"notify::active", self.on_switch_toggle "notify::active", self.on_switch_toggle
) )
self.connect("vm_status_changed", self.vm_status_changed) self.connect("vm_status_changed", self.on_vm_status_changed)
# Make sure the VM is killed when the reference to this object is dropped # Make sure the VM is killed when the reference to this object is dropped
self._finalizer = weakref.finalize(self, self.kill_ref_drop) self._finalizer = weakref.finalize(self, self.kill_ref_drop)
def vm_status_changed(self, vm: "VM", _vm: "VM") -> None: def on_vm_status_changed(self, vm: "VM", _vm: "VM") -> None:
self.switch.set_state(self.is_running() and not self.is_building()) self.switch.set_state(self.is_running() and not self.is_building())
if self.switch.get_sensitive() is False and not self.is_building(): if self.switch.get_sensitive() is False and not self.is_building():
self.switch.set_sensitive(True) self.switch.set_sensitive(True)
@@ -145,7 +151,7 @@ class VM(GObject.Object):
tmpdir=log_dir, tmpdir=log_dir,
vm=self.data.flake.vm, vm=self.data.flake.vm,
) )
GLib.idle_add(self.emit, "vm_status_changed", self) GLib.idle_add(self.vm_status_changed)
# Start the logs watcher # Start the logs watcher
self._logs_id = GLib.timeout_add( self._logs_id = GLib.timeout_add(
@@ -170,7 +176,7 @@ class VM(GObject.Object):
# Check if the VM was built successfully # Check if the VM was built successfully
if self.build_process.proc.exitcode != 0: if self.build_process.proc.exitcode != 0:
log.error(f"Failed to build VM {self.get_id()}") log.error(f"Failed to build VM {self.get_id()}")
GLib.idle_add(self.emit, "vm_status_changed", self) GLib.idle_add(self.vm_status_changed)
return return
log.info(f"Successfully built VM {self.get_id()}") log.info(f"Successfully built VM {self.get_id()}")
@@ -182,7 +188,7 @@ class VM(GObject.Object):
vm=self.data.flake.vm, vm=self.data.flake.vm,
) )
log.debug(f"Started VM {self.get_id()}") log.debug(f"Started VM {self.get_id()}")
GLib.idle_add(self.emit, "vm_status_changed", self) GLib.idle_add(self.vm_status_changed)
# Start the logs watcher # Start the logs watcher
self._logs_id = GLib.timeout_add(50, self._get_logs_task, self.vm_process) self._logs_id = GLib.timeout_add(50, self._get_logs_task, self.vm_process)
@@ -193,7 +199,7 @@ class VM(GObject.Object):
# Wait for the VM to stop # Wait for the VM to stop
self.vm_process.proc.join() self.vm_process.proc.join()
log.debug(f"VM {self.get_id()} has stopped") log.debug(f"VM {self.get_id()} has stopped")
GLib.idle_add(self.emit, "vm_status_changed", self) GLib.idle_add(self.vm_status_changed)
def start(self) -> None: def start(self) -> None:
if self.is_running(): if self.is_running():
@@ -269,7 +275,7 @@ class VM(GObject.Object):
# Try 20 times to stop the VM # Try 20 times to stop the VM
time.sleep(self.KILL_TIMEOUT / 20) time.sleep(self.KILL_TIMEOUT / 20)
GLib.idle_add(self.emit, "vm_status_changed", self) GLib.idle_add(self.vm_status_changed)
log.debug(f"VM {self.get_id()} has stopped") log.debug(f"VM {self.get_id()} has stopped")
def shutdown(self) -> None: def shutdown(self) -> None:
@@ -338,6 +344,22 @@ class VMs:
def clan_store(self) -> GKVStore[str, VMStore]: def clan_store(self) -> GKVStore[str, VMStore]:
return self._clan_store return self._clan_store
def create_vm_task(self, vm: HistoryEntry) -> bool:
self.push_history_entry(vm)
return GLib.SOURCE_REMOVE
def push_history_entry(self, entry: HistoryEntry) -> None:
if entry.flake.icon is None:
icon = assets.loc / "placeholder.jpeg"
else:
icon = entry.flake.icon
vm = VM(
icon=Path(icon),
data=entry,
)
self.push(vm)
def push(self, vm: VM) -> None: def push(self, vm: VM) -> None:
url = vm.data.flake.flake_url url = vm.data.flake.flake_url

View File

@@ -8,7 +8,7 @@ from clan_cli import history, machines
from clan_cli.clan_uri import ClanURI from clan_cli.clan_uri import ClanURI
from clan_vm_manager.models.interfaces import ClanConfig from clan_vm_manager.models.interfaces import ClanConfig
from clan_vm_manager.models.use_join import Join, JoinValue from clan_vm_manager.models.use_join import JoinList, JoinValue
from clan_vm_manager.models.use_vms import VM, VMs, VMStore from clan_vm_manager.models.use_vms import VM, VMs, VMStore
gi.require_version("Adw", "1") gi.require_version("Adw", "1")
@@ -54,7 +54,7 @@ class ClanList(Gtk.Box):
# Add join list # Add join list
self.join_boxed_list = create_boxed_list( self.join_boxed_list = create_boxed_list(
model=Join.use().list_store, render_row=self.render_join_row model=JoinList.use().list_store, render_row=self.render_join_row
) )
self.join_boxed_list.add_css_class("join-list") self.join_boxed_list.add_css_class("join-list")
self.append(self.join_boxed_list) self.append(self.join_boxed_list)
@@ -113,8 +113,10 @@ class ClanList(Gtk.Box):
# ====== Display Avatar ====== # ====== Display Avatar ======
avatar = Adw.Avatar() avatar = Adw.Avatar()
machine_icon = flake.vm.machine_icon machine_icon = flake.vm.machine_icon
# If there is a machine icon, display it else
# display the clan icon
if machine_icon: if machine_icon:
avatar.set_custom_image(Gdk.Texture.new_from_filename(str(machine_icon))) avatar.set_custom_image(Gdk.Texture.new_from_filename(str(machine_icon)))
elif flake.icon: elif flake.icon:
@@ -128,10 +130,11 @@ class ClanList(Gtk.Box):
# ====== Display Name And Url ===== # ====== Display Name And Url =====
row.set_title(flake.flake_attr) row.set_title(flake.flake_attr)
row.set_title_lines(1) row.set_title_lines(1)
row.set_title_selectable(True) row.set_title_selectable(True)
# If there is a machine description, display it else
# display the clan name
if flake.vm.machine_description: if flake.vm.machine_description:
row.set_subtitle(flake.vm.machine_description) row.set_subtitle(flake.vm.machine_description)
else: else:
@@ -139,37 +142,35 @@ class ClanList(Gtk.Box):
row.set_subtitle_lines(1) row.set_subtitle_lines(1)
# ==== Display build progress bar ==== # ==== Display build progress bar ====
box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=5) build_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=5)
box.set_valign(Gtk.Align.CENTER) build_box.set_valign(Gtk.Align.CENTER)
box.append(vm.progress_bar) build_box.append(vm.progress_bar)
box.set_homogeneous(False) build_box.set_homogeneous(False)
row.add_suffix(box) # This allows children to have different sizes row.add_suffix(build_box) # This allows children to have different sizes
# ==== Action buttons ==== # ==== Action buttons ====
switch_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) button_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=5)
switch_box.set_valign(Gtk.Align.CENTER) button_box.set_valign(Gtk.Align.CENTER)
switch_box.append(vm.switch)
box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=5)
box.set_valign(Gtk.Align.CENTER)
## Drop down menu
open_action = Gio.SimpleAction.new("edit", GLib.VariantType.new("s")) open_action = Gio.SimpleAction.new("edit", GLib.VariantType.new("s"))
open_action.connect("activate", self.on_edit) open_action.connect("activate", self.on_edit)
app = Gio.Application.get_default() app = Gio.Application.get_default()
app.add_action(open_action) app.add_action(open_action)
menu_model = Gio.Menu() menu_model = Gio.Menu()
menu_model.append("Edit", f"app.edit::{vm.get_id()}") menu_model.append("Edit", f"app.edit::{vm.get_id()}")
pref_button = Gtk.MenuButton() pref_button = Gtk.MenuButton()
pref_button.set_icon_name("open-menu-symbolic") pref_button.set_icon_name("open-menu-symbolic")
pref_button.set_menu_model(menu_model) pref_button.set_menu_model(menu_model)
button_box.append(pref_button)
box.append(switch_box) ## VM switch button
box.append(pref_button) switch_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
switch_box.set_valign(Gtk.Align.CENTER)
switch_box.append(vm.switch)
button_box.append(switch_box)
# suffix.append(box) row.add_suffix(button_box)
row.add_suffix(box)
return row return row
@@ -221,24 +222,20 @@ class ClanList(Gtk.Box):
def on_join_request(self, widget: Any, url: str) -> None: def on_join_request(self, widget: Any, url: str) -> None:
log.debug("Join request: %s", url) log.debug("Join request: %s", url)
clan_uri = ClanURI.from_str(url) clan_uri = ClanURI.from_str(url)
Join.use().push(clan_uri, self.after_join) value = JoinValue(url=clan_uri)
JoinList.use().push(value, self.on_after_join)
def after_join(self, item: JoinValue) -> None: def on_after_join(self, source: JoinValue, item: JoinValue) -> None:
# If the join request list is empty disable the shadow artefact # If the join request list is empty disable the shadow artefact
if not Join.use().list_store.get_n_items(): if JoinList.use().is_empty():
self.join_boxed_list.add_css_class("no-shadow") self.join_boxed_list.add_css_class("no-shadow")
print("after join in list")
def on_trust_clicked(self, item: JoinValue, widget: Gtk.Widget) -> None: def on_trust_clicked(self, value: JoinValue, widget: Gtk.Widget) -> None:
widget.set_sensitive(False) widget.set_sensitive(False)
self.cancel_button.set_sensitive(False) self.cancel_button.set_sensitive(False)
value.join()
# TODO(@hsjobeki): Confirm and edit details def on_discard_clicked(self, value: JoinValue, widget: Gtk.Widget) -> None:
# Views.use().view.set_visible_child_name("details") JoinList.use().discard(value)
if JoinList.use().is_empty():
Join.use().join(item)
def on_discard_clicked(self, item: JoinValue, widget: Gtk.Widget) -> None:
Join.use().discard(item)
if not Join.use().list_store.get_n_items():
self.join_boxed_list.add_css_class("no-shadow") self.join_boxed_list.add_css_class("no-shadow")

View File

@@ -1,15 +1,13 @@
import logging import logging
import threading import threading
from pathlib import Path
from typing import Any from typing import Any
import gi import gi
from clan_cli.history.list import list_history from clan_cli.history.list import list_history
from clan_vm_manager import assets
from clan_vm_manager.models.interfaces import ClanConfig from clan_vm_manager.models.interfaces import ClanConfig
from clan_vm_manager.models.use_views import Views from clan_vm_manager.models.use_views import Views
from clan_vm_manager.models.use_vms import VM, VMs from clan_vm_manager.models.use_vms import VMs
from clan_vm_manager.views.details import Details from clan_vm_manager.views.details import Details
from clan_vm_manager.views.list import ClanList from clan_vm_manager.views.list import ClanList
@@ -61,24 +59,11 @@ class MainWindow(Adw.ApplicationWindow):
self.connect("destroy", self.on_destroy) self.connect("destroy", self.on_destroy)
def push_vm(self, vm: VM) -> bool:
VMs.use().push(vm)
return GLib.SOURCE_REMOVE
def _populate_vms(self) -> None: def _populate_vms(self) -> None:
# Execute `clan flakes add <path>` to democlan for this to work # Execute `clan flakes add <path>` to democlan for this to work
# TODO: Make list_history a generator function # TODO: Make list_history a generator function
for entry in list_history(): for entry in list_history():
if entry.flake.icon is None: GLib.idle_add(VMs.use().create_vm_task, entry)
icon = assets.loc / "placeholder.jpeg"
else:
icon = entry.flake.icon
vm = VM(
icon=Path(icon),
data=entry,
)
GLib.idle_add(self.push_vm, vm)
def on_destroy(self, *_args: Any) -> None: def on_destroy(self, *_args: Any) -> None:
self.tray_icon.destroy() self.tray_icon.destroy()