From ed171f026433a1db33998a0f846ddcd718bf2008 Mon Sep 17 00:00:00 2001 From: Johannes Kirschbauer Date: Sun, 26 May 2024 15:57:10 +0200 Subject: [PATCH 1/2] Api: init response envelop --- pkgs/clan-cli/clan_cli/api/__init__.py | 20 +++++++++++++++++++- pkgs/clan-cli/clan_cli/machines/create.py | 16 +++++++++++++++- 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/pkgs/clan-cli/clan_cli/api/__init__.py b/pkgs/clan-cli/clan_cli/api/__init__.py index f7bb4d1e..501554a7 100644 --- a/pkgs/clan-cli/clan_cli/api/__init__.py +++ b/pkgs/clan-cli/clan_cli/api/__init__.py @@ -1,9 +1,27 @@ from collections.abc import Callable -from typing import Any, TypeVar +from dataclasses import dataclass +from typing import Any, Generic, Literal, TypeVar T = TypeVar("T") +ResponseDataType = TypeVar("ResponseDataType") + + +@dataclass +class ApiError: + message: str + description: str | None + location: list[str] | None + + +@dataclass +class ApiResponse(Generic[ResponseDataType]): + status: Literal["success", "error"] + errors: list[ApiError] | None + data: ResponseDataType | None + + class _MethodRegistry: def __init__(self) -> None: self._registry: dict[str, Callable] = {} diff --git a/pkgs/clan-cli/clan_cli/machines/create.py b/pkgs/clan-cli/clan_cli/machines/create.py index 711735eb..a4420600 100644 --- a/pkgs/clan-cli/clan_cli/machines/create.py +++ b/pkgs/clan-cli/clan_cli/machines/create.py @@ -1,13 +1,27 @@ import argparse import logging +from dataclasses import dataclass +from pathlib import Path +from clan_cli.api import API from clan_cli.config.machine import set_config_for_machine log = logging.getLogger(__name__) +@dataclass +class MachineCreateRequest: + name: str + config: dict + + +@API.register +def create_machine(flake_dir: str | Path, machine: MachineCreateRequest) -> None: + set_config_for_machine(Path(flake_dir), machine.name, machine.config) + + def create_command(args: argparse.Namespace) -> None: - set_config_for_machine(args.flake, args.machine, dict()) + create_machine(args.flake, MachineCreateRequest(args.machine, dict())) def register_create_parser(parser: argparse.ArgumentParser) -> None: -- 2.45.1 From ab656d5655b0a9c9770b29ac51ea2993c226ee4f Mon Sep 17 00:00:00 2001 From: Johannes Kirschbauer Date: Sun, 26 May 2024 18:04:49 +0200 Subject: [PATCH 2/2] API: handle functions with multiple arguments --- pkgs/clan-cli/api.py | 4 +- pkgs/clan-cli/clan_cli/api/__init__.py | 41 +++++++++++++++---- pkgs/clan-cli/clan_cli/api/util.py | 12 ++++-- pkgs/clan-cli/clan_cli/flakes/inspect.py | 2 +- pkgs/clan-cli/clan_cli/machines/create.py | 2 +- pkgs/clan-cli/clan_cli/machines/list.py | 4 +- .../clan_vm_manager/views/webview.py | 28 ++++++++++++- pkgs/webview-ui/app/src/Config.tsx | 2 +- pkgs/webview-ui/app/src/message.ts | 14 +++++-- 9 files changed, 85 insertions(+), 24 deletions(-) diff --git a/pkgs/clan-cli/api.py b/pkgs/clan-cli/api.py index 1acb847c..e5015a3d 100644 --- a/pkgs/clan-cli/api.py +++ b/pkgs/clan-cli/api.py @@ -1,10 +1,12 @@ +import json + from clan_cli.api import API def main() -> None: schema = API.to_json_schema() print( - f"""export const schema = {schema} as const; + f"""export const schema = {json.dumps(schema, indent=2)} as const; """ ) diff --git a/pkgs/clan-cli/clan_cli/api/__init__.py b/pkgs/clan-cli/clan_cli/api/__init__.py index 501554a7..7d54871e 100644 --- a/pkgs/clan-cli/clan_cli/api/__init__.py +++ b/pkgs/clan-cli/clan_cli/api/__init__.py @@ -24,15 +24,14 @@ class ApiResponse(Generic[ResponseDataType]): class _MethodRegistry: def __init__(self) -> None: - self._registry: dict[str, Callable] = {} + self._registry: dict[str, Callable[[Any], Any]] = {} def register(self, fn: Callable[..., T]) -> Callable[..., T]: self._registry[fn.__name__] = fn return fn - def to_json_schema(self) -> str: + def to_json_schema(self) -> dict[str, Any]: # Import only when needed - import json from typing import get_type_hints from clan_cli.api.util import type_to_dict @@ -41,25 +40,51 @@ class _MethodRegistry: "$comment": "An object containing API methods. ", "type": "object", "additionalProperties": False, - "required": ["list_machines"], + "required": [func_name for func_name in self._registry.keys()], "properties": {}, } + for name, func in self._registry.items(): hints = get_type_hints(func) + serialized_hints = { - "argument" if key != "return" else "return": type_to_dict( + key: type_to_dict( value, scope=name + " argument" if key != "return" else "return" ) for key, value in hints.items() } + + return_type = serialized_hints.pop("return") + api_schema["properties"][name] = { "type": "object", - "required": [k for k in serialized_hints.keys()], + "required": ["arguments", "return"], "additionalProperties": False, - "properties": {**serialized_hints}, + "properties": { + "return": return_type, + "arguments": { + "type": "object", + "required": [k for k in serialized_hints.keys()], + "additionalProperties": False, + "properties": serialized_hints, + }, + }, } - return json.dumps(api_schema, indent=2) + return api_schema + + def get_method_argtype(self, method_name: str, arg_name: str) -> Any: + from inspect import signature + + func = self._registry.get(method_name, None) + if func: + sig = signature(func) + param = sig.parameters.get(arg_name) + if param: + param_class = param.annotation + return param_class + + return None API = _MethodRegistry() diff --git a/pkgs/clan-cli/clan_cli/api/util.py b/pkgs/clan-cli/clan_cli/api/util.py index bd9b10a4..277a273e 100644 --- a/pkgs/clan-cli/clan_cli/api/util.py +++ b/pkgs/clan-cli/clan_cli/api/util.py @@ -42,10 +42,14 @@ def type_to_dict(t: Any, scope: str = "") -> dict: return {"type": "array", "items": type_to_dict(t.__args__[0], scope)} elif issubclass(origin, dict): - return { - "type": "object", - "additionalProperties": type_to_dict(t.__args__[1], scope), - } + value_type = t.__args__[1] + if value_type is Any: + return {"type": "object", "additionalProperties": True} + else: + return { + "type": "object", + "additionalProperties": type_to_dict(value_type, scope), + } raise BaseException(f"Error api type not yet supported {t!s}") diff --git a/pkgs/clan-cli/clan_cli/flakes/inspect.py b/pkgs/clan-cli/clan_cli/flakes/inspect.py index 6b1bf85b..e5c5190c 100644 --- a/pkgs/clan-cli/clan_cli/flakes/inspect.py +++ b/pkgs/clan-cli/clan_cli/flakes/inspect.py @@ -39,7 +39,7 @@ def inspect_flake(flake_url: str | Path, machine_name: str) -> FlakeConfig: system = config["system"] # Check if the machine exists - machines = list_machines(False, flake_url) + machines = list_machines(flake_url, False) if machine_name not in machines: raise ClanError( f"Machine {machine_name} not found in {flake_url}. Available machines: {', '.join(machines)}" diff --git a/pkgs/clan-cli/clan_cli/machines/create.py b/pkgs/clan-cli/clan_cli/machines/create.py index a4420600..348d02f1 100644 --- a/pkgs/clan-cli/clan_cli/machines/create.py +++ b/pkgs/clan-cli/clan_cli/machines/create.py @@ -12,7 +12,7 @@ log = logging.getLogger(__name__) @dataclass class MachineCreateRequest: name: str - config: dict + config: dict[str, int] @API.register diff --git a/pkgs/clan-cli/clan_cli/machines/list.py b/pkgs/clan-cli/clan_cli/machines/list.py index d0966ea6..7378a001 100644 --- a/pkgs/clan-cli/clan_cli/machines/list.py +++ b/pkgs/clan-cli/clan_cli/machines/list.py @@ -20,7 +20,7 @@ class MachineInfo: @API.register -def list_machines(debug: bool, flake_url: Path | str) -> dict[str, MachineInfo]: +def list_machines(flake_url: str | Path, debug: bool) -> dict[str, MachineInfo]: config = nix_config() system = config["system"] cmd = nix_eval( @@ -57,7 +57,7 @@ def list_command(args: argparse.Namespace) -> None: print("Listing all machines:\n") print("Source: ", flake_path) print("-" * 40) - for name, machine in list_machines(args.debug, flake_path).items(): + for name, machine in list_machines(flake_path, args.debug).items(): description = machine.machine_description or "[no description]" print(f"{name}\n: {description}\n") print("-" * 40) diff --git a/pkgs/clan-vm-manager/clan_vm_manager/views/webview.py b/pkgs/clan-vm-manager/clan_vm_manager/views/webview.py index 667913ea..fece3835 100644 --- a/pkgs/clan-vm-manager/clan_vm_manager/views/webview.py +++ b/pkgs/clan-vm-manager/clan_vm_manager/views/webview.py @@ -9,6 +9,7 @@ from threading import Lock from typing import Any import gi +from clan_cli.api import API gi.require_version("WebKit", "6.0") @@ -95,11 +96,34 @@ class WebView: self.queue_size += 1 def threaded_handler( - self, handler_fn: Callable[[Any], Any], data: Any, method_name: str + self, + handler_fn: Callable[ + ..., + Any, + ], + data: dict[str, Any] | None, + method_name: str, ) -> None: with self.mutex_lock: log.debug("Executing... ", method_name) - result = handler_fn(data) + log.debug(f"{data}") + if data is None: + result = handler_fn() + else: + reconciled_arguments = {} + for k, v in data.items(): + # Some functions expect to be called with dataclass instances + # But the js api returns dictionaries. + # Introspect the function and create the expected dataclass from dict dynamically + # Depending on the introspected argument_type + arg_type = API.get_method_argtype(method_name, k) + if dataclasses.is_dataclass(arg_type): + reconciled_arguments[k] = arg_type(**v) + else: + reconciled_arguments[k] = v + + result = handler_fn(**reconciled_arguments) + serialized = json.dumps(dataclass_to_dict(result)) # Use idle_add to queue the response call to js on the main GTK thread diff --git a/pkgs/webview-ui/app/src/Config.tsx b/pkgs/webview-ui/app/src/Config.tsx index a2ccaf3b..c4a93665 100644 --- a/pkgs/webview-ui/app/src/Config.tsx +++ b/pkgs/webview-ui/app/src/Config.tsx @@ -28,7 +28,7 @@ export const makeCountContext = () => { getMachines: () => { // When the gtk function sends its data the loading state will be set to false setLoading(true); - pyApi.list_machines.dispatch("."); + pyApi.list_machines.dispatch({ debug: true, flake_url: "." }); }, }, ] as const; diff --git a/pkgs/webview-ui/app/src/message.ts b/pkgs/webview-ui/app/src/message.ts index ee059a38..bda18410 100644 --- a/pkgs/webview-ui/app/src/message.ts +++ b/pkgs/webview-ui/app/src/message.ts @@ -4,7 +4,7 @@ import { schema } from "@/api"; export type API = FromSchema; export type OperationNames = keyof API; -export type OperationArgs = API[T]["argument"]; +export type OperationArgs = API[T]["arguments"]; export type OperationResponse = API[T]["return"]; declare global { @@ -15,7 +15,10 @@ declare global { webkit: { messageHandlers: { gtk: { - postMessage: (message: { method: OperationNames; data: any }) => void; + postMessage: (message: { + method: OperationNames; + data: OperationArgs; + }) => void; }; }; }; @@ -31,7 +34,7 @@ function createFunctions( return { dispatch: (args: OperationArgs) => { console.log( - `Operation: ${operationName}, Arguments: ${JSON.stringify(args)}` + `Operation: ${String(operationName)}, Arguments: ${JSON.stringify(args)}` ); // Send the data to the gtk app window.webkit.messageHandlers.gtk.postMessage({ @@ -69,7 +72,10 @@ const deserialize = // Create the API object const pyApi: PyApi = {} as PyApi; -operationNames.forEach((name) => { +operationNames.forEach((opName) => { + const name = opName as OperationNames; + // @ts-ignore: TODO make typescript happy pyApi[name] = createFunctions(name); }); + export { pyApi }; -- 2.45.1