1
0
forked from clan/clan-core

Merge pull request 'API: improve type & class construction' (#1610) from hsjobeki/clan-core:hsjobeki-main into main

This commit is contained in:
clan-bot 2024-06-11 17:23:42 +00:00
commit 175b219246
9 changed files with 275 additions and 19 deletions

View File

@ -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

View File

@ -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,
}

View File

@ -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()

View File

@ -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)

View File

@ -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",
},
}
);

View File

@ -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",

View File

@ -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"

View File

@ -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<ClanMeta>({
initialValues: { name: "MyClan" },
});
const handleSubmit: SubmitHandler<ClanMeta> = (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 (
<div class="card-body">
<Form onSubmit={handleSubmit}>
<Field
name="name"
validate={[required("Please enter a unique name for the clan.")]}
>
{(field, props) => (
<label class="form-control w-full max-w-xs">
<div class="label">
<span class="label-text block after:ml-0.5 after:text-primary after:content-['*']">
Name
</span>
</div>
<input
{...props}
type="email"
required
placeholder="your.mail@example.com"
class="input w-full max-w-xs"
classList={{ "input-error": !!field.error }}
value={field.value}
/>
<div class="label">
{field.error && (
<span class="label-text-alt">{field.error}</span>
)}
</div>
</label>
)}
</Field>
<Field name="description">
{(field, props) => (
<label class="form-control w-full max-w-xs">
<div class="label">
<span class="label-text">Description</span>
</div>
<input
{...props}
required
type="text"
placeholder="Some words about your clan"
class="input w-full max-w-xs"
classList={{ "input-error": !!field.error }}
value={field.value || ""}
/>
<div class="label">
{field.error && (
<span class="label-text-alt">{field.error}</span>
)}
</div>
</label>
)}
</Field>
<Field name="icon">
{(field, props) => (
<label class="form-control w-full max-w-xs">
<div class="label">
<span class="label-text">Select icon</span>
</div>
<input
type="file"
class="file-input file-input-bordered w-full max-w-xs"
/>
<div class="label">
{field.error && (
<span class="label-text-alt">{field.error}</span>
)}
</div>
</label>
)}
</Field>
<div class="card-actions justify-end">
<button class="btn btn-primary" type="submit">
Create
</button>
</div>
</Form>
</div>
);
}
export const EditMetaFields = (props: MetaFieldsProps) => {
const { meta, editable, actions, directory } = props;
@ -41,8 +150,9 @@ export const EditMetaFields = (props: MetaFieldsProps) => {
/>
</figure>
<div class="card-body">
<form onSubmit={fn}>
<h2 class="card-title justify-between">
<Login />
{/* <form onSubmit={fn}> */}
{/* <h2 class="card-title justify-between">
<input
classList={{
[cx("text-slate-600")]: editing() !== "name",
@ -85,8 +195,8 @@ export const EditMetaFields = (props: MetaFieldsProps) => {
<span>{directory}</span>
</div>
</Show>
{actions}
</form>
{actions} */}
{/* </form> */}
</div>
</div>
);

View File

@ -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" ];