Merge pull request 'Clan-app: devshell add schemas for gtk4; gsettings' (#1803) from hsjobeki/clan-core:hsjobeki-main into main

This commit is contained in:
clan-bot
2024-07-24 16:31:47 +00:00
7 changed files with 232 additions and 182 deletions

View File

@@ -4,11 +4,10 @@ import logging
from typing import Any from typing import Any
import gi 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 import GObjApi, GResult, ImplFunc
from clan_app.api.file import open_file 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") gi.require_version("WebKit", "6.0")
from gi.repository import GLib, GObject, WebKit from gi.repository import GLib, GObject, WebKit

View File

@@ -4,7 +4,7 @@
setuptools, setuptools,
copyDesktopItems, copyDesktopItems,
pygobject3, pygobject3,
wrapGAppsHook, wrapGAppsHook4,
gtk4, gtk4,
adwaita-icon-theme, adwaita-icon-theme,
pygobject-stubs, pygobject-stubs,
@@ -84,7 +84,7 @@ python3.pkgs.buildPythonApplication rec {
nativeBuildInputs = [ nativeBuildInputs = [
setuptools setuptools
copyDesktopItems copyDesktopItems
wrapGAppsHook wrapGAppsHook4
gobject-introspection gobject-introspection
]; ];

View File

@@ -1,5 +1,7 @@
{ {
lib, lib,
glib,
gsettings-desktop-schemas,
stdenv, stdenv,
clan-app, clan-app,
mkShell, mkShell,
@@ -33,7 +35,9 @@ mkShell {
buildInputs = buildInputs =
[ [
glib
ruff ruff
gtk4
gtk4.dev # has the demo called 'gtk4-widget-factory' gtk4.dev # has the demo called 'gtk4-widget-factory'
libadwaita.devdoc # has the demo called 'adwaita-1-demo' libadwaita.devdoc # has the demo called 'adwaita-1-demo'
] ]
@@ -60,6 +64,9 @@ mkShell {
# Add clan-cli to the python path so that we can import it without building it in nix first # Add clan-cli to the python path so that we can import it without building it in nix first
export PYTHONPATH="$GIT_ROOT/pkgs/clan-cli":"$PYTHONPATH" export PYTHONPATH="$GIT_ROOT/pkgs/clan-cli":"$PYTHONPATH"
export XDG_DATA_DIRS=${gtk4}/share/gsettings-schemas/gtk4-4.14.4:$XDG_DATA_DIRS
export XDG_DATA_DIRS=${gsettings-desktop-schemas}/share/gsettings-schemas/gsettings-desktop-schemas-46.0:$XDG_DATA_DIRS
# Add the webview-ui to the .webui directory # Add the webview-ui to the .webui directory
ln -nsf ${webview-ui}/lib/node_modules/@clan/webview-ui/dist/ ./clan_app/.webui ln -nsf ${webview-ui}/lib/node_modules/@clan/webview-ui/dist/ ./clan_app/.webui

View File

@@ -1,11 +1,141 @@
import dataclasses
import json
from collections.abc import Callable from collections.abc import Callable
from dataclasses import dataclass from dataclasses import dataclass, fields, is_dataclass
from functools import wraps from functools import wraps
from inspect import Parameter, Signature, signature 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 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") T = TypeVar("T")
ResponseDataType = TypeVar("ResponseDataType") ResponseDataType = TypeVar("ResponseDataType")

View File

@@ -12,14 +12,10 @@ Operate on the returned inventory to make changes
- save_inventory: To persist changes. - save_inventory: To persist changes.
""" """
import dataclasses
import json import json
from dataclasses import fields, is_dataclass
from pathlib import Path 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.errors import ClanCmdError, ClanError
from clan_cli.git import commit_file from clan_cli.git import commit_file
@@ -41,6 +37,8 @@ from .classes import (
# Re export classes here # Re export classes here
# This allows to rename classes in the generated code # This allows to rename classes in the generated code
__all__ = [ __all__ = [
"from_dict",
"dataclass_to_dict",
"Service", "Service",
"Machine", "Machine",
"Meta", "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: def get_path(flake_dir: str | Path) -> Path:
""" """
Get the path to the inventory file in the flake directory Get the path to the inventory file in the flake directory

View File

@@ -143,8 +143,9 @@ const deserialize =
fn(r); fn(r);
} catch (e) { } catch (e) {
console.log("Error parsing JSON: ", e); console.log("Error parsing JSON: ", e);
console.log({ download: () => download("error.json", str) }); window.localStorage.setItem("error", str);
console.error(str); console.error(str);
console.error("See localStorage 'error'");
alert(`Error parsing JSON: ${e}`); alert(`Error parsing JSON: ${e}`);
} }
}; };

View File

@@ -12,7 +12,8 @@ import {
setRoute, setRoute,
clanList, clanList,
} from "@/src/App"; } from "@/src/App";
import { For } from "solid-js"; import { For, Show } from "solid-js";
import { createQuery } from "@tanstack/solid-query";
export const registerClan = async () => { export const registerClan = async () => {
try { try {
@@ -26,6 +27,7 @@ export const registerClan = async () => {
const res = new Set([...s, loc.data]); const res = new Set([...s, loc.data]);
return Array.from(res); return Array.from(res);
}); });
setActiveURI(loc.data);
setRoute((r) => { setRoute((r) => {
if (r === "welcome") return "machines"; if (r === "welcome") return "machines";
return r; 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 (
<div class="stat">
<div class="stat-figure text-primary">
<div class="join">
<button
class=" join-item btn-sm"
classList={{
"btn btn-ghost btn-outline": activeURI() !== clan_dir,
"badge badge-primary": activeURI() === clan_dir,
}}
disabled={activeURI() === clan_dir}
onClick={() => {
setActiveURI(clan_dir);
}}
>
{activeURI() === clan_dir ? "active" : "select"}
</button>
<button
class="btn btn-ghost btn-outline join-item btn-sm"
onClick={() => {
setClanList((s) =>
s.filter((v, idx) => {
if (v == clan_dir) {
setActiveURI(
clanList()[idx - 1] || clanList()[idx + 1] || null
);
return false;
}
return true;
})
);
}}
>
Remove
</button>
</div>
</div>
<div class="stat-title">Clan URI</div>
<Show when={details.isSuccess}>
<div
class="stat-value"
// classList={{
// "text-primary": activeURI() === clan_dir,
// }}
>
{details.data?.name}
</div>
</Show>
<Show
when={details.isSuccess && details.data?.description}
fallback={<div class="stat-desc text-lg">{clan_dir}</div>}
>
<div
class="stat-desc text-lg"
// classList={{
// "text-primary": activeURI() === clan_dir,
// }}
>
{details.data?.description}
</div>
</Show>
</div>
);
};
export const Settings = () => { export const Settings = () => {
return ( return (
<div class="card card-normal"> <div class="card card-normal">
@@ -54,60 +137,7 @@ export const Settings = () => {
</div> </div>
<div class="stats stats-vertical shadow"> <div class="stats stats-vertical shadow">
<For each={clanList()}> <For each={clanList()}>
{(value) => ( {(value) => <ClanDetails clan_dir={value} />}
<div class="stat">
<div class="stat-figure text-primary">
<div class="join">
<button
class=" join-item btn-sm"
classList={{
"btn btn-ghost btn-outline": activeURI() !== value,
"badge badge-primary": activeURI() === value,
}}
disabled={activeURI() === value}
onClick={() => {
setActiveURI(value);
}}
>
{activeURI() === value ? "active" : "select"}
</button>
<button
class="btn btn-ghost btn-outline join-item btn-sm"
onClick={() => {
setClanList((s) =>
s.filter((v, idx) => {
if (v == value) {
setActiveURI(
clanList()[idx - 1] ||
clanList()[idx + 1] ||
null
);
return false;
}
return true;
})
);
// if (activeURI() === value) {
// setActiveURI();
// }
}}
>
Remove URI
</button>
</div>
</div>
<div class="stat-title">Clan URI</div>
<div
class="stat-desc text-lg"
classList={{
"text-primary": activeURI() === value,
}}
>
{value}
</div>
</div>
)}
</For> </For>
</div> </div>
</div> </div>