Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fixed issues related to $refs and added support for dictionaries #123

Open
wants to merge 9 commits into
base: main
Choose a base branch
from
221 changes: 193 additions & 28 deletions ipywidgets_jsonschema/form.py
Original file line number Diff line number Diff line change
Expand Up @@ -219,7 +219,9 @@ def data(self, _data):
self._form_element.setter(_data)

def _construct(self, schema, label=None, root=False):
if schema in self._construction_stack:
if schema in self._construction_stack and not (
"$ref" in schema or "enum" in schema
):
return self.construct_element()
self._construction_stack.append(schema)

Expand Down Expand Up @@ -315,27 +317,65 @@ def _construct_object(self, schema, label=None, root=False):
if "properties" in schema:
for prop, subschema in schema["properties"].items():
elements[prop] = self._construct(subschema, label=prop)
elif "additionalProperties" in schema:
# Handle dictionaries with dynamically defined keys and values
elements["dict_container"] = self._construct_dict(
schema, label=label
) # call the new function for dict

widget_list = elements[
"dict_container"
].widgets # get dict_container widgets
# Maybe wrap this in an Accordion widget
wrapped_widget_list = widget_list
if not root and label is not None:
wrapped_widget_list = self._wrap_accordion(
widget_list, schema, label=label
)

# Add conditional elements
def add_conditional_elements(s):
# Check whether we have an if statement
cond = s.get("if", None)
if cond is None:
return
def _getter():
return elements["dict_container"].getter()

for cprop, csubschema in (
s.get("then", {}).get("properties", {}).items()
):
celem = self._construct(csubschema, label=cprop)
conditionals.append((cond, cprop, celem))
elements[cprop] = celem
def _setter(_d):
elements["dict_container"].setter(_d)

def _register_observer(h, n, t):
elements["dict_container"].register_observer(h, n, t)

def _resetter():
elements["dict_container"].resetter()

return self.construct_element(
getter=_getter,
setter=_setter,
resetter=_resetter,
widgets=wrapped_widget_list,
subelements=elements,
register_observer=_register_observer,
)

else:
widget_list = []

# Handle the conditionals
def add_conditional_elements(s):
# Check whether we have an if statement
cond = s.get("if", None)
if cond is None:
return

for cprop, csubschema in s.get("then", {}).get("properties", {}).items():
celem = self._construct(csubschema, label=cprop)
conditionals.append((cond, cprop, celem))
elements[cprop] = celem

if "else" in s:
add_conditional_elements(s["else"])
if "else" in s:
add_conditional_elements(s["else"])

add_conditional_elements(schema)
add_conditional_elements(schema)

# Apply sorting to the keys
# Apply sorting to the keys
if "properties" in schema:
keys = schema["properties"].keys()
try:
keys = self.sorter(keys)
Expand All @@ -356,7 +396,11 @@ def add_conditional_elements(s):

# Maybe wrap this in an Accordion widget
wrapped_widget_list = widget_list
if not root and len(schema.get("properties", {})) > 1:
if (
not root
and len(schema.get("properties", {})) > 1
and not "additionalProperties" in schema
):
wrapped_widget_list = self._wrap_accordion(widget_list, schema, label=label)

def _getter():
Expand Down Expand Up @@ -390,11 +434,15 @@ def _register_observer(h, n, t):
if "properties" in schema:
for e in elements.values():
e.register_observer(h, n, t)
elif "additionalProperties" in schema:
elements["dict_container"].register_observer(h, n, t)

def _resetter():
if "properties" in schema:
for e in elements.values():
e.resetter()
elif "additionalProperties" in schema:
elements["dict_container"].resetter()

# Add the conditional information
if "properties" in schema:
Expand Down Expand Up @@ -443,6 +491,121 @@ def _cond_observer(_):
register_observer=_register_observer,
)

def _construct_dict(self, schema, label=None):
if "additionalProperties" not in schema:
raise FormError(
f"Expecting 'additionalProperties' key in schema for type dict: {schema}"
)

additional_props_schema = schema["additionalProperties"]

# container for the input widgets

widget = ipywidgets.VBox([])

elements = [] # list of widgets corresponding to each key

def _update_widget():
widget.children = [ipywidgets.VBox(e.widgets) for e in elements]

def add_dict_entry(key=None, value=None):
if key is None or key == "":
key = "key" + str(len(elements))

key_widget = ipywidgets.Text(value=key, description="key")

elem_dict = self._construct(additional_props_schema)

