Merge pull request 'Api: init response envelop' (#1444) from hsjobeki-feat/api-improvements into main

This commit is contained in:
clan-bot
2024-05-26 16:08:02 +00:00
9 changed files with 118 additions and 25 deletions

View File

@@ -1,10 +1,12 @@
import json
from clan_cli.api import API from clan_cli.api import API
def main() -> None: def main() -> None:
schema = API.to_json_schema() schema = API.to_json_schema()
print( print(
f"""export const schema = {schema} as const; f"""export const schema = {json.dumps(schema, indent=2)} as const;
""" """
) )

View File

@@ -1,20 +1,37 @@
from collections.abc import Callable from collections.abc import Callable
from typing import Any, TypeVar from dataclasses import dataclass
from typing import Any, Generic, Literal, TypeVar
T = TypeVar("T") 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: class _MethodRegistry:
def __init__(self) -> None: def __init__(self) -> None:
self._registry: dict[str, Callable] = {} self._registry: dict[str, Callable[[Any], Any]] = {}
def register(self, fn: Callable[..., T]) -> Callable[..., T]: def register(self, fn: Callable[..., T]) -> Callable[..., T]:
self._registry[fn.__name__] = fn self._registry[fn.__name__] = fn
return fn return fn
def to_json_schema(self) -> str: def to_json_schema(self) -> dict[str, Any]:
# Import only when needed # Import only when needed
import json
from typing import get_type_hints from typing import get_type_hints
from clan_cli.api.util import type_to_dict from clan_cli.api.util import type_to_dict
@@ -23,25 +40,51 @@ class _MethodRegistry:
"$comment": "An object containing API methods. ", "$comment": "An object containing API methods. ",
"type": "object", "type": "object",
"additionalProperties": False, "additionalProperties": False,
"required": ["list_machines"], "required": [func_name for func_name in self._registry.keys()],
"properties": {}, "properties": {},
} }
for name, func in self._registry.items(): for name, func in self._registry.items():
hints = get_type_hints(func) hints = get_type_hints(func)
serialized_hints = { serialized_hints = {
"argument" if key != "return" else "return": type_to_dict( key: type_to_dict(
value, scope=name + " argument" if key != "return" else "return" value, scope=name + " argument" if key != "return" else "return"
) )
for key, value in hints.items() for key, value in hints.items()
} }
return_type = serialized_hints.pop("return")
api_schema["properties"][name] = { api_schema["properties"][name] = {
"type": "object",
"required": ["arguments", "return"],
"additionalProperties": False,
"properties": {
"return": return_type,
"arguments": {
"type": "object", "type": "object",
"required": [k for k in serialized_hints.keys()], "required": [k for k in serialized_hints.keys()],
"additionalProperties": False, "additionalProperties": False,
"properties": {**serialized_hints}, "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() API = _MethodRegistry()

View File

@@ -42,9 +42,13 @@ def type_to_dict(t: Any, scope: str = "") -> dict:
return {"type": "array", "items": type_to_dict(t.__args__[0], scope)} return {"type": "array", "items": type_to_dict(t.__args__[0], scope)}
elif issubclass(origin, dict): elif issubclass(origin, dict):
value_type = t.__args__[1]
if value_type is Any:
return {"type": "object", "additionalProperties": True}
else:
return { return {
"type": "object", "type": "object",
"additionalProperties": type_to_dict(t.__args__[1], scope), "additionalProperties": type_to_dict(value_type, 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

@@ -39,7 +39,7 @@ def inspect_flake(flake_url: str | Path, machine_name: str) -> FlakeConfig:
system = config["system"] system = config["system"]
# Check if the machine exists # Check if the machine exists
machines = list_machines(False, flake_url) machines = list_machines(flake_url, False)
if machine_name not in machines: if machine_name not in machines:
raise ClanError( raise ClanError(
f"Machine {machine_name} not found in {flake_url}. Available machines: {', '.join(machines)}" f"Machine {machine_name} not found in {flake_url}. Available machines: {', '.join(machines)}"

View File

@@ -1,13 +1,27 @@
import argparse import argparse
import logging 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 from clan_cli.config.machine import set_config_for_machine
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@dataclass
class MachineCreateRequest:
name: str
config: dict[str, int]
@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: 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: def register_create_parser(parser: argparse.ArgumentParser) -> None:

View File

@@ -20,7 +20,7 @@ class MachineInfo:
@API.register @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() config = nix_config()
system = config["system"] system = config["system"]
cmd = nix_eval( cmd = nix_eval(
@@ -57,7 +57,7 @@ def list_command(args: argparse.Namespace) -> None:
print("Listing all machines:\n") print("Listing all machines:\n")
print("Source: ", flake_path) print("Source: ", flake_path)
print("-" * 40) 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]" description = machine.machine_description or "[no description]"
print(f"{name}\n: {description}\n") print(f"{name}\n: {description}\n")
print("-" * 40) print("-" * 40)

View File

@@ -9,6 +9,7 @@ from threading import Lock
from typing import Any from typing import Any
import gi import gi
from clan_cli.api import API
gi.require_version("WebKit", "6.0") gi.require_version("WebKit", "6.0")
@@ -95,11 +96,34 @@ class WebView:
self.queue_size += 1 self.queue_size += 1
def threaded_handler( 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: ) -> None:
with self.mutex_lock: with self.mutex_lock:
log.debug("Executing... ", method_name) 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)) 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

View File

@@ -28,7 +28,7 @@ export const makeCountContext = () => {
getMachines: () => { getMachines: () => {
// When the gtk function sends its data the loading state will be set to false // When the gtk function sends its data the loading state will be set to false
setLoading(true); setLoading(true);
pyApi.list_machines.dispatch("."); pyApi.list_machines.dispatch({ debug: true, flake_url: "." });
}, },
}, },
] as const; ] as const;

View File

@@ -4,7 +4,7 @@ import { schema } from "@/api";
export type API = FromSchema<typeof schema>; export type API = FromSchema<typeof schema>;
export type OperationNames = keyof API; export type OperationNames = keyof API;
export type OperationArgs<T extends OperationNames> = API[T]["argument"]; export type OperationArgs<T extends OperationNames> = API[T]["arguments"];
export type OperationResponse<T extends OperationNames> = API[T]["return"]; export type OperationResponse<T extends OperationNames> = API[T]["return"];
declare global { declare global {
@@ -15,7 +15,10 @@ declare global {
webkit: { webkit: {
messageHandlers: { messageHandlers: {
gtk: { gtk: {
postMessage: (message: { method: OperationNames; data: any }) => void; postMessage: (message: {
method: OperationNames;
data: OperationArgs<OperationNames>;
}) => void;
}; };
}; };
}; };
@@ -31,7 +34,7 @@ function createFunctions<K extends OperationNames>(
return { return {
dispatch: (args: OperationArgs<K>) => { dispatch: (args: OperationArgs<K>) => {
console.log( console.log(
`Operation: ${operationName}, Arguments: ${JSON.stringify(args)}` `Operation: ${String(operationName)}, Arguments: ${JSON.stringify(args)}`
); );
// Send the data to the gtk app // Send the data to the gtk app
window.webkit.messageHandlers.gtk.postMessage({ window.webkit.messageHandlers.gtk.postMessage({
@@ -69,7 +72,10 @@ const deserialize =
// Create the API object // Create the API object
const pyApi: PyApi = {} as PyApi; 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); pyApi[name] = createFunctions(name);
}); });
export { pyApi }; export { pyApi };