diff --git a/pkgs/clan-app/clan_app/views/webview.py b/pkgs/clan-app/clan_app/views/webview.py index 9c8893041..467883038 100644 --- a/pkgs/clan-app/clan_app/views/webview.py +++ b/pkgs/clan-app/clan_app/views/webview.py @@ -4,11 +4,10 @@ import logging from typing import Any import gi -from clan_cli.api import MethodRegistry +from clan_cli.api import MethodRegistry, dataclass_to_dict, from_dict from clan_app.api import GObjApi, GResult, ImplFunc from clan_app.api.file import open_file -from clan_app.components.serializer import dataclass_to_dict, from_dict gi.require_version("WebKit", "6.0") from gi.repository import GLib, GObject, WebKit diff --git a/pkgs/clan-cli/clan_cli/api/__init__.py b/pkgs/clan-cli/clan_cli/api/__init__.py index 103c645b2..5237bc2a5 100644 --- a/pkgs/clan-cli/clan_cli/api/__init__.py +++ b/pkgs/clan-cli/clan_cli/api/__init__.py @@ -1,11 +1,141 @@ +import dataclasses +import json from collections.abc import Callable -from dataclasses import dataclass +from dataclasses import dataclass, fields, is_dataclass from functools import wraps from inspect import Parameter, Signature, signature -from typing import Annotated, Any, Generic, Literal, TypeVar, get_type_hints +from pathlib import Path +from types import UnionType +from typing import ( + Annotated, + Any, + Generic, + Literal, + TypeVar, + get_args, + get_origin, + get_type_hints, +) from clan_cli.errors import ClanError + +def sanitize_string(s: str) -> str: + # Using the native string sanitizer to handle all edge cases + # Remove the outer quotes '"string"' + return json.dumps(s)[1:-1] + + +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 is_dataclass(obj): + return { + # Use either the original name or name + sanitize_string( + field.metadata.get("original_name", field.name) + ): dataclass_to_dict(getattr(obj, field.name)) + for field in fields(obj) # type: ignore + } + elif isinstance(obj, list | tuple): + return [dataclass_to_dict(item) for item in obj] + elif isinstance(obj, dict): + return {sanitize_string(k): dataclass_to_dict(v) for k, v in obj.items()} + elif isinstance(obj, Path): + return sanitize_string(str(obj)) + elif isinstance(obj, str): + return sanitize_string(obj) + else: + return obj + + +def is_union_type(type_hint: type) -> bool: + return type(type_hint) is UnionType + + +def get_inner_type(type_hint: type) -> type: + if is_union_type(type_hint): + # Return the first non-None type + return next(t for t in get_args(type_hint) if t is not type(None)) + return type_hint + + +def get_second_type(type_hint: type[dict]) -> type: + """ + Get the value type of a dictionary type hint + """ + args = get_args(type_hint) + if len(args) == 2: + # Return the second argument, which should be the value type (Machine) + return args[1] + + raise ValueError(f"Invalid type hint for dict: {type_hint}") + + +def from_dict(t: type, data: dict[str, Any] | None) -> Any: + """ + Dynamically instantiate a data class from a dictionary, handling nested data classes. + """ + if data is None: + return None + + try: + # Attempt to create an instance of the data_class + field_values = {} + for field in fields(t): + original_name = field.metadata.get("original_name", field.name) + + field_value = data.get(original_name) + + field_type = get_inner_type(field.type) # type: ignore + + if original_name in data: + # If the field is another dataclass, recursively instantiate it + if is_dataclass(field_type): + field_value = from_dict(field_type, field_value) + elif isinstance(field_type, Path | str) and isinstance( + field_value, str + ): + field_value = ( + Path(field_value) if field_type == Path else field_value + ) + elif get_origin(field_type) is dict and isinstance(field_value, dict): + # The field is a dictionary with a specific type + inner_type = get_second_type(field_type) + field_value = { + k: from_dict(inner_type, v) for k, v in field_value.items() + } + elif get_origin is list and isinstance(field_value, list): + # The field is a list with a specific type + inner_type = get_args(field_type)[0] + field_value = [from_dict(inner_type, v) for v in field_value] + + # Set the value + if ( + field.default is not dataclasses.MISSING + or field.default_factory is not dataclasses.MISSING + ): + # Fields with default value + # a: Int = 1 + # b: list = Field(default_factory=list) + if original_name in data or field_value is not None: + field_values[field.name] = field_value + else: + # Fields without default value + # a: Int + field_values[field.name] = field_value + + return t(**field_values) + + except (TypeError, ValueError) as e: + print(f"Failed to instantiate {t.__name__}: {e} {data}") + return None + + T = TypeVar("T") ResponseDataType = TypeVar("ResponseDataType") diff --git a/pkgs/clan-cli/clan_cli/inventory/__init__.py b/pkgs/clan-cli/clan_cli/inventory/__init__.py index 68a8e8bf9..acf680b75 100644 --- a/pkgs/clan-cli/clan_cli/inventory/__init__.py +++ b/pkgs/clan-cli/clan_cli/inventory/__init__.py @@ -12,14 +12,10 @@ Operate on the returned inventory to make changes - save_inventory: To persist changes. """ -import dataclasses import json -from dataclasses import fields, is_dataclass from pathlib import Path -from types import UnionType -from typing import Any, get_args, get_origin -from clan_cli.api import API +from clan_cli.api import API, dataclass_to_dict, from_dict from clan_cli.errors import ClanCmdError, ClanError from clan_cli.git import commit_file @@ -41,6 +37,8 @@ from .classes import ( # Re export classes here # This allows to rename classes in the generated code __all__ = [ + "from_dict", + "dataclass_to_dict", "Service", "Machine", "Meta", @@ -54,121 +52,6 @@ __all__ = [ ] -def sanitize_string(s: str) -> str: - return s.replace("\\", "\\\\").replace('"', '\\"').replace("\n", "\\n") - - -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 is_dataclass(obj): - return { - # Use either the original name or name - sanitize_string( - field.metadata.get("original_name", field.name) - ): dataclass_to_dict(getattr(obj, field.name)) - for field in fields(obj) # type: ignore - } - elif isinstance(obj, list | tuple): - return [dataclass_to_dict(item) for item in obj] - elif isinstance(obj, dict): - return {sanitize_string(k): dataclass_to_dict(v) for k, v in obj.items()} - elif isinstance(obj, Path): - return str(obj) - elif isinstance(obj, str): - return sanitize_string(obj) - else: - return obj - - -def is_union_type(type_hint: type) -> bool: - return type(type_hint) is UnionType - - -def get_inner_type(type_hint: type) -> type: - if is_union_type(type_hint): - # Return the first non-None type - return next(t for t in get_args(type_hint) if t is not type(None)) - return type_hint - - -def get_second_type(type_hint: type[dict]) -> type: - """ - Get the value type of a dictionary type hint - """ - args = get_args(type_hint) - if len(args) == 2: - # Return the second argument, which should be the value type (Machine) - return args[1] - - raise ValueError(f"Invalid type hint for dict: {type_hint}") - - -def from_dict(t: type, data: dict[str, Any] | None) -> Any: - """ - Dynamically instantiate a data class from a dictionary, handling nested data classes. - """ - if data is None: - return None - - try: - # Attempt to create an instance of the data_class - field_values = {} - for field in fields(t): - original_name = field.metadata.get("original_name", field.name) - - field_value = data.get(original_name) - - field_type = get_inner_type(field.type) # type: ignore - - if original_name in data: - # If the field is another dataclass, recursively instantiate it - if is_dataclass(field_type): - field_value = from_dict(field_type, field_value) - elif isinstance(field_type, Path | str) and isinstance( - field_value, str - ): - field_value = ( - Path(field_value) if field_type == Path else field_value - ) - elif get_origin(field_type) is dict and isinstance(field_value, dict): - # The field is a dictionary with a specific type - inner_type = get_second_type(field_type) - field_value = { - k: from_dict(inner_type, v) for k, v in field_value.items() - } - elif get_origin is list and isinstance(field_value, list): - # The field is a list with a specific type - inner_type = get_args(field_type)[0] - field_value = [from_dict(inner_type, v) for v in field_value] - - # Set the value - if ( - field.default is not dataclasses.MISSING - or field.default_factory is not dataclasses.MISSING - ): - # Fields with default value - # a: Int = 1 - # b: list = Field(default_factory=list) - if original_name in data or field_value is not None: - field_values[field.name] = field_value - else: - # Fields without default value - # a: Int - field_values[field.name] = field_value - - return t(**field_values) - - except (TypeError, ValueError) as e: - print(f"Failed to instantiate {t.__name__}: {e} {data}") - return None - # raise ClanError(f"Failed to instantiate {t.__name__}: {e}") - - def get_path(flake_dir: str | Path) -> Path: """ Get the path to the inventory file in the flake directory diff --git a/pkgs/webview-ui/app/src/api.ts b/pkgs/webview-ui/app/src/api.ts index bcb1689cf..dbe19ad9b 100644 --- a/pkgs/webview-ui/app/src/api.ts +++ b/pkgs/webview-ui/app/src/api.ts @@ -143,8 +143,9 @@ const deserialize = fn(r); } catch (e) { console.log("Error parsing JSON: ", e); - console.log({ download: () => download("error.json", str) }); + window.localStorage.setItem("error", str); console.error(str); + console.error("See localStorage 'error'"); alert(`Error parsing JSON: ${e}`); } }; diff --git a/pkgs/webview-ui/app/src/routes/settings/index.tsx b/pkgs/webview-ui/app/src/routes/settings/index.tsx index 0cd639446..29eb2846b 100644 --- a/pkgs/webview-ui/app/src/routes/settings/index.tsx +++ b/pkgs/webview-ui/app/src/routes/settings/index.tsx @@ -12,7 +12,8 @@ import { setRoute, clanList, } from "@/src/App"; -import { For } from "solid-js"; +import { For, Show } from "solid-js"; +import { createQuery } from "@tanstack/solid-query"; export const registerClan = async () => { try { @@ -26,6 +27,7 @@ export const registerClan = async () => { const res = new Set([...s, loc.data]); return Array.from(res); }); + setActiveURI(loc.data); setRoute((r) => { if (r === "welcome") return "machines"; return r; @@ -37,6 +39,87 @@ export const registerClan = async () => { } }; +interface ClanDetailsProps { + clan_dir: string; +} +const ClanDetails = (props: ClanDetailsProps) => { + const { clan_dir } = props; + + const details = createQuery(() => ({ + queryKey: [clan_dir, "meta"], + queryFn: async () => { + const result = await callApi("show_clan_meta", { uri: clan_dir }); + if (result.status === "error") throw new Error("Failed to fetch data"); + return result.data; + }, + })); + + return ( +