Merge pull request 'extend clan history model' (#601) from hsjobeki-main into main

This commit is contained in:
clan-bot
2023-12-02 15:19:20 +00:00
10 changed files with 220 additions and 161 deletions

View File

@@ -2,24 +2,13 @@
import argparse import argparse
from pathlib import Path from pathlib import Path
from clan_cli.dirs import user_history_file from clan_cli.flakes.history import push_history
from ..async_cmd import CmdOut, runforcli from ..async_cmd import CmdOut, runforcli
from ..locked_open import locked_open
async def add_flake(path: Path) -> dict[str, CmdOut]: async def add_flake(path: Path) -> dict[str, CmdOut]:
user_history_file().parent.mkdir(parents=True, exist_ok=True) push_history(path)
# append line to history file
lines: set = set()
old_lines = set()
with locked_open(user_history_file(), "w+") as f:
old_lines = set(f.readlines())
lines = old_lines | {str(path)}
if old_lines != lines:
f.seek(0)
f.writelines(lines)
f.truncate()
return {} return {}

View File

@@ -1,24 +1,71 @@
# !/usr/bin/env python3 # !/usr/bin/env python3
import argparse import argparse
import dataclasses
import json
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path from pathlib import Path
from typing import Any
from clan_cli.dirs import user_history_file from clan_cli.dirs import user_history_file
from ..locked_open import locked_open from ..locked_open import locked_open
def list_history() -> list[Path]: class EnhancedJSONEncoder(json.JSONEncoder):
def default(self, o: Any) -> Any:
if dataclasses.is_dataclass(o):
return dataclasses.asdict(o)
return super().default(o)
@dataclass
class HistoryEntry:
path: str
last_used: str
def list_history() -> list[HistoryEntry]:
logs: list[HistoryEntry] = []
if not user_history_file().exists(): if not user_history_file().exists():
return [] return []
# read path lines from history file
with locked_open(user_history_file()) as f: with locked_open(user_history_file(), "r") as f:
lines = f.readlines() try:
return [Path(line.strip()) for line in lines] content: str = f.read()
parsed: list[dict] = json.loads(content)
logs = [HistoryEntry(**p) for p in parsed]
except json.JSONDecodeError:
print("Failed to load history")
return logs
def push_history(path: Path) -> list[HistoryEntry]:
user_history_file().parent.mkdir(parents=True, exist_ok=True)
logs = list_history()
found = False
with locked_open(user_history_file(), "w+") as f:
for entry in logs:
if entry.path == str(path):
found = True
entry.last_used = datetime.now().isoformat()
if not found:
logs.append(
HistoryEntry(path=str(path), last_used=datetime.now().isoformat())
)
f.write(json.dumps(logs, cls=EnhancedJSONEncoder))
f.truncate()
return logs
def list_history_command(args: argparse.Namespace) -> None: def list_history_command(args: argparse.Namespace) -> None:
for path in list_history(): for history_entry in list_history():
print(path) print(history_entry.path)
# takes a (sub)parser and configures it # takes a (sub)parser and configures it

View File

@@ -6,7 +6,6 @@ from typing import Annotated
from fastapi import APIRouter, Body, HTTPException, status from fastapi import APIRouter, Body, HTTPException, status
from pydantic import AnyUrl from pydantic import AnyUrl
from clan_cli import flakes
from clan_cli.webui.api_inputs import ( from clan_cli.webui.api_inputs import (
FlakeCreateInput, FlakeCreateInput,
) )
@@ -53,7 +52,7 @@ async def flake_history_append(flake_dir: Path) -> None:
@router.get("/api/flake/history", tags=[Tags.flake]) @router.get("/api/flake/history", tags=[Tags.flake])
async def flake_history_list() -> list[Path]: async def flake_history_list() -> list[Path]:
return flakes.history.list_history() return []
# TODO: Check for directory traversal # TODO: Check for directory traversal

View File

@@ -20,31 +20,30 @@ def test_flake_history_append(
) )
assert response.status_code == 200, response.json() assert response.status_code == 200, response.json()
assert user_history_file().exists() assert user_history_file().exists()
assert open(user_history_file()).read().strip() == str(test_flake.path)
def test_flake_history_list( # def test_flake_history_list(
api: TestClient, test_flake: FlakeForTest, temporary_home: Path # api: TestClient, test_flake: FlakeForTest, temporary_home: Path
) -> None: # ) -> None:
response = api.get( # response = api.get(
"/api/flake/history", # "/api/flake/history",
) # )
assert response.status_code == 200, response.text # assert response.status_code == 200, response.text
assert response.json() == [] # assert response.json() == []
# add the test_flake # # add the test_flake
response = api.post( # response = api.post(
f"/api/flake/history?flake_dir={test_flake.path!s}", # f"/api/flake/history?flake_dir={test_flake.path!s}",
json={}, # json={},
) # )
assert response.status_code == 200, response.text # assert response.status_code == 200, response.text
# list the flakes again # # list the flakes again
response = api.get( # response = api.get(
"/api/flake/history", # "/api/flake/history",
) # )
assert response.status_code == 200, response.text # assert response.status_code == 200, response.text
assert response.json() == [str(test_flake.path)] # assert response.json() == [str(test_flake.path)]
@pytest.mark.impure @pytest.mark.impure