trash = ipywidgets.Button(icon="trash")

def remove_entry(_):
# Identify the current list index of the entry
for index, child in enumerate(widget.children):
if trash in child.children:
break

elements.pop(index)

# Remove it from the widget list and the handler list
_update_widget()

trash.on_click(remove_entry)

def _dict_getter():
return {key_widget.value: elem_dict.getter()}

def _dict_setter(_dict):

if key_widget.value in _dict:
elem_dict.setter(_dict[key_widget.value])
else:
elem_dict.resetter()

elements.append(
self.construct_element(
getter=_dict_getter,
setter=_dict_setter,
resetter=elem_dict.resetter,
widgets=[
ipywidgets.HBox(
[key_widget, ipywidgets.VBox(elem_dict.widgets), trash]
)
],
)
)
_update_widget()

add_btn = ipywidgets.Button(
description="Add key value",
icon="plus",
layout=ipywidgets.Layout(width="100%"),
)
add_btn.on_click(lambda x: add_dict_entry())

widget_list = [widget, add_btn]

def _getter():

data = {}
for e in elements:
data.update(e.getter())

return data

def _setter(_d):

for e in elements:
key = list(e.getter().keys())[0]
if key in _d:
e.setter(_d)
else:
e.resetter()

# check for keys that need to added
keys = [list(e.getter().keys())[0] for e in elements]
for key, value in _d.items():

if key not in keys:
add_dict_entry(key=key, value=value)

def _register_observer(h, n, t):
for e in elements:
e.register_observer(h, n, t)

def _resetter():
if "default" in schema:
_setter(schema["default"])

_resetter()

return self.construct_element(
getter=_getter,
setter=_setter,
resetter=_resetter,
widgets=widget_list,
register_observer=_register_observer,
)

def _construct_simple(self, schema, widget, label=None, root=False):
# Extract the best description that we have
tooltip = schema.get("description", None)
Expand Down Expand Up @@ -858,7 +1021,10 @@ def _construct_array(self, schema, label=None, root=False):

# Trigger whenever the resulting widget needs update
def update_widget():
subwidgets = sum((e.widgets for e in elements[:element_size]), [])
subwidgets = []
for e in elements[:element_size]:
if e.widgets:
subwidgets.extend(e.widgets)
if not fixed_length:
subwidgets.append(button)
vbox.children = subwidgets
Expand Down Expand Up @@ -917,8 +1083,8 @@ def remove_entry(b):
return

# Identify the current list index of the entry
for index, child in enumerate(vbox.children[:-1]):
if b in child.children[1].children:
for index, child in enumerate(vbox.children):
if child.children and b in child.children[1].children:
break

# Move the corresponding element to the back of the list
Expand All @@ -936,9 +1102,9 @@ def remove_entry(b):

def move(dir):
def _move(b):
items = list(vbox.children[:-1])
items = list(vbox.children)
for i, it in enumerate(items):
if b in it.children[1].children:
if it.children and b in it.children[1].children:
newi = min(max(i + dir, 0), len(items) - 1)
items[i], items[newi] = items[newi], items[i]
elements[i], elements[newi] = (
Expand All @@ -955,19 +1121,18 @@ def _move(b):
up.on_click(move(-1))
down.on_click(move(1))

array_entry_widget = ipywidgets.VBox()
children = []
if recelem.widgets and recelem.widgets[0].children:
children = [
recelem.widgets[0].children[0],
]
if recelem.widgets:
children.append(ipywidgets.VBox(recelem.widgets))
if not fixed_length:
children.append(
ipywidgets.HBox(
[trash, up, down], layout=ipywidgets.Layout(width="100%")
)
)

array_entry_widget = ipywidgets.VBox(children)
array_entry_widget.children = children

# Modify recelem to our needs
elemdict = recelem._asdict()
Expand Down
21 changes: 21 additions & 0 deletions tests/models/Dictionary.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
from pydantic import BaseModel


class DictionaryModel(BaseModel):
name: str
settings: dict[str, int]

@classmethod
def valid_cases(cls):
"""Provide valid cases for the model."""
return [{"name": "Test", "settings": {"key1": 1}}]

@classmethod
def invalid_cases(cls):
"""Provide invalid cases for the model."""
return [{"name": "Test", "settings": {"key1": "string"}}, {"name": "Test"}]

@classmethod
def default_values(cls):
"""Provide default values for the model."""
return {"name": "", "settings": {}}