Skip to content

Commit f76711a

Browse files
authored
Merge pull request #18 from LaSi5002/feature/input-validation-general
feature/input validation general
2 parents 2b618a5 + ca61a6b commit f76711a

File tree

3 files changed

+194
-8
lines changed

3 files changed

+194
-8
lines changed

src/fourc_webviewer/fourc_webserver.py

Lines changed: 48 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,9 @@
99

1010
import numpy as np
1111
import pyvista as pv
12+
import yaml
1213
from fourcipp import CONFIG
13-
from fourcipp.fourc_input import FourCInput
14+
from fourcipp.fourc_input import FourCInput, ValidationError
1415
from trame.app import get_server
1516
from trame.decorators import TrameApp, change, controller
1617

@@ -27,7 +28,14 @@
2728
read_fourc_yaml_file,
2829
write_fourc_yaml_file,
2930
)
30-
from fourc_webviewer.python_utils import convert_string2number, find_value_recursively
31+
from fourc_webviewer.python_utils import (
32+
convert_string2number,
33+
dict_leaves_to_number_if_schema,
34+
dict_number_leaves_to_string,
35+
find_value_recursively,
36+
parse_validation_error_text,
37+
smart_string2number_cast,
38+
)
3139
from fourc_webviewer.read_geometry_from_file import (
3240
FourCGeometry,
3341
)
@@ -73,6 +81,9 @@ def __init__(
7381
# create temporary directory
7482
self._server_vars["temp_dir_object"] = tempfile.TemporaryDirectory()
7583

84+
# Register on_field_blur function, which is called when the user leaves a field
85+
self.server.controller.on_leave_edit_field = self.on_leave_edit_field
86+
7687
# initialize state variables for the different modes and
7788
# statuses of the client (e.g. view mode versus edit mode,
7889
# read-in and export status, ...)
@@ -158,6 +169,9 @@ def init_state_and_server_vars(self):
158169
Path(self._server_vars["temp_dir_object"].name)
159170
/ f"new_{self.state.fourc_yaml_file['name']}"
160171
)
172+
# dict to store input errors for the input validation
173+
# imitates structure of self.state.general_sections
174+
self.state.input_error_dict = {}
161175

162176
# get state variables of the general sections
163177
self.init_general_sections_state_and_server_vars()
@@ -923,7 +937,6 @@ def change_fourc_yaml_file(self, fourc_yaml_file, **kwargs):
923937
self._server_vars["fourc_yaml_last_modified"],
924938
self._server_vars["fourc_yaml_read_in_status"],
925939
) = read_fourc_yaml_file(temp_fourc_yaml_file)
926-
927940
self._server_vars["fourc_yaml_name"] = Path(temp_fourc_yaml_file).name
928941

929942
# set vtu file path empty to make the convert button visible
@@ -951,6 +964,11 @@ def change_selected_main_section_name(self, selected_main_section_name, **kwargs
951964
selected_main_section_name
952965
]["subsections"][0]
953966

967+
@change("selected_section_name")
968+
def change_selected_section_name(self, selected_section_name, **kwargs):
969+
"""Reaction to change of state.selected_section_name."""
970+
self.state.selected_subsection_name = selected_section_name.split("/")[-1]
971+
954972
@change("selected_material")
955973
def change_selected_material(self, selected_material, **kwargs):
956974
"""Reaction to change of state.selected_material."""
@@ -1175,6 +1193,33 @@ def click_save_button(self, **kwargs):
11751193
else:
11761194
self.state.export_status = self.state.all_export_statuses["error"]
11771195

1196+
@change("general_sections")
1197+
def on_sections_change(self, general_sections, **kwargs):
1198+
"""Reaction to change of state.general_sections."""
1199+
1200+
self.sync_server_vars_from_state()
1201+
try:
1202+
fourcinput = FourCInput(self._server_vars["fourc_yaml_content"])
1203+
1204+
dict_leaves_to_number_if_schema(fourcinput._sections)
1205+
1206+
fourcinput.validate()
1207+
self.state.input_error_dict = {}
1208+
except ValidationError as exc:
1209+
self.state.input_error_dict = parse_validation_error_text(
1210+
str(exc.args[0])
1211+
) # exc.args[0] is the error message
1212+
return False
1213+
1214+
def on_leave_edit_field(self):
1215+
"""Reaction to user leaving the field.
1216+
1217+
Currently only supported for the general sections.
1218+
"""
1219+
# also gets called when a new file is loaded
1220+
# basically just sets the state based on server_vars
1221+
self.init_general_sections_state_and_server_vars()
1222+
11781223
""" --- Other helper functions"""
11791224