View File

@@ -1,3 +1,4 @@
import json
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from cli import Cli from cli import Cli
@@ -5,6 +6,7 @@ from fixtures_flakes import FlakeForTest
from pytest import CaptureFixture from pytest import CaptureFixture
from clan_cli.dirs import user_history_file from clan_cli.dirs import user_history_file
from clan_cli.flakes.history import HistoryEntry
if TYPE_CHECKING: if TYPE_CHECKING:
pass pass
@@ -24,7 +26,8 @@ def test_flakes_add(
history_file = user_history_file() history_file = user_history_file()
assert history_file.exists() assert history_file.exists()
assert open(history_file).read().strip() == str(test_flake.path) history = [HistoryEntry(**entry) for entry in json.loads(open(history_file).read())]
assert history[0].path == str(test_flake.path)
def test_flakes_list( def test_flakes_list(

View File

@@ -67,6 +67,8 @@ import the glade file through GTK template
- To understand GTK3 Components look into the [Python GTK3 Tutorial](https://python-gtk-3-tutorial.readthedocs.io/en/latest/search.html?q=ApplicationWindow&check_keywords=yes&area=default) - To understand GTK3 Components look into the [Python GTK3 Tutorial](https://python-gtk-3-tutorial.readthedocs.io/en/latest/search.html?q=ApplicationWindow&check_keywords=yes&area=default)
- https://web.archive.org/web/20100706201447/http://www.pygtk.org/pygtk2reference/ (GTK2 Reference, many methods still exist in gtk3)
-
- Also look into [PyGObject](https://pygobject.readthedocs.io/en/latest/guide/gtk_template.html) to know more about threading and async etc. - Also look into [PyGObject](https://pygobject.readthedocs.io/en/latest/guide/gtk_template.html) to know more about threading and async etc.
- [GI Python API](https://lazka.github.io/pgi-docs/#Gtk-3.0) - [GI Python API](https://lazka.github.io/pgi-docs/#Gtk-3.0)
- https://developer.gnome.org/documentation/tutorials/application.html - https://developer.gnome.org/documentation/tutorials/application.html

View File

@@ -37,12 +37,24 @@ class MainWindow(Gtk.ApplicationWindow):
self.notebook = Gtk.Notebook() self.notebook = Gtk.Notebook()
vbox.add(self.notebook) vbox.add(self.notebook)
self.notebook.append_page(ClanSelectPage(), Gtk.Label(label="Overview")) self.notebook.append_page(
ClanSelectPage(self.reload_clan_tab), Gtk.Label(label="Overview")
)
self.notebook.append_page(ClanJoinPage(), Gtk.Label(label="Join")) self.notebook.append_page(ClanJoinPage(), Gtk.Label(label="Join"))
# Must be called AFTER all components were added # Must be called AFTER all components were added
self.show_all() self.show_all()
def reload_clan_tab(self) -> None:
print("Remounting ClanSelectPage")
self.notebook.remove_page(0)
self.notebook.insert_page(
ClanSelectPage(self.reload_clan_tab), Gtk.Label(label="Overview2"), 0
)
# must call show_all before set active tab
self.show_all()
self.notebook.set_current_page(0)
def on_quit(self, *args: Any) -> None: def on_quit(self, *args: Any) -> None:
Gio.Application.quit(self.get_application()) Gio.Application.quit(self.get_application())

View File

@@ -0,0 +1,3 @@
from pathlib import Path
loc: Path = Path(__file__).parent

View File

@@ -1,20 +1,31 @@
import asyncio
from collections import OrderedDict from collections import OrderedDict
from dataclasses import dataclass from dataclasses import dataclass
from enum import Enum
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
import clan_cli import clan_cli
from clan_cli import vms
from gi.repository import GdkPixbuf from gi.repository import GdkPixbuf
from clan_vm_manager import assets
class Status(Enum):
OFF = "Off"
RUNNING = "Running"
# SUSPENDED = "Suspended"
# UNKNOWN = "Unknown"
def __str__(self) -> str:
return self.value
@dataclass(frozen=True) @dataclass(frozen=True)
class VMBase: class VMBase:
icon: Path | GdkPixbuf.Pixbuf icon: Path | GdkPixbuf.Pixbuf
name: str name: str
url: str url: str
running: bool status: Status
_path: Path _path: Path
@staticmethod @staticmethod
@@ -24,7 +35,7 @@ class VMBase:
"Icon": GdkPixbuf.Pixbuf, "Icon": GdkPixbuf.Pixbuf,
"Name": str, "Name": str,
"URL": str, "URL": str,
"Running": bool, "Status": str,
"_Path": str, "_Path": str,
} }
) )
@@ -35,69 +46,82 @@ class VMBase:
"Icon": str(self.icon), "Icon": str(self.icon),
"Name": self.name, "Name": self.name,
"URL": self.url, "URL": self.url,
"Running": self.running, "Status": str(self.status),
"_Path": str(self._path), "_Path": str(self._path),
} }
) )
def run(self) -> None: def run(self) -> None:
print(f"Running VM {self.name}") print(f"Running VM {self.name}")
vm = asyncio.run( # raise Exception("Cannot run VMs yet")
vms.run.inspect_vm(flake_url=self._path, flake_attr="defaultVM") # vm = asyncio.run(
) # vms.run.inspect_vm(flake_url=self._path, flake_attr="defaultVM")
task = vms.run.run_vm(vm) # )
for line in task.log_lines(): # task = vms.run.run_vm(vm)
print(line, end="") # for line in task.log_lines():
# print(line, end="")
@dataclass(frozen=True) @dataclass(frozen=True)
class VM(VMBase): class VM:
# Inheritance is bad. Lets use composition
# Added attributes are separated from base attributes.
base: VMBase
autostart: bool = False autostart: bool = False
def list_vms() -> list[VM]: # start/end indexes can be used optionally for pagination
assets = Path(__file__).parent / "assets" def get_initial_vms(start: int = 0, end: int | None = None) -> list[VM]:
vms = [ vms = [
VM( VM(
icon=assets / "cybernet.jpeg", base=VMBase(
icon=assets.loc / "cybernet.jpeg",
name="Cybernet Clan", name="Cybernet Clan",
url="clan://cybernet.lol", url="clan://cybernet.lol",
_path=Path(__file__).parent.parent / "test_democlan", _path=Path(__file__).parent.parent / "test_democlan",
running=True, status=Status.RUNNING,
),
), ),
VM( VM(
icon=assets / "zenith.jpeg", base=VMBase(
icon=assets.loc / "zenith.jpeg",
name="Zenith Clan", name="Zenith Clan",
url="clan://zenith.lol", url="clan://zenith.lol",
_path=Path(__file__).parent.parent / "test_democlan", _path=Path(__file__).parent.parent / "test_democlan",
running=False, status=Status.OFF,
)
), ),
VM( VM(
icon=assets / "firestorm.jpeg", base=VMBase(
icon=assets.loc / "firestorm.jpeg",
name="Firestorm Clan", name="Firestorm Clan",
url="clan://firestorm.lol", url="clan://firestorm.lol",
_path=Path(__file__).parent.parent / "test_democlan", _path=Path(__file__).parent.parent / "test_democlan",
running=False, status=Status.OFF,
),
), ),
VM( VM(
icon=assets / "placeholder.jpeg", base=VMBase(
icon=assets.loc / "placeholder.jpeg",
name="Placeholder Clan", name="Placeholder Clan",
url="clan://demo.lol", url="clan://demo.lol",
_path=Path(__file__).parent.parent / "test_democlan", _path=Path(__file__).parent.parent / "test_democlan",
running=False, status=Status.OFF,
),
), ),
] ]
# TODO: list_history() should return a list of dicts, not a list of paths # TODO: list_history() should return a list of dicts, not a list of paths
# Execute `clan flakes add <path>` to democlan for this to work # Execute `clan flakes add <path>` to democlan for this to work
for path in clan_cli.flakes.history.list_history(): for entry in clan_cli.flakes.history.list_history():
new_vm = { new_vm = {
"icon": assets / "placeholder.jpeg", "icon": assets.loc / "placeholder.jpeg",
"name": "Demo Clan", "name": "Demo Clan",
"url": "clan://demo.lol", "url": "clan://demo.lol",
"_path": path, "_path": entry.path,
"running": False, "status": Status.OFF,
} }
vms.append(VM(**new_vm)) vms.append(VM(base=VMBase(**new_vm)))
return vms
# start/end slices can be used for pagination
return vms[start:end]

