diff --git a/geoh5py/ui_json/annotations.py b/geoh5py/ui_json/annotations.py index 9de8c97b0..3cc38f48f 100644 --- a/geoh5py/ui_json/annotations.py +++ b/geoh5py/ui_json/annotations.py @@ -27,7 +27,7 @@ from geoh5py.data import DataAssociationEnum, DataTypeEnum from geoh5py.groups import Group from geoh5py.objects import ObjectBase -from geoh5py.shared.utils import enum_name_to_str, stringify +from geoh5py.shared.utils import enum_name_to_str, none2str, str2none, stringify from geoh5py.shared.validators import ( to_class, to_list, @@ -80,6 +80,12 @@ def deprecate(value, info): PlainSerializer(types_to_string, when_used="json"), ] +OptionalPath = Annotated[ + Path | None, + BeforeValidator(str2none), + PlainSerializer(none2str), +] + OptionalUUID = Annotated[ UUID | None, BeforeValidator(optional_uuid_mapper), diff --git a/geoh5py/ui_json/forms.py b/geoh5py/ui_json/forms.py index 6897b3f66..c5f3d7cdc 100644 --- a/geoh5py/ui_json/forms.py +++ b/geoh5py/ui_json/forms.py @@ -90,10 +90,10 @@ class BaseForm(BaseModel): model_config = ConfigDict( extra="allow", - frozen=True, populate_by_name=True, loc_by_alias=True, alias_generator=to_camel, + validate_assignment=True, ) label: str @@ -129,8 +129,6 @@ class and lastly fall back on type checking the value field of the form. fields to avoid false positives. :param data: Form data. - :param form_types: Pre-compute all the base classes to check against. - :param indicators: Pre-compute the indicator attributes for each subclass. """ data = {to_snake(k): v for k, v in data.items()} @@ -154,6 +152,13 @@ def flatten(self): def validate_data(self, params: dict[str, Any]): """Validate the form data.""" + def set_value(self, value: Any): + """Set the form value.""" + self.value = value + + if "optional" in self.model_fields_set: + self.enabled = self.value is not None + class StringForm(BaseForm): """ @@ -556,17 +561,31 @@ def property_if_not_is_value(self): and not isinstance(self.property, UUID) # pylint: disable=unsupported-membership-test ): raise ValueError("A property must be provided if is_value is used.") + return self def flatten(self) -> UUID | float | int | None: """Returns the data for the form.""" - if ( - "is_value" in self.model_fields_set # pylint: disable=unsupported-membership-test - and not self.is_value - ): + if "is_value" in self.model_fields_set and not self.is_value: return self.property return self.value + def set_value(self, value: Any): + """Set the form value.""" + try: + self.value = value + self.is_value = True + except ValidationError: + if value is not None: + self.property = value + self.is_value = False + else: + self.is_value = True + self.property = None + + if "optional" in self.model_fields_set: + self.enabled = value is not None + class MultiSelectDataForm(DataFormMixin, BaseForm): """ diff --git a/geoh5py/ui_json/ui_json.py b/geoh5py/ui_json/ui_json.py index 17dc3bfd7..450bca890 100644 --- a/geoh5py/ui_json/ui_json.py +++ b/geoh5py/ui_json/ui_json.py @@ -23,14 +23,12 @@ import json import logging from pathlib import Path -from typing import Annotated, Any +from typing import Any from uuid import UUID from pydantic import ( BaseModel, - BeforeValidator, ConfigDict, - PlainSerializer, create_model, field_validator, ) @@ -39,24 +37,18 @@ from geoh5py.groups import PropertyGroup, UIJsonGroup from geoh5py.shared import Entity from geoh5py.shared.utils import ( + as_str_if_uuid, + dict_mapper, + entity2uuid, fetch_active_workspace, - none2str, - str2none, - str2uuid, - stringify, ) +from geoh5py.ui_json.annotations import OptionalPath from geoh5py.ui_json.forms import BaseForm from geoh5py.ui_json.validation import ErrorPool, UIJsonError, get_validations logger = logging.getLogger(__name__) -OptionalPath = Annotated[ - Path | None, - BeforeValidator(str2none), - PlainSerializer(none2str), -] - class BaseUIJson(BaseModel): """ @@ -259,7 +251,7 @@ def flatten(self, skip_disabled=False, active_only=False) -> dict[str, Any]: return data - def fill(self, copy: bool = False, **kwargs) -> BaseUIJson: + def set_values(self, copy: bool = False, **kwargs) -> BaseUIJson: """ Fill the UIJson with new values. @@ -269,38 +261,20 @@ def fill(self, copy: bool = False, **kwargs) -> BaseUIJson: :return: A new UIJson object with the updated values. """ - temp_properties = {} - for key, form in dict(self).items(): - if not isinstance(form, BaseForm): - if key in kwargs: - if not isinstance(kwargs[key], str): - raise TypeError( - "Only string values can be updated for non-form fields. " - ) - temp_properties[key] = kwargs[key] - continue - - updates: dict[str, Any] = {} - - # if a value has no default value, set enabled to false - if not bool(form.value) if form.value != [""] else False: - updates["enabled"] = False - - if key in kwargs: - updates["value"] = str2uuid(stringify(kwargs[key])) - updates["enabled"] = True - - if updates: - temp_properties[key] = form.model_copy(update=updates) - - updated_model = self.model_copy(update=temp_properties) + if copy: + uijson = self.model_copy(deep=True) + else: + uijson = self - if not copy: - for field_name in type(self).model_fields: - setattr(self, field_name, getattr(updated_model, field_name)) - return self + demotion = [entity2uuid, as_str_if_uuid] + for key, value in kwargs.items(): + form = getattr(uijson, key, None) + if isinstance(form, BaseForm): + form.set_value(value) + else: + setattr(uijson, key, dict_mapper(value, demotion)) - return updated_model + return uijson def to_params(self, workspace: Workspace | None = None) -> dict[str, Any]: """ diff --git a/recipe.yaml b/recipe.yaml index 613450daa..55952ceec 100644 --- a/recipe.yaml +++ b/recipe.yaml @@ -2,7 +2,7 @@ schema_version: 1 context: name: "geoh5py" - version: "0.0.0.dev0" # This will be replaced by the actual version in the build process + version: "0.12.1rc2.dev279+3df1d110" # This will be replaced by the actual version in the build process python_min: "3.12" module_name: ${{ name|lower|replace("-", "_") }} diff --git a/tests/ui_json/forms_test.py b/tests/ui_json/forms_test.py index b22384973..9def33ce4 100644 --- a/tests/ui_json/forms_test.py +++ b/tests/ui_json/forms_test.py @@ -97,12 +97,6 @@ def test_base_form_config_extra(sample_form): assert form.model_extra == {"extra": "stuff"} -def test_base_form_config_frozen(sample_form): - form = sample_form(label="name", value="test") - with pytest.raises(ValidationError, match="Instance is frozen"): - form.label = "new" - - def test_base_form_config_alias(sample_form): form = sample_form( label="name", @@ -515,6 +509,32 @@ def test_data_or_value_form(): property="", ) + form.set_value(None) + assert form.is_value + + optional_form = DataOrValueForm( + label="name", + value=1.0, + parent="my_param", + association="Vertex", + data_type="Float", + is_value=True, + property="", + optional=True, + ) + assert optional_form.enabled + optional_form.set_value(None) + assert optional_form.is_value + assert not optional_form.enabled + + optional_form.set_value(data_uid) + assert not optional_form.is_value + assert optional_form.property == uuid.UUID(data_uid) + assert optional_form.enabled + + form.set_value(2) + assert form.value == 2 + def test_multichoice_data_form(): data_uid_1 = str(uuid.uuid4()) diff --git a/tests/ui_json/uijson_test.py b/tests/ui_json/uijson_test.py index cd47c5ea9..82fcfc1d6 100644 --- a/tests/ui_json/uijson_test.py +++ b/tests/ui_json/uijson_test.py @@ -617,7 +617,7 @@ class MyUIJson(BaseUIJson): "my_int_parameter": {"label": "b", "value": 1}, }, ) - result = uijson.fill(my_string_parameter="updated") + result = uijson.set_values(my_string_parameter="updated") assert result is uijson assert uijson.my_string_parameter.value == "updated" @@ -635,31 +635,15 @@ class MyUIJson(BaseUIJson): uijson=MyUIJson, data={"my_string_parameter": {"label": "a", "value": "original"}}, ) - copy = uijson.fill(copy=True, my_string_parameter="updated", title="ok") + copy = uijson.set_values(copy=True, my_string_parameter="updated", title="ok") assert copy is not uijson assert copy.my_string_parameter.value == "updated" assert uijson.my_string_parameter.value == "original" assert copy.title == "ok" - with pytest.raises(TypeError, match="Only string"): - _ = uijson.fill(copy=True, my_string_parameter="updated", title=666) - - -def test_fill_disables_forms_with_falsy_value(tmp_path): - ws = Workspace(tmp_path / "test.geoh5") - - class MyUIJson(BaseUIJson): - my_zero_param: FloatForm - - uijson = generate_test_uijson( - ws, - uijson=MyUIJson, - data={"my_zero_param": {"label": "a", "value": 0.0}}, - ) - uijson.fill() - - assert uijson.my_zero_param.enabled is False + with pytest.raises(ValidationError): + _ = uijson.set_values(copy=True, my_string_parameter="updated", title=666) def test_fill_truthy_value_leaves_updates_empty(tmp_path): @@ -675,7 +659,7 @@ class MyUIJson(BaseUIJson): data={"my_param": {"label": "a", "value": 3.14}}, ) original_enabled = uijson.my_param.enabled - uijson.fill() + uijson.set_values() assert uijson.my_param.enabled == original_enabled assert uijson.my_param.value == 3.14 @@ -690,9 +674,11 @@ class MyUIJson(BaseUIJson): uijson = generate_test_uijson( ws, uijson=MyUIJson, - data={"my_param": {"label": "a", "value": 0.0, "enabled": False}}, + data={ + "my_param": {"label": "a", "value": 0.0, "enabled": False, "optional": True} + }, ) - uijson.fill(my_param=5.0) + uijson.set_values(my_param=5.0) assert uijson.my_param.enabled is True assert uijson.my_param.value == 5.0 @@ -717,7 +703,7 @@ class MyUIJson(BaseUIJson): } }, ) - uijson.fill(my_object_parameter=pts2.uid) + uijson.set_values(my_object_parameter=pts2.uid) assert uijson.my_object_parameter.value == pts2.uid