11801225
def convert_string2num_all_sections(self):

src/fourc_webviewer/gui_utils.py

Lines changed: 62 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -576,8 +576,9 @@ def _functions_panel(server):
576576
)
577577

578578

579-
def _prop_value_table():
579+
def _prop_value_table(server):
580580
"""Table (property - value) layout (for general sections)."""
581+
581582
with vuetify.VTable(
582583
v_if=(
583584
"section_names[selected_main_section_name]['content_mode'] == all_content_modes['general_section']",
@@ -626,14 +627,71 @@ def _prop_value_table():
626627
v_if="edit_mode == all_edit_modes['edit_mode']",
627628
classes="text-center w-50",
628629
):
630+
item_error = "input_error_dict[selected_main_section_name]?.[item_key] || input_error_dict[selected_main_section_name + '~1' + selected_subsection_name]?.[item_key]"
631+
# if item is a string, number or integer -> use VTextField
629632
vuetify.VTextField(
630633
v_model=(
631634
"general_sections[selected_main_section_name][selected_section_name][item_key]", # binding item_val directly does not work, since Object.entries(...) creates copies for the mutable objects
632635
),
636+
v_if=(
637+
"(json_schema['properties']?.[selected_section_name]?.['properties']?.[item_key]?.['type'] == 'string' "
638+
"|| json_schema['properties']?.[selected_section_name]?.['properties']?.[item_key]?.['type'] == 'number' "
639+
"|| json_schema['properties']?.[selected_section_name]?.['properties']?.[item_key]?.['type'] == 'integer')"
640+
"&& !json_schema['properties']?.[selected_section_name]?.['properties']?.[item_key]?.['enum']"
641+
),
642+
blur=server.controller.on_leave_edit_field,
633643
update_modelValue="flushState('general_sections')", # this is required in order to flush the state changes correctly to the server, as our passed on v-model is a nested variable
634-
classes="w-80",
644+
classes="w-80 pb-1",
635645
dense=True,
636-
hide_details=True,
646+
color=f"{item_error} && error",
647+
bg_color=(f"{item_error} ? 'rgba(255, 0, 0, 0.2)' : ''",),
648+
error_messages=(
649+
f"{item_error}?.length > 100 ? {item_error}?.slice(0, 97)+' ...' : {item_error}",
650+
),
651+
)
652+
# if item is a boolean -> use VSwitch
653+
with html.Div(
654+
v_if=(
655+
"json_schema['properties']?.[selected_section_name]?.['properties']?.[item_key]?.['type'] === 'boolean'"
656+
),
657+
classes="d-flex align-center justify-center",
658+
):
659+
vuetify.VSwitch(
660+
v_model=(
661+
"general_sections[selected_main_section_name][selected_section_name][item_key]"
662+
),
663+
classes="mt-4",
664+
update_modelValue="flushState('general_sections')",
665+
class_="mx-100",
666+
dense=True,
667+
color="primary",
668+
)
669+
# if item is an enum -> use VAutocomplete
670+
(
671+
vuetify.VAutocomplete(
672+
v_model=(
673+
"general_sections[selected_main_section_name]"
674+
"[selected_section_name][item_key]"
675+
),
676+
v_if=(
677+
"json_schema['properties']?.[selected_section_name]"
678+
"?.['properties']?.[item_key]?.['enum']"
679+
),
680+
update_modelValue="flushState('general_sections')",
681+
# bind the enum array as items
682+
items=(
683+
"json_schema['properties'][selected_section_name]['properties'][item_key]['enum']",
684+
),
685+
dense=True,
686+
solo=True,
687+
filterable=True,
688+
classes="w-80 pb-1",
689+
color=f"{item_error} && error",
690+
bg_color=(f"{item_error} ? 'rgba(255, 0, 0, 0.2)' : ''",),
691+
error_messages=(
692+
f"{item_error}?.length > 100 ? {item_error}?.slice(0, 97)+' ...' : {item_error}",
693+
),
694+
),
637695
)
638696

639697

@@ -1259,7 +1317,7 @@ def create_gui(server, render_window):
12591317

12601318
# Further elements with conditional rendering (see above)
12611319
_sections_dropdown()
1262-
_prop_value_table()
1320+
_prop_value_table(server)
12631321
_materials_panel()
12641322
_functions_panel(server)
12651323
_design_conditions_panel()

src/fourc_webviewer/python_utils.py

Lines changed: 84 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
"""Module for python utils."""
22

3+
import re
4+
5+
from fourcipp import CONFIG
6+
37

48
def flatten_list(input_list):
59
"""Flattens a given (multi-level) list into a single list.
@@ -48,6 +52,82 @@ def find_value_recursively(input_dict, target_key):
4852
return None
4953

5054

55+
def get_by_path(dct, path):
56+
"""Retrieve the value at the nested path from dct.
57+
58+
Raises KeyError if any key is missing.
59+
"""
60+
current = dct
61+
for key in path:
62+
current = current[key]
63+
return current
64+
65+
66+
def dict_leaves_to_number_if_schema(value, schema_path=[]):
67+
"""Convert all leaves of a dict to numbers if possible."""
68+
if isinstance(value, dict):
69+
for k, v in value.items():
70+
value[k] = dict_leaves_to_number_if_schema(
71+
v, schema_path + ["properties", k]
72+
)
73+
return value
74+
if isinstance(value, str) and get_by_path(
75+
CONFIG.fourc_json_schema, schema_path + ["type"]
76+
) in ["number", "integer"]:
77+
return smart_string2number_cast(value)
78+
return value
79+
80+
81+
def dict_number_leaves_to_string(value):
82+
"""Convert all leaves of a dict to numbers if possible."""
83+
if isinstance(value, bool):
84+
return value # isinstance(True, int) is True
85+
if isinstance(value, dict):
86+
for k, v in value.items():
87+
value[k] = dict_number_leaves_to_string(v)
88+
return value
89+
if isinstance(value, int) or isinstance(value, float):
90+
return str(value)
91+
return value
92+
93+
94+
def parse_validation_error_text(text):
95+
"""Parse a ValidationError message string (with multiple "- Parameter in
96+
[...]" blocks) into a nested dict.
97+
98+
Args:
99+
text (str): <fill in your definition>
100+
Returns:
101+
dict: <fill in your definition>
102+
"""
103+
error_dict = {}
104+
# Match "- Parameter in [...]" blocks up until the next one or end of string
105+
block_re = re.compile(
106+
r"- Parameter in (?P<path>(?:\[[^\]]+\])+)\n"
107+
r"(?P<body>.*?)(?=(?:- Parameter in )|\Z)",
108+
re.DOTALL,
109+
)
110+
for m in block_re.finditer(text):
111+
path_str = m.group("path")
112+
body = m.group("body")
113+
114+
# extract the Error: line
115+
err_m = re.search(r"Error:\s*(.+)", body)
116+
if not err_m:
117+
continue
118+
err_msg = err_m.group(1).strip()
119+
120+
keys = re.findall(r'\["([^"]+)"\]', path_str)
121+
122+
# walk/create nested dicts, then assign the message at the leaf
123+
cur = error_dict
124+
for key in keys[:-1]:
125+
cur = cur.setdefault(key, {})
126+
cur[keys[-1]] = err_msg
127+
128+
return error_dict
129+
130+
51131
def smart_string2number_cast(input_string):
52132
"""Casts an input_string to float / int if possible. Helpful when dealing
53133
with automatic to-string conversions from vuetify.VTextField input
@@ -56,8 +136,11 @@ def smart_string2number_cast(input_string):
56136
Args:
57137
input_string (str): input string to be converted.
58138
Returns:
59-
int | float | str: converted value.
139+
int | float | str | object: converted value.
60140
"""
141+
# otherwise boolean values are converted to 0/1
142+
if not isinstance(input_string, str):
143+
return input_string
61144
try:
62145
# first convert to float
63146
input_float = float(input_string)

0 commit comments

Comments
 (0)