From d587b326b5d2fff1a57170cc09a7de550131f68f Mon Sep 17 00:00:00 2001 From: Johannes Kirschbauer Date: Tue, 11 Jun 2024 19:20:06 +0200 Subject: [PATCH 1/2] API: improve type & class construction --- pkgs/clan-app/clan_app/views/webview.py | 65 ++++++++++++++++++++++--- pkgs/clan-cli/clan_cli/api/util.py | 18 +++++-- 2 files changed, 73 insertions(+), 10 deletions(-) diff --git a/pkgs/clan-app/clan_app/views/webview.py b/pkgs/clan-app/clan_app/views/webview.py index 054c6303..ade90b0b 100644 --- a/pkgs/clan-app/clan_app/views/webview.py +++ b/pkgs/clan-app/clan_app/views/webview.py @@ -4,9 +4,11 @@ import logging import sys import threading from collections.abc import Callable +from dataclasses import fields, is_dataclass from pathlib import Path from threading import Lock -from typing import Any +from types import UnionType +from typing import Any, get_args import gi from clan_cli.api import API @@ -37,7 +39,7 @@ def dataclass_to_dict(obj: Any) -> Any: elif isinstance(obj, dict): return {k: dataclass_to_dict(v) for k, v in obj.items()} else: - return obj + return str(obj) # Implement the abstract open_file function @@ -135,6 +137,58 @@ def open_file(file_request: FileRequest) -> str | None: return selected_path +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 from_dict(t: type, data: dict[str, Any] | None) -> Any: + """ + Dynamically instantiate a data class from a dictionary, handling nested data classes. + """ + if not data: + return None + + try: + # Attempt to create an instance of the data_class + field_values = {} + for field in fields(t): + field_value = data.get(field.name) + field_type = get_inner_type(field.type) + if field_value is not None: + # 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 + ) + + if ( + field.default is not dataclasses.MISSING + or field.default_factory is not dataclasses.MISSING + ): + # Field has a default value. We cannot set the value to None + if field_value is not None: + field_values[field.name] = field_value + else: + field_values[field.name] = field_value + + return t(**field_values) + + except (TypeError, ValueError) as e: + print(f"Failed to instantiate {t.__name__}: {e}") + return None + + class WebView: def __init__(self, methods: dict[str, Callable]) -> None: self.method_registry: dict[str, Callable] = methods @@ -216,14 +270,13 @@ class WebView: # 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) + arg_class = API.get_method_argtype(method_name, k) + if dataclasses.is_dataclass(arg_class): + reconciled_arguments[k] = from_dict(arg_class, 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/clan-cli/clan_cli/api/util.py b/pkgs/clan-cli/clan_cli/api/util.py index d356b28a..84f96691 100644 --- a/pkgs/clan-cli/clan_cli/api/util.py +++ b/pkgs/clan-cli/clan_cli/api/util.py @@ -1,6 +1,7 @@ import copy import dataclasses import pathlib +from dataclasses import MISSING from types import NoneType, UnionType from typing import ( Annotated, @@ -77,20 +78,29 @@ def type_to_dict(t: Any, scope: str = "", type_map: dict[TypeVar, type] = {}) -> for f in fields } - required = [] + required = set() for pn, pv in properties.items(): if pv.get("type") is not None: if "null" not in pv["type"]: - required.append(pn) + required.add(pn) elif pv.get("oneOf") is not None: if "null" not in [i["type"] for i in pv.get("oneOf", [])]: - required.append(pn) + required.add(pn) + + required_fields = { + f.name + for f in fields + if f.default is MISSING and f.default_factory is MISSING + } + + # Find intersection + intersection = required & required_fields return { "type": "object", "properties": properties, - "required": required, + "required": list(intersection), # Dataclasses can only have the specified properties "additionalProperties": False, } From 48aee84547f09248904311265e9520389058a18a Mon Sep 17 00:00:00 2001 From: Johannes Kirschbauer Date: Tue, 11 Jun 2024 19:20:28 +0200 Subject: [PATCH 2/2] Webview: add form handling for create clan --- pkgs/clan-cli/clan_cli/clan/create.py | 4 +- pkgs/clan-cli/clan_cli/clan/update.py | 35 +++++ pkgs/webview-ui/app/eslint.config.mjs | 3 + pkgs/webview-ui/app/package-lock.json | 43 ++++++ pkgs/webview-ui/app/package.json | 2 + .../app/src/routes/clan/clanDetails.tsx | 122 +++++++++++++++++- pkgs/webview-ui/flake-module.nix | 2 +- 7 files changed, 202 insertions(+), 9 deletions(-) create mode 100644 pkgs/clan-cli/clan_cli/clan/update.py diff --git a/pkgs/clan-cli/clan_cli/clan/create.py b/pkgs/clan-cli/clan_cli/clan/create.py index 2b2f244a..e198ffa0 100644 --- a/pkgs/clan-cli/clan_cli/clan/create.py +++ b/pkgs/clan-cli/clan_cli/clan/create.py @@ -33,7 +33,7 @@ class ClanMetaInfo: @dataclass class CreateOptions: - directory: Path + directory: Path | str # Metadata for the clan # Metadata can be shown with `clan show` meta: ClanMetaInfo | None = None @@ -43,7 +43,7 @@ class CreateOptions: @API.register def create_clan(options: CreateOptions) -> CreateClanResponse: - directory = options.directory + directory = Path(options.directory) template_url = options.template_url if not directory.exists(): directory.mkdir() diff --git a/pkgs/clan-cli/clan_cli/clan/update.py b/pkgs/clan-cli/clan_cli/clan/update.py new file mode 100644 index 00000000..4dcc33d7 --- /dev/null +++ b/pkgs/clan-cli/clan_cli/clan/update.py @@ -0,0 +1,35 @@ +import json +from dataclasses import dataclass +from pathlib import Path + +from clan_cli.api import API +from clan_cli.clan.create import ClanMetaInfo +from clan_cli.errors import ClanError + + +@dataclass +class UpdateOptions: + directory: str + meta: ClanMetaInfo | None = None + + +@API.register +def update_clan_meta(options: UpdateOptions) -> ClanMetaInfo: + meta_file = Path(options.directory) / Path("clan/meta.json") + if not meta_file.exists(): + raise ClanError( + "File not found", + description=f"Could not find {meta_file} to update.", + location="update_clan_meta", + ) + + meta_content: dict[str, str] = {} + with open(meta_file) as f: + meta_content = json.load(f) + + meta_content = {**meta_content, **options.meta.__dict__} + + with open(meta_file) as f: + json.dump(meta_content, f) + + return ClanMetaInfo(**meta_content) diff --git a/pkgs/webview-ui/app/eslint.config.mjs b/pkgs/webview-ui/app/eslint.config.mjs index 68dc5215..8a408bee 100644 --- a/pkgs/webview-ui/app/eslint.config.mjs +++ b/pkgs/webview-ui/app/eslint.config.mjs @@ -22,6 +22,9 @@ export default tseslint.config( whitelist: ["material-icons"], }, ], + // TODO: make this more strict by removing later + "no-unused-vars": "off", + "@typescript-eslint/no-unused-vars": "off", }, } ); diff --git a/pkgs/webview-ui/app/package-lock.json b/pkgs/webview-ui/app/package-lock.json index d33a88df..c1bef99a 100644 --- a/pkgs/webview-ui/app/package-lock.json +++ b/pkgs/webview-ui/app/package-lock.json @@ -9,6 +9,8 @@ "version": "0.0.1", "license": "MIT", "dependencies": { + "@modular-forms/solid": "^0.21.0", + "@tanstack/solid-query": "^5.44.0", "material-icons": "^1.13.12", "solid-js": "^1.8.11", "solid-toast": "^0.5.0" @@ -1004,6 +1006,17 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@modular-forms/solid": { + "version": "0.21.0", + "resolved": "https://registry.npmjs.org/@modular-forms/solid/-/solid-0.21.0.tgz", + "integrity": "sha512-9QUykslNeVvn/gBj4iXoiNPV1UutS8sOCPGoqBxCNBwVQVjfeMljvXU3kZtctWEMy984q7JtKVVGSOXMDydW6A==", + "dependencies": { + "valibot": ">=0.31.0 <1" + }, + "peerDependencies": { + "solid-js": "^1.3.1" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -1535,6 +1548,31 @@ "node": ">=4" } }, + "node_modules/@tanstack/query-core": { + "version": "5.44.0", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.44.0.tgz", + "integrity": "sha512-Fa1J7iEEyJnjXG1N4+Fz4OXNH/INve3XSn0Htms3X4wgRsXHxMDwqBE2XtDCts7swkwSIs4izEtaFqWVFr/eLQ==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/solid-query": { + "version": "5.44.0", + "resolved": "https://registry.npmjs.org/@tanstack/solid-query/-/solid-query-5.44.0.tgz", + "integrity": "sha512-fxsSFGbaHknL1zuymEZqrSRFk0wI3/RVDInlQJS3jZevnKJBAlDohIis6MZdm3EOFRTq+sfaMTNXZTu2B5MpOw==", + "dependencies": { + "@tanstack/query-core": "5.44.0", + "solid-js": "^1.8.17" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "solid-js": "^1.6.0" + } + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -5819,6 +5857,11 @@ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "dev": true }, + "node_modules/valibot": { + "version": "0.31.1", + "resolved": "https://registry.npmjs.org/valibot/-/valibot-0.31.1.tgz", + "integrity": "sha512-2YYIhPrnVSz/gfT2/iXVTrSj92HwchCt9Cga/6hX4B26iCz9zkIsGTS0HjDYTZfTi1Un0X6aRvhBi1cfqs/i0Q==" + }, "node_modules/validate-html-nesting": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/validate-html-nesting/-/validate-html-nesting-1.2.2.tgz", diff --git a/pkgs/webview-ui/app/package.json b/pkgs/webview-ui/app/package.json index 14e6c399..9bf4f256 100644 --- a/pkgs/webview-ui/app/package.json +++ b/pkgs/webview-ui/app/package.json @@ -37,6 +37,8 @@ "vitest": "^1.6.0" }, "dependencies": { + "@modular-forms/solid": "^0.21.0", + "@tanstack/solid-query": "^5.44.0", "material-icons": "^1.13.12", "solid-js": "^1.8.11", "solid-toast": "^0.5.0" diff --git a/pkgs/webview-ui/app/src/routes/clan/clanDetails.tsx b/pkgs/webview-ui/app/src/routes/clan/clanDetails.tsx index 50746c1b..beac427b 100644 --- a/pkgs/webview-ui/app/src/routes/clan/clanDetails.tsx +++ b/pkgs/webview-ui/app/src/routes/clan/clanDetails.tsx @@ -8,7 +8,12 @@ import { createEffect, createSignal, } from "solid-js"; -import cx from "classnames"; +import { + SubmitHandler, + createForm, + email, + required, +} from "@modular-forms/solid"; interface ClanDetailsProps { directory: string; @@ -23,9 +28,113 @@ interface MetaFieldsProps { const fn = (e: SubmitEvent) => { e.preventDefault(); - console.log(e.currentTarget); + console.log("form submit", e.currentTarget); }; +export default function Login() { + const [, { Form, Field }] = createForm({ + initialValues: { name: "MyClan" }, + }); + + const handleSubmit: SubmitHandler = (values, event) => { + pyApi.open_file.dispatch({ file_request: { mode: "save" } }); + pyApi.open_file.receive((r) => { + if (r.status === "success") { + if (r.data) { + pyApi.create_clan.dispatch({ + options: { directory: r.data, meta: values }, + }); + } + + return; + } + }); + console.log("submit", values); + }; + return ( +
+
+ + {(field, props) => ( + + )} + + + {(field, props) => ( + + )} + + + {(field, props) => ( + + )} + +
+ +
+
+
+ ); +} + export const EditMetaFields = (props: MetaFieldsProps) => { const { meta, editable, actions, directory } = props; @@ -41,8 +150,9 @@ export const EditMetaFields = (props: MetaFieldsProps) => { />
-
-

+ + {/* */} + {/*

{ {directory}

- {actions} - + {actions} */} + {/* */} ); diff --git a/pkgs/webview-ui/flake-module.nix b/pkgs/webview-ui/flake-module.nix index dc3e9212..0adbd223 100644 --- a/pkgs/webview-ui/flake-module.nix +++ b/pkgs/webview-ui/flake-module.nix @@ -11,7 +11,7 @@ npmDeps = pkgs.fetchNpmDeps { src = ./app; - hash = "sha256-NauXhPRKC80yiAeyt5mEBLcPZw3aheZDYXx4vLvpRvI="; + hash = "sha256-3LjcHh+jCuarh9XmS+mOv7xaGgAHxf3L7fWnxxmxUGQ="; }; # The prepack script runs the build script, which we'd rather do in the build phase. npmPackFlags = [ "--ignore-scripts" ];