1
0
forked from clan/clan-core

Merge pull request 'API: init op_key, improve seralisation & signature typing' (#1622) from hsjobeki/clan-core:hsjobeki-main into main

This commit is contained in:
clan-bot 2024-06-15 09:38:29 +00:00
commit b28950f310
9 changed files with 183 additions and 51 deletions

View File

@ -38,8 +38,10 @@ def dataclass_to_dict(obj: Any) -> Any:
return [dataclass_to_dict(item) for item in obj] return [dataclass_to_dict(item) for item in obj]
elif isinstance(obj, dict): elif isinstance(obj, dict):
return {k: dataclass_to_dict(v) for k, v in obj.items()} return {k: dataclass_to_dict(v) for k, v in obj.items()}
else: elif isinstance(obj, Path):
return str(obj) return str(obj)
else:
return obj
# Implement the abstract open_file function # Implement the abstract open_file function
@ -265,6 +267,7 @@ class WebView:
result = handler_fn() result = handler_fn()
else: else:
reconciled_arguments = {} reconciled_arguments = {}
op_key = data.pop("op_key", None)
for k, v in data.items(): for k, v in data.items():
# Some functions expect to be called with dataclass instances # Some functions expect to be called with dataclass instances
# But the js api returns dictionaries. # But the js api returns dictionaries.
@ -276,8 +279,13 @@ class WebView:
else: else:
reconciled_arguments[k] = v reconciled_arguments[k] = v
result = handler_fn(**reconciled_arguments) r = handler_fn(**reconciled_arguments)
serialized = json.dumps(dataclass_to_dict(result)) # Parse the result to a serializable dictionary
# Echo back the "op_key" to the js api
result = dataclass_to_dict(r)
result["op_key"] = op_key
serialized = json.dumps(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

@ -1,6 +1,7 @@
from collections.abc import Callable from collections.abc import Callable
from dataclasses import dataclass from dataclasses import dataclass
from functools import wraps from functools import wraps
from inspect import Parameter, signature
from typing import Annotated, Any, Generic, Literal, TypeVar, get_type_hints from typing import Annotated, Any, Generic, Literal, TypeVar, get_type_hints
from clan_cli.errors import ClanError from clan_cli.errors import ClanError
@ -21,17 +22,34 @@ class ApiError:
class SuccessDataClass(Generic[ResponseDataType]): class SuccessDataClass(Generic[ResponseDataType]):
status: Annotated[Literal["success"], "The status of the response."] status: Annotated[Literal["success"], "The status of the response."]
data: ResponseDataType data: ResponseDataType
op_key: str | None
@dataclass @dataclass
class ErrorDataClass: class ErrorDataClass:
status: Literal["error"] status: Literal["error"]
errors: list[ApiError] errors: list[ApiError]
op_key: str | None
ApiResponse = SuccessDataClass[ResponseDataType] | ErrorDataClass ApiResponse = SuccessDataClass[ResponseDataType] | ErrorDataClass
def update_wrapper_signature(wrapper: Callable, wrapped: Callable) -> None:
sig = signature(wrapped)
params = list(sig.parameters.values())
# Add 'op_key' parameter
op_key_param = Parameter(
"op_key", Parameter.KEYWORD_ONLY, default=None, annotation=str | None
)
params.append(op_key_param)
# Create a new signature
new_sig = sig.replace(parameters=params)
wrapper.__signature__ = new_sig # type: ignore
class _MethodRegistry: class _MethodRegistry:
def __init__(self) -> None: def __init__(self) -> None:
self._orig: dict[str, Callable[[Any], Any]] = {} self._orig: dict[str, Callable[[Any], Any]] = {}
@ -41,13 +59,16 @@ class _MethodRegistry:
self._orig[fn.__name__] = fn self._orig[fn.__name__] = fn
@wraps(fn) @wraps(fn)
def wrapper(*args: Any, **kwargs: Any) -> ApiResponse[T]: def wrapper(
*args: Any, op_key: str | None = None, **kwargs: Any
) -> ApiResponse[T]:
try: try:
data: T = fn(*args, **kwargs) data: T = fn(*args, **kwargs)
return SuccessDataClass(status="success", data=data) return SuccessDataClass(status="success", data=data, op_key=op_key)
except ClanError as e: except ClanError as e:
return ErrorDataClass( return ErrorDataClass(
status="error", status="error",
op_key=op_key,
errors=[ errors=[
ApiError( ApiError(
message=e.msg, message=e.msg,
@ -63,6 +84,11 @@ class _MethodRegistry:
orig_return_type = get_type_hints(fn).get("return") orig_return_type = get_type_hints(fn).get("return")
wrapper.__annotations__["return"] = ApiResponse[orig_return_type] # type: ignore wrapper.__annotations__["return"] = ApiResponse[orig_return_type] # type: ignore
# Add additional argument for the operation key
wrapper.__annotations__["op_key"] = str | None # type: ignore
update_wrapper_signature(wrapper, fn)
self._registry[fn.__name__] = wrapper self._registry[fn.__name__] = wrapper
return fn return fn
@ -91,6 +117,12 @@ class _MethodRegistry:
return_type = serialized_hints.pop("return") return_type = serialized_hints.pop("return")
sig = signature(func)
required_args = []
for n, param in sig.parameters.items():
if param.default == Parameter.empty:
required_args.append(n)
api_schema["properties"][name] = { api_schema["properties"][name] = {
"type": "object", "type": "object",
"required": ["arguments", "return"], "required": ["arguments", "return"],
@ -99,7 +131,7 @@ class _MethodRegistry:
"return": return_type, "return": return_type,
"arguments": { "arguments": {
"type": "object", "type": "object",
"required": [k for k in serialized_hints.keys()], "required": required_args,
"additionalProperties": False, "additionalProperties": False,
"properties": serialized_hints, "properties": serialized_hints,
}, },

View File

@ -11,15 +11,17 @@ faker.option({
const getFakeResponse = (method: OperationNames, data: any) => { const getFakeResponse = (method: OperationNames, data: any) => {
const fakeData = faker.generate(schema.properties[method].properties.return); const fakeData = faker.generate(schema.properties[method].properties.return);
const { op_key } = data;
if (method === "open_file") { if (method === "open_file") {
return { return {
status: "success", status: "success",
data: "/path/to/clan", data: "/path/to/clan",
op_key,
}; };
} }
return fakeData; // @ts-expect-error: fakeData is guaranteed to always be some object
return { ...fakeData, op_key };
}; };
export { getFakeResponse }; export { getFakeResponse };

View File

@ -7,6 +7,12 @@ import { Toaster } from "solid-toast";
// Global state // Global state
const [route, setRoute] = createSignal<Route>("machines"); const [route, setRoute] = createSignal<Route>("machines");
const [currClanURI, setCurrClanURI] = createSignal<string>(
"/home/johannes/1_clans/myclan"
);
export { currClanURI, setCurrClanURI };
export { route, setRoute }; export { route, setRoute };
const App: Component = () => { const App: Component = () => {

View File

@ -6,6 +6,7 @@ import {
createEffect, createEffect,
} from "solid-js"; } from "solid-js";
import { OperationResponse, pyApi } from "./api"; import { OperationResponse, pyApi } from "./api";
import { currClanURI } from "./App";
export const makeMachineContext = () => { export const makeMachineContext = () => {
const [machines, setMachines] = const [machines, setMachines] =
@ -27,7 +28,10 @@ export const makeMachineContext = () => {
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({ debug: true, flake_url: "." }); pyApi.list_machines.dispatch({
debug: true,
flake_url: currClanURI(),
});
}, },
}, },
] as const; ] as const;

View File

@ -11,6 +11,10 @@ export type SuccessData<T extends OperationNames> = Extract<
OperationResponse<T>, OperationResponse<T>,
{ status: "success" } { status: "success" }
>; >;
export type ErrorData<T extends OperationNames> = Extract<
OperationResponse<T>,
{ status: "error" }
>;
export type ClanOperations = { export type ClanOperations = {
[K in OperationNames]: (str: string) => void; [K in OperationNames]: (str: string) => void;
@ -33,6 +37,27 @@ declare global {
// Make sure window.webkit is defined although the type is not correctly filled yet. // Make sure window.webkit is defined although the type is not correctly filled yet.
window.clan = {} as ClanOperations; window.clan = {} as ClanOperations;
const operations = schema.properties;
const operationNames = Object.keys(operations) as OperationNames[];
type ObserverRegistry = {
[K in OperationNames]: ((response: OperationResponse<K>) => void)[];
};
const obs: ObserverRegistry = operationNames.reduce(
(acc, opName) => ({
...acc,
[opName]: [],
}),
{} as ObserverRegistry
);
interface ReceiveOptions {
/**
* Calls only the registered function that has the same key as used with dispatch
*
*/
fnKey: string;
}
function createFunctions<K extends OperationNames>( function createFunctions<K extends OperationNames>(
operationName: K operationName: K
): { ): {
@ -41,24 +66,27 @@ function createFunctions<K extends OperationNames>(
} { } {
return { return {
dispatch: (args: OperationArgs<K>) => { dispatch: (args: OperationArgs<K>) => {
console.log( // console.log(
`Operation: ${String(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({
method: operationName, method: operationName,
data: args, data: args,
}); });
}, },
receive: (fn: (response: OperationResponse<K>) => void) => { receive: (
window.clan[operationName] = deserialize(fn); fn: (response: OperationResponse<K>) => void
// options?: ReceiveOptions
) => {
obs[operationName].push(fn);
window.clan[operationName] = (s: string) => {
obs[operationName].forEach((f) => deserialize(f)(s));
};
}, },
}; };
} }
const operations = schema.properties;
const operationNames = Object.keys(operations) as OperationNames[];
type PyApi = { type PyApi = {
[K in OperationNames]: { [K in OperationNames]: {
dispatch: (args: OperationArgs<K>) => void; dispatch: (args: OperationArgs<K>) => void;
@ -70,7 +98,6 @@ 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

@ -0,0 +1,83 @@
import { For, Show, createSignal } from "solid-js";
import { ErrorData, SuccessData, pyApi } from "../api";
import { currClanURI } from "../App";
interface MachineListItemProps {
name: string;
}
type MachineDetails = Record<string, SuccessData<"show_machine">["data"]>;
type MachineErrors = Record<string, ErrorData<"show_machine">["errors"]>;
const [details, setDetails] = createSignal<MachineDetails>({});
const [errors, setErrors] = createSignal<MachineErrors>({});
pyApi.show_machine.receive((r) => {
if (r.status === "error") {
const { op_key } = r;
if (op_key) {
setErrors((e) => ({ ...e, [op_key]: r.errors }));
}
console.error(r.errors);
}
if (r.status === "success") {
setDetails((d) => ({ ...d, [r.data.machine_name]: r.data }));
}
});
export const MachineListItem = (props: MachineListItemProps) => {
const { name } = props;
pyApi.show_machine.dispatch({
op_key: name,
machine_name: name,
debug: false,
flake_url: currClanURI(),
});
return (
<li>
<div class="card card-side m-2 bg-base-100 shadow-lg">
<figure class="pl-2">
<span class="material-icons content-center text-5xl">
devices_other
</span>
</figure>
<div class="card-body flex-row justify-between">
<div class="flex flex-col">
<h2 class="card-title">{name}</h2>
<Show when={errors()[name]}>
{(errors) => (
<For each={errors()}>
{(error) => (
<p class="text-red-500">
{error.message}: {error.description}
</p>
)}
</For>
)}
</Show>
<Show when={details()[name]}>
{(details) => (
<p
classList={{
"text-gray-400": !details().machine_description,
"text-gray-600": !!details().machine_description,
}}
>
{details().machine_description || "No description"}
</p>
)}
</Show>
</div>
<div>
<button class="btn btn-ghost">
<span class="material-icons">more_vert</span>
</button>
</div>
</div>
</div>
</li>
);
};

View File

@ -14,7 +14,6 @@ if (import.meta.env.DEV && !(root instanceof HTMLElement)) {
); );
} }
console.log(import.meta.env);
if (import.meta.env.DEV) { if (import.meta.env.DEV) {
console.log("Development mode"); console.log("Development mode");
// Load the debugger in development mode // Load the debugger in development mode
@ -27,7 +26,7 @@ if (import.meta.env.DEV) {
console.debug("Python API call", { method, data }); console.debug("Python API call", { method, data });
setTimeout(() => { setTimeout(() => {
const mock = getFakeResponse(method, data); const mock = getFakeResponse(method, data);
console.log("mock", { mock }); console.log("Returning mock-data: ", { mock });
window.clan[method](JSON.stringify(mock)); window.clan[method](JSON.stringify(mock));
}, 200); }, 200);

View File

@ -10,6 +10,7 @@ import { useMachineContext } from "../../Config";
import { route } from "@/src/App"; import { route } from "@/src/App";
import { OperationResponse, pyApi } from "@/src/api"; import { OperationResponse, pyApi } from "@/src/api";
import toast from "solid-toast"; import toast from "solid-toast";
import { MachineListItem } from "@/src/components/MachineListItem";
type FilesModel = Extract< type FilesModel = Extract<
OperationResponse<"get_directory">, OperationResponse<"get_directory">,
@ -99,37 +100,7 @@ export const MachineListView: Component = () => {
<Match when={!loading()}> <Match when={!loading()}>
<ul> <ul>
<For each={data()}> <For each={data()}>
{(entry) => ( {(entry) => <MachineListItem name={entry} />}
<li>
<div class="card card-side m-2 bg-base-100 shadow-lg">
<figure class="pl-2">
<span class="material-icons content-center text-5xl">
devices_other
</span>
</figure>
<div class="card-body flex-row justify-between">
<div class="flex flex-col">
<h2 class="card-title">{entry}</h2>
{/*
<p
classList={{
"text-gray-400": !entry.machine_description,
"text-gray-600": !!entry.machine_description,
}}
>
{entry.machine_description || "No description"}
</p>
*/}
</div>
<div>
<button class="btn btn-ghost">
<span class="material-icons">more_vert</span>
</button>
</div>
</div>
</div>
</li>
)}
</For> </For>
</ul> </ul>
</Match> </Match>