Merge pull request 'api: improve message serialisation' (#1440) from hsjobeki-feat/api-improvements into main

This commit is contained in:
clan-bot
2024-05-26 12:20:11 +00:00
11 changed files with 114 additions and 32 deletions

View File

@@ -1,12 +1,14 @@
from collections.abc import Callable from collections.abc import Callable
from typing import Any from typing import Any, TypeVar
T = TypeVar("T")
class _MethodRegistry: class _MethodRegistry:
def __init__(self) -> None: def __init__(self) -> None:
self._registry: dict[str, Callable] = {} self._registry: dict[str, Callable] = {}
def register(self, fn: Callable) -> Callable: def register(self, fn: Callable[..., T]) -> Callable[..., T]:
self._registry[fn.__name__] = fn self._registry[fn.__name__] = fn
return fn return fn

View File

@@ -44,6 +44,7 @@ def type_to_dict(t: Any, scope: str = "") -> dict:
elif issubclass(origin, dict): elif issubclass(origin, dict):
return { return {
"type": "object", "type": "object",
"additionalProperties": type_to_dict(t.__args__[1], scope),
} }
raise BaseException(f"Error api type not yet supported {t!s}") raise BaseException(f"Error api type not yet supported {t!s}")

View File

@@ -1,4 +1,5 @@
import argparse import argparse
import dataclasses
import json import json
import logging import logging
from pathlib import Path from pathlib import Path
@@ -11,18 +12,24 @@ from ..nix import nix_config, nix_eval
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@dataclasses.dataclass
class MachineInfo:
machine_name: str
machine_description: str | None
machine_icon: str | None
@API.register @API.register
def list_machines( def list_machines(debug: bool, flake_url: Path | str) -> dict[str, MachineInfo]:
debug: bool,
flake_url: Path | str,
) -> list[str]:
config = nix_config() config = nix_config()
system = config["system"] system = config["system"]
cmd = nix_eval( cmd = nix_eval(
[ [
f"{flake_url}#clanInternals.machines.{system}", f"{flake_url}#clanInternals.machines.{system}",
"--apply", "--apply",
"builtins.attrNames", """builtins.mapAttrs (name: attrs: {
inherit (attrs.config.clanCore) machineDescription machineIcon machineName;
})""",
"--json", "--json",
] ]
) )
@@ -33,12 +40,27 @@ def list_machines(
proc = run(cmd) proc = run(cmd)
res = proc.stdout.strip() res = proc.stdout.strip()
return json.loads(res) machines_dict = json.loads(res)
return {
k: MachineInfo(
machine_name=v.get("machineName"),
machine_description=v.get("machineDescription", None),
machine_icon=v.get("machineIcon", None),
)
for k, v in machines_dict.items()
}
def list_command(args: argparse.Namespace) -> None: def list_command(args: argparse.Namespace) -> None:
for machine in list_machines(args.debug, Path(args.flake)): flake_path = Path(args.flake).resolve()
print(machine) print("Listing all machines:\n")
print("Source: ", flake_path)
print("-" * 40)
for name, machine in list_machines(args.debug, flake_path).items():
description = machine.machine_description or "[no description]"
print(f"{name}\n: {description}\n")
print("-" * 40)
def register_list_parser(parser: argparse.ArgumentParser) -> None: def register_list_parser(parser: argparse.ArgumentParser) -> None:

View File

@@ -26,6 +26,14 @@ def test_create_flake(
cli.run(["machines", "create", "machine1"]) cli.run(["machines", "create", "machine1"])
capsys.readouterr() # flush cache capsys.readouterr() # flush cache
# create a hardware-configuration.nix that doesn't throw an eval error
for patch_machine in ["jon", "sara"]:
with open(
flake_dir / "machines" / f"{patch_machine}/hardware-configuration.nix", "w"
) as hw_config_nix:
hw_config_nix.write("{}")
cli.run(["machines", "list"]) cli.run(["machines", "list"])
assert "machine1" in capsys.readouterr().out assert "machine1" in capsys.readouterr().out
flake_show = subprocess.run( flake_show = subprocess.run(

View File

@@ -16,7 +16,10 @@ def test_machine_subcommands(
cli.run(["--flake", str(test_flake_with_core.path), "machines", "list"]) cli.run(["--flake", str(test_flake_with_core.path), "machines", "list"])
out = capsys.readouterr() out = capsys.readouterr()
assert "machine1\nvm1\nvm2\n" == out.out
assert "machine1" in out.out
assert "vm1" in out.out
assert "vm2" in out.out
cli.run( cli.run(
["--flake", str(test_flake_with_core.path), "machines", "delete", "machine1"] ["--flake", str(test_flake_with_core.path), "machines", "delete", "machine1"]
@@ -25,4 +28,7 @@ def test_machine_subcommands(
capsys.readouterr() capsys.readouterr()
cli.run(["--flake", str(test_flake_with_core.path), "machines", "list"]) cli.run(["--flake", str(test_flake_with_core.path), "machines", "list"])
out = capsys.readouterr() out = capsys.readouterr()
assert "vm1\nvm2\n" == out.out
assert "machine1" not in out.out
assert "vm1" in out.out
assert "vm2" in out.out

View File

@@ -106,7 +106,7 @@ class MainApplication(Adw.Application):
def on_activate(self, source: "MainApplication") -> None: def on_activate(self, source: "MainApplication") -> None:
if not self.window: if not self.window:
self.init_style() self.init_style()
self.window = MainWindow(config=ClanConfig(initial_view="webview")) self.window = MainWindow(config=ClanConfig(initial_view="list"))
self.window.set_application(self) self.window.set_application(self)
self.window.show() self.window.show()

View File

@@ -1,3 +1,4 @@
import dataclasses
import json import json
import logging import logging
import sys import sys
@@ -22,6 +23,23 @@ site_index: Path = (
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
def dataclass_to_dict(obj: Any) -> Any:
"""
Utility function to convert dataclasses to dictionaries
It converts all nested dataclasses, lists, tuples, and dictionaries to dictionaries
It does NOT convert member functions.
"""
if dataclasses.is_dataclass(obj):
return {k: dataclass_to_dict(v) for k, v in dataclasses.asdict(obj).items()}
elif isinstance(obj, list | tuple):
return [dataclass_to_dict(item) for item in obj]
elif isinstance(obj, dict):
return {k: dataclass_to_dict(v) for k, v in obj.items()}
else:
return obj
class WebView: class WebView:
def __init__(self, methods: dict[str, Callable]) -> None: def __init__(self, methods: dict[str, Callable]) -> None:
self.method_registry: dict[str, Callable] = methods self.method_registry: dict[str, Callable] = methods
@@ -82,7 +100,7 @@ class WebView:
with self.mutex_lock: with self.mutex_lock:
log.debug("Executing... ", method_name) log.debug("Executing... ", method_name)
result = handler_fn(data) result = handler_fn(data)
serialized = json.dumps(result) serialized = json.dumps(dataclass_to_dict(result))
# Use idle_add to queue the response call to js on the main GTK thread # Use idle_add to queue the response call to js on the main GTK thread
GLib.idle_add(self.return_data_to_js, method_name, serialized) GLib.idle_add(self.return_data_to_js, method_name, serialized)

View File

@@ -62,8 +62,7 @@ class MainWindow(Adw.ApplicationWindow):
stack_view.add_named(Logs(), "logs") stack_view.add_named(Logs(), "logs")
webview = WebView(methods=API._registry) webview = WebView(methods=API._registry)
stack_view.add_named(webview.get_webview(), "webview")
stack_view.add_named(webview.get_webview(), "list")
stack_view.set_visible_child_name(config.initial_view) stack_view.set_visible_child_name(config.initial_view)

View File

@@ -1,8 +1,16 @@
import { createSignal, createContext, useContext, JSXElement } from "solid-js"; import {
import { pyApi } from "./message"; createSignal,
createContext,
useContext,
JSXElement,
createEffect,
} from "solid-js";
import { OperationResponse, pyApi } from "./message";
export const makeCountContext = () => { export const makeCountContext = () => {
const [machines, setMachines] = createSignal<string[]>([]); const [machines, setMachines] = createSignal<
OperationResponse<"list_machines">
>({});
const [loading, setLoading] = createSignal(false); const [loading, setLoading] = createSignal(false);
pyApi.list_machines.receive((machines) => { pyApi.list_machines.receive((machines) => {
@@ -10,6 +18,10 @@ export const makeCountContext = () => {
setMachines(machines); setMachines(machines);
}); });
createEffect(() => {
console.log("The count is now", machines());
});
return [ return [
{ loading, machines }, { loading, machines },
{ {
@@ -25,7 +37,10 @@ export const makeCountContext = () => {
type CountContextType = ReturnType<typeof makeCountContext>; type CountContextType = ReturnType<typeof makeCountContext>;
export const CountContext = createContext<CountContextType>([ export const CountContext = createContext<CountContextType>([
{ loading: () => false, machines: () => [] }, {
loading: () => false,
machines: () => ({}),
},
{ {
getMachines: () => {}, getMachines: () => {},
}, },

View File

@@ -1,11 +1,11 @@
import { FromSchema } from "json-schema-to-ts"; import { FromSchema } from "json-schema-to-ts";
import { schema } from "@/api"; import { schema } from "@/api";
type API = FromSchema<typeof schema>; export type API = FromSchema<typeof schema>;
type OperationNames = keyof API; export type OperationNames = keyof API;
type OperationArgs<T extends OperationNames> = API[T]["argument"]; export type OperationArgs<T extends OperationNames> = API[T]["argument"];
type OperationResponse<T extends OperationNames> = API[T]["return"]; export type OperationResponse<T extends OperationNames> = API[T]["return"];
declare global { declare global {
interface Window { interface Window {
@@ -40,7 +40,7 @@ function createFunctions<K extends OperationNames>(
}); });
}, },
receive: (fn: (response: OperationResponse<K>) => void) => { receive: (fn: (response: OperationResponse<K>) => void) => {
window.clan.list_machines = deserialize(fn); window.clan[operationName] = deserialize(fn);
}, },
}; };
} }
@@ -59,6 +59,7 @@ const deserialize =
<T>(fn: (response: T) => void) => <T>(fn: (response: T) => void) =>
(str: string) => { (str: string) => {
try { try {
console.debug("Received data: ", str);
fn(JSON.parse(str) as T); fn(JSON.parse(str) as T);
} catch (e) { } catch (e) {
alert(`Error parsing JSON: ${e}`); alert(`Error parsing JSON: ${e}`);

View File

@@ -1,24 +1,34 @@
import { For, Match, Switch, type Component } from "solid-js"; import { For, Match, Switch, createEffect, type Component } from "solid-js";
import { useCountContext } from "./Config"; import { useCountContext } from "./Config";
export const Nested: Component = () => { export const Nested: Component = () => {
const [{ machines, loading }, { getMachines }] = useCountContext(); const [{ machines, loading }, { getMachines }] = useCountContext();
const list = () => Object.values(machines());
createEffect(() => {
console.log("1", list());
});
createEffect(() => {
console.log("2", machines());
});
return ( return (
<div> <div>
<button onClick={() => getMachines()} class="btn btn-primary"> <button onClick={() => getMachines()} class="btn btn-primary">
Get machines Get machines
</button> </button>
<hr /> <div></div>
<Switch> <Switch>
<Match when={loading()}>Loading...</Match> <Match when={loading()}>Loading...</Match>
<Match when={!loading() && machines().length === 0}> <Match when={!loading() && Object.entries(machines()).length === 0}>
No machines found No machines found
</Match> </Match>
<Match when={!loading() && machines().length}> <Match when={!loading()}>
<For each={machines()}> <For each={list()}>
{(machine, i) => ( {(entry, i) => (
<li> <li>
{i() + 1}: {machine} {i() + 1}: {entry.machine_name}{" "}
{entry.machine_description || "No description"}
</li> </li>
)} )}
</For> </For>