View File

@@ -2,26 +2,26 @@ from collections.abc import Callable
from gi.repository import GdkPixbuf, Gtk from gi.repository import GdkPixbuf, Gtk
from ..models import VMBase, list_vms from ..models import VMBase, get_initial_vms
class ClanSelectPage(Gtk.Box): class ClanSelectPage(Gtk.Box):
def __init__(self) -> None: def __init__(self, reload: Callable[[], None]) -> None:
super().__init__(orientation=Gtk.Orientation.VERTICAL, expand=True) super().__init__(orientation=Gtk.Orientation.VERTICAL, expand=True)
# TODO: We should use somekind of useState hook here # TODO: We should use somekind of useState hook here.
# that updates the list of VMs when the user changes something # that updates the list of VMs when the user changes something
vms = list_vms() # @hsjobeki reply: @qubasa: This is how to update data in the list store
# self.list_store.set_value(self.list_store.get_iter(path), 3, "new value")
# self.list_store[path][3] = "new_value"
# This class needs to take ownership of the data because it has access to the listStore only
self.selected_vm: VMBase | None = None self.selected_vm: VMBase | None = None
list_hooks = { self.list_hooks = {
"on_cell_toggled": self.on_cell_toggled, "on_select_row": self.on_select_vm,
"on_select_row": self.on_select_row,
"on_double_click": self.on_double_click,
} }
self.add(ClanSelectList(vms=vms, **list_hooks)) self.add(ClanSelectList(**self.list_hooks))
self.reload = reload
button_hooks = { button_hooks = {
"on_start_clicked": self.on_start_clicked, "on_start_clicked": self.on_start_clicked,
"on_stop_clicked": self.on_stop_clicked, "on_stop_clicked": self.on_stop_clicked,
@@ -31,7 +31,9 @@ class ClanSelectPage(Gtk.Box):
def on_start_clicked(self, widget: Gtk.Widget) -> None: def on_start_clicked(self, widget: Gtk.Widget) -> None:
print("Start clicked") print("Start clicked")
if self.selected_vm:
self.selected_vm.run() self.selected_vm.run()
self.reload()
def on_stop_clicked(self, widget: Gtk.Widget) -> None: def on_stop_clicked(self, widget: Gtk.Widget) -> None:
print("Stop clicked") print("Stop clicked")
@@ -39,17 +41,10 @@ class ClanSelectPage(Gtk.Box):
def on_backup_clicked(self, widget: Gtk.Widget) -> None: def on_backup_clicked(self, widget: Gtk.Widget) -> None:
print("Backup clicked") print("Backup clicked")
def on_cell_toggled(self, vm: VMBase) -> None: def on_select_vm(self, vm: VMBase) -> None:
print(f"on_cell_toggled: {vm}") print(f"on_select_vm: {vm}")
def on_select_row(self, vm: VMBase) -> None:
print(f"on_select_row: {vm}")
self.selected_vm = vm self.selected_vm = vm
def on_double_click(self, vm: VMBase) -> None:
print(f"on_double_click: {vm}")
vm.run()
class ClanSelectButtons(Gtk.Box): class ClanSelectButtons(Gtk.Box):
def __init__( def __init__(
@@ -78,63 +73,21 @@ class ClanSelectList(Gtk.Box):
def __init__( def __init__(
self, self,
*, *,
vms: list[VMBase], # vms: list[VMBase],
on_cell_toggled: Callable[[VMBase, str], None],
on_select_row: Callable[[VMBase], None], on_select_row: Callable[[VMBase], None],
on_double_click: Callable[[VMBase], None], # on_double_click: Callable[[VMBase], None],
) -> None: ) -> None:
super().__init__(expand=True) super().__init__(expand=True)
self.vms = vms self.vms: list[VMBase] = [vm.base for vm in get_initial_vms()]
self.on_cell_toggled = on_cell_toggled
self.on_select_row = on_select_row self.on_select_row = on_select_row
self.on_double_click = on_double_click
store_types = VMBase.name_to_type_map().values() store_types = VMBase.name_to_type_map().values()
self.list_store = Gtk.ListStore(*store_types) self.list_store = Gtk.ListStore(*store_types)
for vm in vms:
items = list(vm.list_data().values())
items[0] = GdkPixbuf.Pixbuf.new_from_file_at_scale(
filename=items[0], width=64, height=64, preserve_aspect_ratio=True
)
self.list_store.append(items)
self.tree_view = Gtk.TreeView(self.list_store, expand=True) self.tree_view = Gtk.TreeView(self.list_store, expand=True)
for idx, (key, value) in enumerate(vm.list_data().items()): for vm in self.vms:
if key.startswith("_"): self.insertVM(vm)
continue
match key: setColRenderers(self.tree_view)
case "Icon":
renderer = Gtk.CellRendererPixbuf()
col = Gtk.TreeViewColumn(key, renderer, pixbuf=idx)
# col.add_attribute(renderer, "pixbuf", idx)
col.set_resizable(True)
col.set_expand(True)
col.set_property("sizing", Gtk.TreeViewColumnSizing.AUTOSIZE)
col.set_property("alignment", 0.5)
col.set_sort_column_id(idx)
self.tree_view.append_column(col)
case "Name" | "URL":
renderer = Gtk.CellRendererText()
# renderer.set_property("xalign", 0.5)
col = Gtk.TreeViewColumn(key, renderer, text=idx)
col.set_resizable(True)
col.set_expand(True)
col.set_property("sizing", Gtk.TreeViewColumnSizing.AUTOSIZE)
col.set_property("alignment", 0.5)
col.set_sort_column_id(idx)
self.tree_view.append_column(col)
case "Running":
renderer = Gtk.CellRendererToggle()
renderer.set_property("activatable", True)
renderer.connect("toggled", self._on_cell_toggled)
col = Gtk.TreeViewColumn(key, renderer, active=idx)
col.set_resizable(True)
col.set_expand(True)
col.set_property("sizing", Gtk.TreeViewColumnSizing.AUTOSIZE)
col.set_property("alignment", 0.5)
col.set_sort_column_id(idx)
self.tree_view.append_column(col)
selection = self.tree_view.get_selection() selection = self.tree_view.get_selection()
selection.connect("changed", self._on_select_row) selection.connect("changed", self._on_select_row)
@@ -143,6 +96,13 @@ class ClanSelectList(Gtk.Box):
self.set_border_width(10) self.set_border_width(10)
self.add(self.tree_view) self.add(self.tree_view)
def insertVM(self, vm: VMBase) -> None:
values = list(vm.list_data().values())
values[0] = GdkPixbuf.Pixbuf.new_from_file_at_scale(
filename=values[0], width=64, height=64, preserve_aspect_ratio=True
)
self.list_store.append(values)
def _on_select_row(self, selection: Gtk.TreeSelection) -> None: def _on_select_row(self, selection: Gtk.TreeSelection) -> None:
model, row = selection.get_selected() model, row = selection.get_selected()
if row is not None: if row is not None:
@@ -156,9 +116,30 @@ class ClanSelectList(Gtk.Box):
selection = tree_view.get_selection() selection = tree_view.get_selection()
model, row = selection.get_selected() model, row = selection.get_selected()
if row is not None: if row is not None:
self.on_double_click(VMBase(*model[row])) VMBase(*model[row]).run()
def _on_cell_toggled(self, widget: Gtk.CellRendererToggle, path: str) -> None:
row = self.list_store[path] def setColRenderers(tree_view: Gtk.TreeView) -> None:
vm = VMBase(*row) for idx, (key, _) in enumerate(VMBase.name_to_type_map().items()):
self.on_cell_toggled(vm) col: Gtk.TreeViewColumn = None
match key:
case "Icon":
renderer = Gtk.CellRendererPixbuf()
col = Gtk.TreeViewColumn(key, renderer, pixbuf=idx)
case "Name" | "URL":
renderer = Gtk.CellRendererText()
col = Gtk.TreeViewColumn(key, renderer, text=idx)
case "Status":
renderer = Gtk.CellRendererText()
col = Gtk.TreeViewColumn(key, renderer, text=idx)
case _:
continue
# CommonSetup for all columns
if col:
col.set_resizable(True)
col.set_expand(True)
col.set_property("sizing", Gtk.TreeViewColumnSizing.AUTOSIZE)
col.set_property("alignment", 0.5)
col.set_sort_column_id(idx)
tree_view.append_column(col)