From 1d84b829d1c43544d356c3afef9752e22446b4d9 Mon Sep 17 00:00:00 2001 From: domfournier Date: Sat, 21 Mar 2026 08:22:50 -0700 Subject: [PATCH 01/45] Rename class BaseUIJson -> UIJson --- geoh5py/ui_json/__init__.py | 2 +- geoh5py/ui_json/ui_json.py | 10 +++++----- tests/ui_json/forms_test.py | 4 ++-- tests/ui_json/uijson_test.py | 28 ++++++++++++++-------------- 4 files changed, 22 insertions(+), 22 deletions(-) diff --git a/geoh5py/ui_json/__init__.py b/geoh5py/ui_json/__init__.py index b6a441266..785e95913 100644 --- a/geoh5py/ui_json/__init__.py +++ b/geoh5py/ui_json/__init__.py @@ -27,4 +27,4 @@ from .utils import monitored_directory_copy from .validation import InputValidation from .forms import BaseForm -from .ui_json import BaseUIJson +from .ui_json import UIJson diff --git a/geoh5py/ui_json/ui_json.py b/geoh5py/ui_json/ui_json.py index 776097b0c..a3c2c5d75 100644 --- a/geoh5py/ui_json/ui_json.py +++ b/geoh5py/ui_json/ui_json.py @@ -54,7 +54,7 @@ ] -class BaseUIJson(BaseModel): +class UIJson(BaseModel): """ Base class for storing ui.json data on disk. @@ -117,7 +117,7 @@ def valid_geoh5_extension(cls, path: Path): return path @classmethod - def read(cls, path: str | Path) -> BaseUIJson: + def read(cls, path: str | Path) -> UIJson: """ Create a UIJson object from ui.json file. @@ -144,10 +144,10 @@ def read(cls, path: str | Path) -> BaseUIJson: with open(path, encoding="utf-8") as file: kwargs = json.load(file) - if cls == BaseUIJson: + if cls == UIJson: fields = {} for name, value in kwargs.items(): - if name in BaseUIJson.model_fields: + if name in UIJson.model_fields: continue if isinstance(value, dict): form_type = BaseForm.infer(value) @@ -160,7 +160,7 @@ def read(cls, path: str | Path) -> BaseUIJson: model = create_model( # type: ignore "UnknownUIJson", - __base__=BaseUIJson, + __base__=UIJson, **fields, ) uijson = model(**kwargs) diff --git a/tests/ui_json/forms_test.py b/tests/ui_json/forms_test.py index fae8010f7..ffe04b8bd 100644 --- a/tests/ui_json/forms_test.py +++ b/tests/ui_json/forms_test.py @@ -54,11 +54,11 @@ all_subclasses, indicator_attributes, ) -from geoh5py.ui_json.ui_json import BaseUIJson +from geoh5py.ui_json.ui_json import UIJson def setup_from_uijson(workspace, form): - class MyUIJson(BaseUIJson): + class MyUIJson(UIJson): my_param: type(form) uijson = MyUIJson.model_construct( diff --git a/tests/ui_json/uijson_test.py b/tests/ui_json/uijson_test.py index d8766bad0..46d92fad9 100644 --- a/tests/ui_json/uijson_test.py +++ b/tests/ui_json/uijson_test.py @@ -41,7 +41,7 @@ RadioLabelForm, StringForm, ) -from geoh5py.ui_json.ui_json import BaseUIJson +from geoh5py.ui_json.ui_json import UIJson from geoh5py.ui_json.validations import UIJsonError @@ -129,7 +129,7 @@ def sample_uijson(tmp_path): def test_uijson(sample_uijson): - class MyUIJson(BaseUIJson): + class MyUIJson(UIJson): my_string_parameter: StringForm my_integer_parameter: IntegerForm my_object_parameter: ObjectForm @@ -165,7 +165,7 @@ def generate_test_uijson(workspace: Workspace, uijson, data: dict): def test_allow_extra(tmp_path): ws = Workspace(tmp_path / "test.geoh5") - class MyUIJson(BaseUIJson): + class MyUIJson(UIJson): my_string_parameter: StringForm kwargs = { @@ -190,7 +190,7 @@ def test_multiple_validations(tmp_path): other_pts = pts.copy(name="other test") data = pts.add_data({"my_data": {"values": np.random.randn(10)}}) - class MyUIJson(BaseUIJson): + class MyUIJson(UIJson): my_object_parameter: ObjectForm my_other_object_parameter: ObjectForm my_data_parameter: DataForm @@ -237,7 +237,7 @@ def test_validate_dependency_type_validation(tmp_path): ws = Workspace(tmp_path / "test.geoh5") # BoolForm dependency is valid - class MyUIJson(BaseUIJson): + class MyUIJson(UIJson): my_parameter: BoolForm my_dependent_parameter: StringForm @@ -257,7 +257,7 @@ class MyUIJson(BaseUIJson): assert params["my_dependent_parameter"] == "test" # Optional non-bool dependency is valid - class MyUIJson(BaseUIJson): + class MyUIJson(UIJson): my_parameter: StringForm my_dependent_parameter: StringForm @@ -281,7 +281,7 @@ def test_parent_child_validation(tmp_path): data = pts.add_data({"my_data": {"values": np.random.randn(10)}}) other_pts = pts.copy(name="other test") - class MyUIJson(BaseUIJson): + class MyUIJson(UIJson): my_object_parameter: ObjectForm my_data_parameter: DataForm @@ -318,7 +318,7 @@ def test_mesh_type_validation(tmp_path): ws = Workspace(tmp_path / "test.geoh5") pts = Points.create(ws, name="test", vertices=np.random.random((10, 3))) - class MyUIJson(BaseUIJson): + class MyUIJson(UIJson): my_object_parameter: ObjectForm kwargs = { @@ -344,7 +344,7 @@ class MyUIJson(BaseUIJson): def test_deprecated_annotation(tmp_path, caplog): geoh5 = Workspace(tmp_path / "test.geoh5") - class MyUIJson(BaseUIJson): + class MyUIJson(UIJson): my_parameter: Deprecated with caplog.at_level(logging.WARNING): @@ -362,7 +362,7 @@ class MyUIJson(BaseUIJson): def test_grouped_forms(tmp_path): - class MyUIJson(BaseUIJson): + class MyUIJson(UIJson): my_param: IntegerForm my_grouped_param: FloatForm my_other_grouped_param: FloatForm @@ -394,7 +394,7 @@ class MyUIJson(BaseUIJson): def test_disabled_forms(tmp_path): - class MyUIJson(BaseUIJson): + class MyUIJson(UIJson): my_param: IntegerForm my_other_param: IntegerForm my_grouped_param: FloatForm @@ -527,7 +527,7 @@ def test_unknown_uijson(tmp_path): } with open(tmp_path / "test.ui.json", mode="w", encoding="utf8") as file: file.write(json.dumps(kwargs)) - uijson = BaseUIJson.read(tmp_path / "test.ui.json") + uijson = UIJson.read(tmp_path / "test.ui.json") uijson.write(tmp_path / "test_copy.ui.json") assert isinstance(uijson.my_string_parameter, StringForm) @@ -550,7 +550,7 @@ def test_unknown_uijson(tmp_path): def test_str_and_repr(tmp_path): Workspace.create(tmp_path / "test.geoh5") - class MyUIJson(BaseUIJson): + class MyUIJson(UIJson): param: StringForm uijson = MyUIJson( @@ -580,7 +580,7 @@ def test_geoh5_validate_extension(tmp_path): h5file.touch() with pytest.raises(ValidationError, match="must have a '.geoh5' file extension."): - _ = BaseUIJson( + _ = UIJson( version="0.1.0", title="my application", geoh5=str(h5file), From b62fc6b233b26dc04d45826406dab211a31a89b7 Mon Sep 17 00:00:00 2001 From: domfournier Date: Sat, 21 Mar 2026 09:12:36 -0700 Subject: [PATCH 02/45] MOve validation. Rename method --- geoh5py/ui_json/ui_json.py | 47 +++++-------------------- geoh5py/ui_json/validations/__init__.py | 1 + geoh5py/ui_json/validations/uijson.py | 28 ++++++++++++++- 3 files changed, 37 insertions(+), 39 deletions(-) diff --git a/geoh5py/ui_json/ui_json.py b/geoh5py/ui_json/ui_json.py index a3c2c5d75..f1f01441d 100644 --- a/geoh5py/ui_json/ui_json.py +++ b/geoh5py/ui_json/ui_json.py @@ -36,12 +36,10 @@ ) from geoh5py import Workspace -from geoh5py.groups import PropertyGroup -from geoh5py.shared import Entity from geoh5py.shared.utils import fetch_active_workspace from geoh5py.shared.validators import none_to_empty_string from geoh5py.ui_json.forms import BaseForm -from geoh5py.ui_json.validations import ErrorPool, UIJsonError, get_validations +from geoh5py.ui_json.validations import ErrorPool, UIJsonError, get_validations, object_or_catch from geoh5py.ui_json.validations.form import empty_string_to_none @@ -274,24 +272,24 @@ def to_params(self, workspace: Workspace | None = None) -> dict[str, Any]: continue if isinstance(value, UUID): - value = self._object_or_catch(geoh5, value) + value = object_or_catch(geoh5, value) if isinstance(value, list) and value and isinstance(value[0], UUID): - value = [self._object_or_catch(geoh5, uid) for uid in value] + value = [object_or_catch(geoh5, uid) for uid in value] if isinstance(value, UIJsonError): errors[field].append(value) data[field] = value - self.validate_data(data, errors) + self._cross_validations(data, errors) return data - def validate_data( - self, params: dict[str, Any] | None = None, errors: dict[str, Any] | None = None + def _cross_validations( + self, params: dict[str, Any], errors: dict[str, Any] | None = None ) -> None: """ - Validate the UIJson data. + Extra validation related to inter-form dependencies and entity types. :param params: Promoted and flattened parameters/values dictionary. The params dictionary will be generated from the model values if not provided. @@ -300,19 +298,14 @@ def validate_data( :raises UIJsonError: If any validations fail. """ - - if params is None: - self.to_params() - return - if errors is None: errors = {k: [] for k in params} ui_json = self.model_dump(exclude_unset=True) - for field in self.model_fields_set: + for field, form in ui_json.items(): if self.is_disabled(field): continue - form = ui_json[field] + validations = get_validations(list(form) if isinstance(form, dict) else []) for validation in validations: try: @@ -321,25 +314,3 @@ def validate_data( errors[field].append(e) ErrorPool(errors).throw() - - def _object_or_catch( - self, - workspace: Workspace, - uuid: UUID, - ) -> Entity | PropertyGroup | UIJsonError: - """ - Returns an object if it exists in the workspace or an error if not. - - :param workspace: Workspace to fetch entities from. - :param uuid: UUID of the object to fetch. - - :returns: The object if it exists in the workspace or a placeholder error - to be collected and raised later with any other UIJson level validation - errors. - """ - - obj = workspace.get_entity(uuid) - if obj[0] is not None: - return obj[0] - - return UIJsonError(f"Workspace does not contain an entity with uid: {uuid}.") diff --git a/geoh5py/ui_json/validations/__init__.py b/geoh5py/ui_json/validations/__init__.py index 315aea7c9..00b7daa0d 100644 --- a/geoh5py/ui_json/validations/__init__.py +++ b/geoh5py/ui_json/validations/__init__.py @@ -25,6 +25,7 @@ dependency_type_validation, mesh_type_validation, parent_validation, + object_or_catch ) diff --git a/geoh5py/ui_json/validations/uijson.py b/geoh5py/ui_json/validations/uijson.py index f0e61e927..1a7dc26cb 100644 --- a/geoh5py/ui_json/validations/uijson.py +++ b/geoh5py/ui_json/validations/uijson.py @@ -19,9 +19,13 @@ from typing import Any +from uuid import UUID + +from geoh5py import Workspace from geoh5py.data import Data from geoh5py.objects import ObjectBase - +from geoh5py.groups import PropertyGroup +from geoh5py.shared import Entity class UIJsonError(Exception): """Exception raised for errors in the UIJson object.""" @@ -88,3 +92,25 @@ def parent_validation(name: str, data: dict[str, Any], params: dict[str, Any]): isinstance(child, Data) and parent.get_entity(child.uid)[0] is None ): raise UIJsonError(f"{name} data is not a child of {form['parent']}.") + + +def object_or_catch( + workspace: Workspace, + uuid: UUID, +) -> Entity | PropertyGroup | UIJsonError: + """ + Returns an object if it exists in the workspace or an error if not. + + :param workspace: Workspace to fetch entities from. + :param uuid: UUID of the object to fetch. + + :returns: The object if it exists in the workspace or a placeholder error + to be collected and raised later with any other UIJson level validation + errors. + """ + + obj = workspace.get_entity(uuid) + if obj[0] is not None: + return obj[0] + + return UIJsonError(f"Workspace does not contain an entity with uid: {uuid}.") \ No newline at end of file From 6572a04e0cc64bec33d0bf47ff18fd3390c40b38 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sat, 21 Mar 2026 16:13:10 +0000 Subject: [PATCH 03/45] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- geoh5py/ui_json/ui_json.py | 7 ++++++- geoh5py/ui_json/validations/__init__.py | 2 +- geoh5py/ui_json/validations/uijson.py | 6 +++--- 3 files changed, 10 insertions(+), 5 deletions(-) diff --git a/geoh5py/ui_json/ui_json.py b/geoh5py/ui_json/ui_json.py index f1f01441d..ca78973fe 100644 --- a/geoh5py/ui_json/ui_json.py +++ b/geoh5py/ui_json/ui_json.py @@ -39,7 +39,12 @@ from geoh5py.shared.utils import fetch_active_workspace from geoh5py.shared.validators import none_to_empty_string from geoh5py.ui_json.forms import BaseForm -from geoh5py.ui_json.validations import ErrorPool, UIJsonError, get_validations, object_or_catch +from geoh5py.ui_json.validations import ( + ErrorPool, + UIJsonError, + get_validations, + object_or_catch, +) from geoh5py.ui_json.validations.form import empty_string_to_none diff --git a/geoh5py/ui_json/validations/__init__.py b/geoh5py/ui_json/validations/__init__.py index 00b7daa0d..6fdf77aae 100644 --- a/geoh5py/ui_json/validations/__init__.py +++ b/geoh5py/ui_json/validations/__init__.py @@ -24,8 +24,8 @@ UIJsonError, dependency_type_validation, mesh_type_validation, + object_or_catch, parent_validation, - object_or_catch ) diff --git a/geoh5py/ui_json/validations/uijson.py b/geoh5py/ui_json/validations/uijson.py index 1a7dc26cb..7095b29e5 100644 --- a/geoh5py/ui_json/validations/uijson.py +++ b/geoh5py/ui_json/validations/uijson.py @@ -18,15 +18,15 @@ # '''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''' from typing import Any - from uuid import UUID from geoh5py import Workspace from geoh5py.data import Data -from geoh5py.objects import ObjectBase from geoh5py.groups import PropertyGroup +from geoh5py.objects import ObjectBase from geoh5py.shared import Entity + class UIJsonError(Exception): """Exception raised for errors in the UIJson object.""" @@ -113,4 +113,4 @@ def object_or_catch( if obj[0] is not None: return obj[0] - return UIJsonError(f"Workspace does not contain an entity with uid: {uuid}.") \ No newline at end of file + return UIJsonError(f"Workspace does not contain an entity with uid: {uuid}.") From b38218b32dd3dd5315a88c5fd849ec000138b697 Mon Sep 17 00:00:00 2001 From: domfournier Date: Sun, 22 Mar 2026 08:59:04 -0700 Subject: [PATCH 04/45] Rename tests for input file --- tests/{ui_json_test.py => input_file_test.py} | 0 tests/{ui_json_utils_test.py => input_file_utils_test.py} | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename tests/{ui_json_test.py => input_file_test.py} (100%) rename tests/{ui_json_utils_test.py => input_file_utils_test.py} (100%) diff --git a/tests/ui_json_test.py b/tests/input_file_test.py similarity index 100% rename from tests/ui_json_test.py rename to tests/input_file_test.py diff --git a/tests/ui_json_utils_test.py b/tests/input_file_utils_test.py similarity index 100% rename from tests/ui_json_utils_test.py rename to tests/input_file_utils_test.py From b581773e220a15081b5ad46c6a495af7dfcb0d90 Mon Sep 17 00:00:00 2001 From: domfournier Date: Sun, 22 Mar 2026 08:59:18 -0700 Subject: [PATCH 05/45] remove unused class --- geoh5py/shared/utils.py | 25 ------------------- tests/ui_json/set_dict_test.py | 45 ---------------------------------- 2 files changed, 70 deletions(-) delete mode 100644 tests/ui_json/set_dict_test.py diff --git a/geoh5py/shared/utils.py b/geoh5py/shared/utils.py index cec533ea6..8831db509 100644 --- a/geoh5py/shared/utils.py +++ b/geoh5py/shared/utils.py @@ -887,31 +887,6 @@ def to_tuple(value: Any) -> tuple: return (value,) -class SetDict(dict): - def __init__(self, **kwargs): - kwargs = {k: self.make_set(v) for k, v in kwargs.items()} - super().__init__(kwargs) - - def make_set(self, value): - if isinstance(value, (set, tuple, list)): - value = set(value) - else: - value = {value} - return value - - def __setitem__(self, key, value): - value = self.make_set(value) - super().__setitem__(key, value) - - def update(self, value: dict, **kwargs) -> None: # type: ignore - for key, val in value.items(): - val = self.make_set(val) - if key in self: - val = self[key].union(val) - value[key] = val - super().update(value, **kwargs) - - def inf2str(value): # map np.inf to "inf" if not isinstance(value, (int, float)): return value diff --git a/tests/ui_json/set_dict_test.py b/tests/ui_json/set_dict_test.py deleted file mode 100644 index a9d4bab0e..000000000 --- a/tests/ui_json/set_dict_test.py +++ /dev/null @@ -1,45 +0,0 @@ -# '''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''' -# Copyright (c) 2020-2026 Mira Geoscience Ltd. ' -# ' -# This file is part of geoh5py. ' -# ' -# geoh5py is free software: you can redistribute it and/or modify ' -# it under the terms of the GNU Lesser General Public License as published by ' -# the Free Software Foundation, either version 3 of the License, or ' -# (at your option) any later version. ' -# ' -# geoh5py is distributed in the hope that it will be useful, ' -# but WITHOUT ANY WARRANTY; without even the implied warranty of ' -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the ' -# GNU Lesser General Public License for more details. ' -# ' -# You should have received a copy of the GNU Lesser General Public License ' -# along with geoh5py. If not, see . ' -# '''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''' - - -from __future__ import annotations - -from geoh5py.shared.utils import SetDict - - -def test_dict_set_class(): - test = SetDict(a=1, b=[2, 3]) - assert repr(test) == "{'a': {1}, 'b': {2, 3}}" - assert test["a"] == {1} - test["a"] = [1, 2] - assert test["a"] == {1, 2} - test["a"] = {1, 2} - assert test["a"] == {1, 2} - test.update({"b": 4}) - assert test["b"] == {2, 3, 4} - test.update({"c": "hello"}) - assert test["c"] == {"hello"} - for v in test.values(): # pylint: disable=invalid-name - assert isinstance(v, set) - assert len(test) == 3 - assert list(test) == ["a", "b", "c"] - assert repr(test) == "{'a': {1, 2}, 'b': {2, 3, 4}, 'c': {'hello'}}" - assert test - test = SetDict() - assert not test From 8754ffcb170957d7621aaf17a3ce2474ccdd5efc Mon Sep 17 00:00:00 2001 From: domfournier Date: Sun, 22 Mar 2026 09:04:07 -0700 Subject: [PATCH 06/45] Clean ups and rename --- geoh5py/ui_json/ui_json.py | 83 +++++++++++++------------ geoh5py/ui_json/validations/__init__.py | 2 +- geoh5py/ui_json/validations/uijson.py | 19 +++--- 3 files changed, 57 insertions(+), 47 deletions(-) diff --git a/geoh5py/ui_json/ui_json.py b/geoh5py/ui_json/ui_json.py index f1f01441d..531e0009b 100644 --- a/geoh5py/ui_json/ui_json.py +++ b/geoh5py/ui_json/ui_json.py @@ -39,7 +39,7 @@ from geoh5py.shared.utils import fetch_active_workspace from geoh5py.shared.validators import none_to_empty_string from geoh5py.ui_json.forms import BaseForm -from geoh5py.ui_json.validations import ErrorPool, UIJsonError, get_validations, object_or_catch +from geoh5py.ui_json.validations import ErrorPool, UIJsonError, get_validations, promote_or_catch from geoh5py.ui_json.validations.form import empty_string_to_none @@ -114,20 +114,15 @@ def valid_geoh5_extension(cls, path: Path): ) return path - @classmethod - def read(cls, path: str | Path) -> UIJson: + @staticmethod + def load(path: str | Path) -> dict: """ - Create a UIJson object from ui.json file. - - Raises errors if the file doesn't exist or is not a .ui.json file. - Also validates at the Form and UIJson level whether the file is - properly formatted. If called from the BaseUIJson class, forms - will be inferred dynamically. + Load ui json from file. :param path: Path to the .ui.json file. - :returns: UIJson object. - """ + :return: Dictionary representing the ui json object. + """ if isinstance(path, str): path = Path(path) @@ -142,28 +137,40 @@ def read(cls, path: str | Path) -> UIJson: with open(path, encoding="utf-8") as file: kwargs = json.load(file) - if cls == UIJson: - fields = {} - for name, value in kwargs.items(): - if name in UIJson.model_fields: - continue - if isinstance(value, dict): - form_type = BaseForm.infer(value) - logger.info( - "Parameter: %s interpreted as a %s.", name, form_type.__name__ - ) - fields[name] = (form_type, ...) - else: - fields[name] = (type(value), ...) - - model = create_model( # type: ignore - "UnknownUIJson", - __base__=UIJson, - **fields, - ) - uijson = model(**kwargs) - else: - uijson = cls(**kwargs) + return kwargs + + @classmethod + def read(cls, path: str | Path) -> UIJson: + """ + Create a UIJson object from ui.json file. + + Raises errors if the file doesn't exist or is not a .ui.json file. + Also validates at the Form and UIJson level whether the file is + properly formatted. If called from the BaseUIJson class, forms + will be inferred dynamically. + + :param path: Path to the .ui.json file. + :returns: UIJson object. + """ + + kwargs = cls.load(path) + + fields = {} + for name, value in kwargs.items(): + if name in UIJson.model_fields: + continue + if isinstance(value, dict): + form_type = BaseForm.infer(value) + fields[name] = (form_type, ...) + else: + fields[name] = (type(value), ...) + + model = create_model( # type: ignore + kwargs.get("title", "UnknownUIJson"), + __base__=UIJson, + **fields, + ) + uijson = model(**kwargs) uijson._path = path # pylint: disable=protected-access return uijson @@ -271,12 +278,9 @@ def to_params(self, workspace: Workspace | None = None) -> dict[str, Any]: data[field] = geoh5 continue - if isinstance(value, UUID): - value = object_or_catch(geoh5, value) - if isinstance(value, list) and value and isinstance(value[0], UUID): - value = [object_or_catch(geoh5, uid) for uid in value] - - if isinstance(value, UIJsonError): + try: + value = promote_or_catch(geoh5, value) + except UIJsonError: errors[field].append(value) data[field] = value @@ -314,3 +318,4 @@ def _cross_validations( errors[field].append(e) ErrorPool(errors).throw() + diff --git a/geoh5py/ui_json/validations/__init__.py b/geoh5py/ui_json/validations/__init__.py index 00b7daa0d..6ca95b333 100644 --- a/geoh5py/ui_json/validations/__init__.py +++ b/geoh5py/ui_json/validations/__init__.py @@ -25,7 +25,7 @@ dependency_type_validation, mesh_type_validation, parent_validation, - object_or_catch + promote_or_catch ) diff --git a/geoh5py/ui_json/validations/uijson.py b/geoh5py/ui_json/validations/uijson.py index 1a7dc26cb..312c6cb86 100644 --- a/geoh5py/ui_json/validations/uijson.py +++ b/geoh5py/ui_json/validations/uijson.py @@ -17,7 +17,7 @@ # along with geoh5py. If not, see . ' # '''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''' -from typing import Any +from typing import Any, Iterable from uuid import UUID @@ -94,23 +94,28 @@ def parent_validation(name: str, data: dict[str, Any], params: dict[str, Any]): raise UIJsonError(f"{name} data is not a child of {form['parent']}.") -def object_or_catch( +def promote_or_catch( workspace: Workspace, - uuid: UUID, -) -> Entity | PropertyGroup | UIJsonError: + value: Any, +) -> Entity | PropertyGroup | UIJsonError | list[Entity | PropertyGroup | UIJsonError]: """ Returns an object if it exists in the workspace or an error if not. :param workspace: Workspace to fetch entities from. - :param uuid: UUID of the object to fetch. + :param value: UUID of the object to fetch. :returns: The object if it exists in the workspace or a placeholder error to be collected and raised later with any other UIJson level validation errors. """ + if isinstance(value, Iterable): + return [promote_or_catch(workspace, val) for val in value] - obj = workspace.get_entity(uuid) + if not isinstance(value, UUID): + return value + + obj = workspace.get_entity(value) if obj[0] is not None: return obj[0] - return UIJsonError(f"Workspace does not contain an entity with uid: {uuid}.") \ No newline at end of file + raise UIJsonError(f"Workspace does not contain an entity with uid: {value}.") \ No newline at end of file From e471548da8cac05adc7fb1f231583afab4bf9d10 Mon Sep 17 00:00:00 2001 From: domfournier Date: Tue, 31 Mar 2026 13:21:42 -0700 Subject: [PATCH 07/45] Allow to skip validation. Update tests --- geoh5py/shared/utils.py | 2 +- geoh5py/ui_json/ui_json.py | 23 +-- tests/ui_json/uijson_test.py | 304 ++++++++++++++--------------------- 3 files changed, 138 insertions(+), 191 deletions(-) diff --git a/geoh5py/shared/utils.py b/geoh5py/shared/utils.py index 2fcf20109..490004bcd 100644 --- a/geoh5py/shared/utils.py +++ b/geoh5py/shared/utils.py @@ -920,7 +920,7 @@ def nan2str(value): def str2none(value): - if value == "": + if value in ("", "{00000000-0000-0000-0000-000000000000}"): return None return value diff --git a/geoh5py/ui_json/ui_json.py b/geoh5py/ui_json/ui_json.py index e9c98e2c2..ed2d1947d 100644 --- a/geoh5py/ui_json/ui_json.py +++ b/geoh5py/ui_json/ui_json.py @@ -77,15 +77,15 @@ class UIJson(BaseModel): monitoring_directory: OptionalPath conda_environment: str workspace_geoh5: OptionalPath | None = None - _path: Path | None = None + _groups: dict[str, list[str]] def model_post_init(self, context: Any, /) -> None: self._groups = self.get_groups() def __repr__(self) -> str: - """Repr level shows a path if it exists or the title otherwise.""" - return f"UIJson('{self.title if self._path is None else str(self._path.name)}')" + """Repr level shows the title.""" + return f"UIJson('{self.title}')" def __str__(self) -> str: """String level shows the full json representation.""" @@ -146,7 +146,7 @@ def load(path: str | Path) -> dict: return kwargs @classmethod - def read(cls, path: str | Path) -> UIJson: + def read(cls, path: str | Path, validate=True) -> UIJson: """ Create a UIJson object from ui.json file. @@ -176,9 +176,12 @@ def read(cls, path: str | Path) -> UIJson: __base__=UIJson, **fields, ) - uijson = model(**kwargs) - uijson._path = path # pylint: disable=protected-access + if validate: + uijson = model(**kwargs) + else: + uijson = model.model_construct(**kwargs) + return uijson def write(self, path: Path): @@ -187,7 +190,6 @@ def write(self, path: Path): :param path: Path to write the .ui.json file. """ - self._path = path with open(path, "w", encoding="utf-8") as file: data = self.model_dump_json(indent=4, exclude_unset=True, by_alias=True) file.write(data) @@ -286,7 +288,9 @@ def set_values(self, copy: bool = False, **kwargs) -> UIJson: return uijson - def to_params(self, workspace: Workspace | None = None) -> dict[str, Any]: + def to_params( + self, workspace: Workspace | None = None, validate=True + ) -> dict[str, Any]: """ Promote, flatten and validate parameter/values dictionary. @@ -316,7 +320,8 @@ def to_params(self, workspace: Workspace | None = None) -> dict[str, Any]: data[field] = value - self._cross_validations(data, errors) + if validate: + self._cross_validations(data, errors) return data diff --git a/tests/ui_json/uijson_test.py b/tests/ui_json/uijson_test.py index f24495f0f..17ca5bc86 100644 --- a/tests/ui_json/uijson_test.py +++ b/tests/ui_json/uijson_test.py @@ -22,6 +22,7 @@ import json import logging +from copy import deepcopy import numpy as np import pytest @@ -46,86 +47,99 @@ from geoh5py.ui_json.validation import UIJsonError +SAMPLE = { + "version": "0.1.0", + "title": "my application", + "geoh5": "", + "run_command": "python -m my_module", + "run_command_boolean": True, + "monitoring_directory": "my_monitoring_directory", + "conda_environment": "my_conda_environment", + "conda_environment_boolean": False, + "workspace_geoh5": "", + "my_string_parameter": { + "label": "My string parameter", + "value": "my string value", + }, + "my_integer_parameter": { + "label": "My integer parameter", + "value": 10, + }, + "my_object_parameter": { + "label": "My object parameter", + "mesh_type": ["{202C5DB1-A56D-4004-9CAD-BAAFD8899406}"], + "value": "", + }, + "my_other_object_parameter": { + "label": "My other object parameter", + "mesh_type": ["{202C5DB1-A56D-4004-9CAD-BAAFD8899406}"], + "value": "", + }, + "my_data_parameter": { + "label": "My data parameter", + "parent": "my_object_parameter", + "association": "Vertex", + "data_type": "Float", + "value": "", + }, + "my_data_or_value_parameter": { + "label": "My other data parameter", + "parent": "my_object_parameter", + "association": "Vertex", + "data_type": "Float", + "is_value": True, + "property": "", + "value": 0.0, + }, + "my_multi_select_data_parameter": { + "label": "My multi-select data parameter", + "parent": "my_other_object_parameter", + "association": "Vertex", + "data_type": "Float", + "value": [""], + "multi_select": True, + }, + "my_faulty_data_parameter": { + "label": "My faulty data parameter", + "parent": "my_other_object_parameter", + "association": "Vertex", + "data_type": "Float", + "value": "", + }, + "my_absent_uid_parameter": { + "label": "My absent uid parameter", + "mesh_type": ["{202C5DB1-A56D-4004-9CAD-BAAFD8899406}"], + "value": "{00000000-0000-0000-0000-000000000000}", + }, + "my_radio_label_parameter": { + "label": "my radio label parameter", + "original_label": "option 1", + "alternate_label": "option 2", + "value": "option_1", + }, +} + + @pytest.fixture def sample_uijson(tmp_path): uijson_path = tmp_path / "test.ui.json" - geoh5_path = tmp_path / "test.geoh5" + geoh5_path = tmp_path / f"{__name__}.geoh5" + ui_dict = deepcopy(SAMPLE) with Workspace.create(geoh5_path) as workspace: pts = Points.create(workspace, name="test", vertices=np.random.random((10, 3))) data = pts.add_data({"my data": {"values": np.random.random(10)}}) other_pts = Points.create( workspace, name="other test", vertices=np.random.random((10, 3)) ) + ui_dict["geoh5"] = str(geoh5_path) + ui_dict["my_object_parameter"]["value"] = str(pts.uid) + ui_dict["my_other_object_parameter"]["value"] = str(other_pts.uid) + ui_dict["my_data_parameter"]["value"] = str(data.uid) + ui_dict["my_faulty_data_parameter"]["value"] = str(data.uid) + ui_dict["my_multi_select_data_parameter"]["value"] = [str(data.uid)] + with open(uijson_path, mode="w", encoding="utf8") as file: - file.write( - json.dumps( - { - "version": "0.1.0", - "title": "my application", - "geoh5": str(geoh5_path), - "run_command": "python -m my_module", - "run_command_boolean": True, - "monitoring_directory": "my_monitoring_directory", - "conda_environment": "my_conda_environment", - "conda_environment_boolean": False, - "workspace_geoh5": str(geoh5_path), - "my_string_parameter": { - "label": "My string parameter", - "value": "my string value", - }, - "my_integer_parameter": { - "label": "My integer parameter", - "value": 10, - }, - "my_object_parameter": { - "label": "My object parameter", - "mesh_type": ["{202C5DB1-A56D-4004-9CAD-BAAFD8899406}"], - "value": str(pts.uid), - }, - "my_other_object_parameter": { - "label": "My other object parameter", - "mesh_type": ["{202C5DB1-A56D-4004-9CAD-BAAFD8899406}"], - "value": str(other_pts.uid), - }, - "my_data_parameter": { - "label": "My data parameter", - "parent": "my_object_parameter", - "association": "Vertex", - "data_type": "Float", - "value": str(data.uid), - }, - "my_data_or_value_parameter": { - "label": "My other data parameter", - "parent": "my_object_parameter", - "association": "Vertex", - "data_type": "Float", - "is_value": True, - "property": "", - "value": 0.0, - }, - "my_multi_select_data_parameter": { - "label": "My multi-select data parameter", - "parent": "my_other_object_parameter", - "association": "Vertex", - "data_type": "Float", - "value": [str(data.uid)], - "multi_select": True, - }, - "my_faulty_data_parameter": { - "label": "My faulty data parameter", - "parent": "my_other_object_parameter", - "association": "Vertex", - "data_type": "Float", - "value": str(data.uid), - }, - "my_absent_uid_parameter": { - "label": "My absent uid parameter", - "mesh_type": ["{202C5DB1-A56D-4004-9CAD-BAAFD8899406}"], - "value": "{00000000-0000-0000-0000-000000000000}", - }, - } - ) - ) + file.write(json.dumps(ui_dict)) return uijson_path @@ -133,6 +147,7 @@ def test_uijson(sample_uijson): class MyUIJson(UIJson): my_string_parameter: StringForm my_integer_parameter: IntegerForm + my_object_parameter: ObjectForm my_other_object_parameter: ObjectForm my_data_parameter: DataForm @@ -140,6 +155,7 @@ class MyUIJson(UIJson): my_multi_select_data_parameter: MultiSelectDataForm my_faulty_data_parameter: DataForm my_absent_uid_parameter: ObjectForm + my_radio_button_parameter: RadioLabelForm uijson = MyUIJson.read(sample_uijson) with pytest.raises(UIJsonError) as err: @@ -164,7 +180,7 @@ def generate_test_uijson(workspace: Workspace, uijson, data: dict): def test_allow_extra(tmp_path): - ws = Workspace(tmp_path / "test.geoh5") + ws = Workspace(tmp_path / f"{__name__}.geoh5") class MyUIJson(UIJson): my_string_parameter: StringForm @@ -186,7 +202,7 @@ class MyUIJson(UIJson): def test_multiple_validations(tmp_path): - ws = Workspace(tmp_path / "test.geoh5") + ws = Workspace(tmp_path / f"{__name__}.geoh5") pts = Points.create(ws, name="test", vertices=np.random.random((10, 3))) other_pts = pts.copy(name="other test") data = pts.add_data({"my_data": {"values": np.random.randn(10)}}) @@ -235,7 +251,7 @@ class MyUIJson(UIJson): def test_validate_dependency_type_validation(tmp_path): - ws = Workspace(tmp_path / "test.geoh5") + ws = Workspace(tmp_path / f"{__name__}.geoh5") # BoolForm dependency is valid class MyUIJson(UIJson): @@ -277,7 +293,7 @@ class MyUIJson(UIJson): def test_parent_child_validation(tmp_path): - ws = Workspace(tmp_path / "test.geoh5") + ws = Workspace(tmp_path / f"{__name__}.geoh5") pts = Points.create(ws, name="test", vertices=np.random.random((10, 3))) data = pts.add_data({"my_data": {"values": np.random.randn(10)}}) other_pts = pts.copy(name="other test") @@ -316,7 +332,7 @@ class MyUIJson(UIJson): def test_mesh_type_validation(tmp_path): - ws = Workspace(tmp_path / "test.geoh5") + ws = Workspace(tmp_path / f"{__name__}.geoh5") pts = Points.create(ws, name="test", vertices=np.random.random((10, 3))) class MyUIJson(UIJson): @@ -343,7 +359,7 @@ class MyUIJson(UIJson): def test_deprecated_annotation(tmp_path, caplog): - geoh5 = Workspace(tmp_path / "test.geoh5") + geoh5 = Workspace(tmp_path / f"{__name__}.geoh5") class MyUIJson(UIJson): my_parameter: Deprecated @@ -385,7 +401,7 @@ class MyUIJson(UIJson): }, } - with Workspace(tmp_path / "test.geoh5") as ws: + with Workspace(tmp_path / f"{__name__}.geoh5") as ws: uijson = generate_test_uijson(ws, uijson=MyUIJson, data=kwargs) groups = uijson._groups @@ -437,7 +453,7 @@ class MyUIJson(UIJson): }, } - with Workspace(tmp_path / "test.geoh5") as ws: + with Workspace(tmp_path / f"{__name__}.geoh5") as ws: uijson = generate_test_uijson(ws, uijson=MyUIJson, data=kwargs) assert not uijson.is_disabled("my_param") @@ -452,83 +468,8 @@ class MyUIJson(UIJson): assert "my_other_param" not in params -def test_unknown_uijson(tmp_path): - ws = Workspace.create(tmp_path / "test.geoh5") - pts = Points.create(ws, name="my points", vertices=np.random.random((10, 3))) - data = pts.add_data({"my data": {"values": np.random.random(10)}}) - kwargs = { - "version": "0.1.0", - "title": "my application", - "geoh5": str(tmp_path / "test.geoh5"), - "run_command": "python -m my_module", - "monitoring_directory": None, - "conda_environment": "test", - "workspace_geoh5": None, - "my_string_parameter": { - "label": "my string parameter", - "value": "my string value", - }, - "my_radio_label_parameter": { - "label": "my radio label parameter", - "original_label": "option 1", - "alternate_label": "option 2", - "value": "option_1", - }, - "my_integer_parameter": { - "label": "my integer parameter", - "value": 10, - }, - "my_object_parameter": { - "label": "my object parameter", - "mesh_type": "{202C5DB1-A56D-4004-9CAD-BAAFD8899406}", - "value": str(pts.uid), - }, - "my_data_parameter": { - "label": "My data parameter", - "parent": "my_object_parameter", - "association": "Vertex", - "data_type": "Float", - "value": str(data.uid), - }, - "my_data_or_value_parameter": { - "label": "My other data parameter", - "parent": "my_object_parameter", - "association": "Vertex", - "data_type": "Float", - "is_value": True, - "property": "", - "value": 0.0, - }, - "my_multi_choice_data_parameter": { - "label": "My multi-choice data parameter", - "parent": "my_object_parameter", - "association": "Vertex", - "data_type": "Float", - "value": [str(data.uid)], - "multi_select": True, - }, - "my_optional_parameter": { - "label": "my optional parameter", - "value": 2.0, - "optional": True, - "enabled": False, - }, - "my_group_optional_parameter": { - "label": "my group optional parameter", - "value": 3.0, - "group": "my group", - "group_optional": True, - "enabled": False, - }, - "my_grouped_parameter": { - "label": "my grouped parameter", - "value": 4.0, - "group": "my group", - }, - } - with open(tmp_path / "test.ui.json", mode="w", encoding="utf8") as file: - file.write(json.dumps(kwargs)) - uijson = UIJson.read(tmp_path / "test.ui.json") +def test_unknown_uijson(tmp_path, sample_uijson): + uijson = UIJson.read(sample_uijson) uijson.write(tmp_path / "test_copy.ui.json") assert isinstance(uijson.my_string_parameter, StringForm) @@ -537,15 +478,21 @@ def test_unknown_uijson(tmp_path): assert isinstance(uijson.my_object_parameter, ObjectForm) assert isinstance(uijson.my_data_parameter, DataForm) assert isinstance(uijson.my_data_or_value_parameter, DataOrValueForm) - assert isinstance(uijson.my_multi_choice_data_parameter, MultiSelectDataForm) - params = uijson.to_params() - assert params["my_object_parameter"].uid == pts.uid - assert params["my_data_parameter"].uid == data.uid - assert params["my_data_or_value_parameter"] == 0.0 - assert params["my_multi_choice_data_parameter"][0].uid == data.uid - assert "my_optional_parameter" not in params - assert "my_group_optional_parameter" not in params - assert "my_grouped_parameter" not in params + assert isinstance(uijson.my_multi_select_data_parameter, MultiSelectDataForm) + + params = uijson.to_params(validate=False) + + with Workspace(tmp_path / f"{__name__}.geoh5") as ws: + assert params["my_object_parameter"].uid == ws.get_entity("test")[0].uid + assert params["my_data_parameter"].uid == ws.get_entity("my data")[0].uid + assert params["my_data_or_value_parameter"] == 0.0 + assert ( + params["my_multi_select_data_parameter"][0].uid + == ws.get_entity("my data")[0].uid + ) + assert "my_optional_parameter" not in params + assert "my_group_optional_parameter" not in params + assert "my_grouped_parameter" not in params re_loaded = UIJson.read(tmp_path / "test_copy.ui.json") @@ -554,7 +501,7 @@ def test_unknown_uijson(tmp_path): def test_str_and_repr(tmp_path): - Workspace.create(tmp_path / "test.geoh5") + Workspace.create(tmp_path / f"{__name__}.geoh5") class MyUIJson(UIJson): param: StringForm @@ -562,7 +509,7 @@ class MyUIJson(UIJson): uijson = MyUIJson( version="0.1.0", title="my application", - geoh5=str(tmp_path / "test.geoh5"), + geoh5=str(tmp_path / f"{__name__}.geoh5"), run_command="python -m my_module", monitoring_directory=None, conda_environment="test", @@ -574,11 +521,6 @@ class MyUIJson(UIJson): repr_uijson = repr(uijson) assert "UIJson('my application')" in repr_uijson assert '"version": "0.1.0"' in str_uijson - uijson.write(tmp_path / "test.ui.json") - str_uijson = str(uijson) - repr_uijson = repr(uijson) - assert "UIJson('test.ui.json')" in repr_uijson - assert '"version": "0.1.0"' in str_uijson def test_geoh5_validate_extension(tmp_path): @@ -603,7 +545,7 @@ def test_geoh5_validate_extension(tmp_path): def test_fill_in_place(tmp_path): - ws = Workspace(tmp_path / "test.geoh5") + ws = Workspace(tmp_path / f"{__name__}.geoh5") class MyUIJson(UIJson): my_string_parameter: StringForm @@ -625,7 +567,7 @@ class MyUIJson(UIJson): def test_fill_copy(tmp_path): - ws = Workspace(tmp_path / "test.geoh5") + ws = Workspace(tmp_path / f"{__name__}.geoh5") class MyUIJson(UIJson): my_string_parameter: StringForm @@ -648,7 +590,7 @@ class MyUIJson(UIJson): def test_fill_truthy_value_leaves_updates_empty(tmp_path): """A form with a truthy value not in kwargs produces no updates.""" - ws = Workspace(tmp_path / "test.geoh5") + ws = Workspace(tmp_path / f"{__name__}.geoh5") class MyUIJson(UIJson): my_param: FloatForm @@ -666,7 +608,7 @@ class MyUIJson(UIJson): def test_fill_kwargs_re_enables_form(tmp_path): - ws = Workspace(tmp_path / "test.geoh5") + ws = Workspace(tmp_path / f"{__name__}.geoh5") class MyUIJson(UIJson): my_param: FloatForm @@ -685,7 +627,7 @@ class MyUIJson(UIJson): def test_fill_with_uuid_value(tmp_path): - ws = Workspace(tmp_path / "test.geoh5") + ws = Workspace(tmp_path / f"{__name__}.geoh5") pts = Points.create(ws, name="pts", vertices=np.random.random((10, 3))) pts2 = Points.create(ws, name="pts2", vertices=np.random.random((10, 3))) @@ -714,7 +656,7 @@ class MyUIJson(UIJson): def test_to_ui_json_group_creates_group(tmp_path): - ws = Workspace.create(tmp_path / "test.geoh5") + ws = Workspace.create(tmp_path / f"{__name__}.geoh5") class MyUIJson(UIJson): my_string_parameter: StringForm @@ -731,7 +673,7 @@ class MyUIJson(UIJson): def test_to_ui_json_group_default_name(tmp_path): - ws = Workspace.create(tmp_path / "test.geoh5") + ws = Workspace.create(tmp_path / f"{__name__}.geoh5") class MyUIJson(UIJson): my_string_parameter: StringForm @@ -747,7 +689,7 @@ class MyUIJson(UIJson): def test_to_ui_json_group_custom_name(tmp_path): - ws = Workspace.create(tmp_path / "test.geoh5") + ws = Workspace.create(tmp_path / f"{__name__}.geoh5") class MyUIJson(UIJson): my_string_parameter: StringForm @@ -763,7 +705,7 @@ class MyUIJson(UIJson): def test_to_ui_json_group_out_group_properties(tmp_path): - ws = Workspace.create(tmp_path / "test.geoh5") + ws = Workspace.create(tmp_path / f"{__name__}.geoh5") class MyUIJson(UIJson): my_string_parameter: StringForm @@ -780,7 +722,7 @@ class MyUIJson(UIJson): def test_to_ui_json_group_without_workspace(tmp_path): - geoh5_path = tmp_path / "test.geoh5" + geoh5_path = tmp_path / f"{__name__}.geoh5" Workspace.create(geoh5_path) class MyUIJson(UIJson): From a71409a16455e51618cce8f702faef2c265f93fb Mon Sep 17 00:00:00 2001 From: domfournier Date: Tue, 31 Mar 2026 15:55:53 -0700 Subject: [PATCH 08/45] Change defaults nad behaviour for geoh5 --- geoh5py/ui_json/annotations.py | 9 ++++++++- geoh5py/ui_json/ui_json.py | 14 ++++++++------ 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/geoh5py/ui_json/annotations.py b/geoh5py/ui_json/annotations.py index 3cc38f48f..30d2cff59 100644 --- a/geoh5py/ui_json/annotations.py +++ b/geoh5py/ui_json/annotations.py @@ -27,7 +27,13 @@ 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, none2str, str2none, stringify +from geoh5py.shared.utils import ( + enum_name_to_str, + none2str, + str2none, + stringify, + workspace2path, +) from geoh5py.shared.validators import ( to_class, to_list, @@ -83,6 +89,7 @@ def deprecate(value, info): OptionalPath = Annotated[ Path | None, BeforeValidator(str2none), + BeforeValidator(workspace2path), PlainSerializer(none2str), ] diff --git a/geoh5py/ui_json/ui_json.py b/geoh5py/ui_json/ui_json.py index ed2d1947d..3e389b597 100644 --- a/geoh5py/ui_json/ui_json.py +++ b/geoh5py/ui_json/ui_json.py @@ -70,13 +70,13 @@ class UIJson(BaseModel): arbitrary_types_allowed=True, extra="allow", validate_assignment=True ) - version: str + version: str | None = "0.0.0" title: str - geoh5: Path | None - run_command: str - monitoring_directory: OptionalPath - conda_environment: str - workspace_geoh5: OptionalPath | None = None + geoh5: OptionalPath + run_command: str | None + monitoring_directory: OptionalPath = None + conda_environment: str | None + workspace_geoh5: OptionalPath = None _groups: dict[str, list[str]] @@ -156,6 +156,8 @@ def read(cls, path: str | Path, validate=True) -> UIJson: will be inferred dynamically. :param path: Path to the .ui.json file. + :param validate: Whether to validate the ui json file. + :returns: UIJson object. """ From 3e99e34a0caca76c305b3d72dc51ec7e5ef17484 Mon Sep 17 00:00:00 2001 From: domfournier Date: Wed, 1 Apr 2026 14:58:39 -0700 Subject: [PATCH 09/45] MOre simplifcations --- geoh5py/shared/utils.py | 12 ++++ geoh5py/shared/validators.py | 6 -- geoh5py/ui_json/annotations.py | 11 +++- geoh5py/ui_json/ui_json.py | 114 ++++++++++++++++++++------------- geoh5py/ui_json/validation.py | 6 +- 5 files changed, 92 insertions(+), 57 deletions(-) diff --git a/geoh5py/shared/utils.py b/geoh5py/shared/utils.py index 490004bcd..5f2f18e35 100644 --- a/geoh5py/shared/utils.py +++ b/geoh5py/shared/utils.py @@ -846,6 +846,7 @@ def stringify(values: dict[str, Any]) -> dict[str, Any]: :return: Dictionary of string values. """ mappers = [ + type2uuid, entity2uuid, nan2str, inf2str, @@ -1427,3 +1428,14 @@ def enum_name_to_str(value: Enum) -> str: :return: Capitalized string. """ return value.name.capitalize() + + +def type2uuid(value: Any) -> Any | UUID: + """ + Convert an Entity type to its default uuid. + + :param value: An entity type or any. + """ + if isinstance(value, type) and hasattr(value, "default_type_uid"): + return value.default_type_uid() + return value diff --git a/geoh5py/shared/validators.py b/geoh5py/shared/validators.py index 7efe98e2f..cd631c7d5 100644 --- a/geoh5py/shared/validators.py +++ b/geoh5py/shared/validators.py @@ -145,12 +145,6 @@ def to_class( return out -def types_to_string(types: list) -> list[str] | str: - if len(types) > 1: - return [f"{{{k.default_type_uid()!s}}}" for k in types] - return f"{{{types[0].default_type_uid()!s}}}" - - class BaseValidator(ABC): """Concrete base class for validators.""" diff --git a/geoh5py/ui_json/annotations.py b/geoh5py/ui_json/annotations.py index 30d2cff59..0c7e238ca 100644 --- a/geoh5py/ui_json/annotations.py +++ b/geoh5py/ui_json/annotations.py @@ -39,7 +39,6 @@ to_list, to_path, to_type_uid_or_class, - types_to_string, ) from geoh5py.ui_json.utils import optional_uuid_mapper @@ -75,7 +74,7 @@ def deprecate(value, info): BeforeValidator(to_class), BeforeValidator(to_type_uid_or_class), BeforeValidator(to_list), - PlainSerializer(types_to_string, when_used="json"), + PlainSerializer(stringify), ] MeshTypes = Annotated[ @@ -83,7 +82,7 @@ def deprecate(value, info): BeforeValidator(to_class), BeforeValidator(to_type_uid_or_class), BeforeValidator(to_list), - PlainSerializer(types_to_string, when_used="json"), + PlainSerializer(stringify), ] OptionalPath = Annotated[ @@ -93,6 +92,12 @@ def deprecate(value, info): PlainSerializer(none2str), ] +OptionalString = Annotated[ + str | None, + BeforeValidator(str2none), + PlainSerializer(none2str), +] + OptionalUUID = Annotated[ UUID | None, BeforeValidator(optional_uuid_mapper), diff --git a/geoh5py/ui_json/ui_json.py b/geoh5py/ui_json/ui_json.py index 3e389b597..63f8cc8bb 100644 --- a/geoh5py/ui_json/ui_json.py +++ b/geoh5py/ui_json/ui_json.py @@ -35,13 +35,13 @@ from geoh5py import Workspace from geoh5py.groups import UIJsonGroup from geoh5py.shared.utils import ( - as_str_if_uuid, + copy_dict_relatives, dict_mapper, entity2uuid, fetch_active_workspace, ) -from geoh5py.ui_json.annotations import OptionalPath -from geoh5py.ui_json.forms import BaseForm +from geoh5py.ui_json.annotations import OptionalPath, OptionalString +from geoh5py.ui_json.forms import BaseForm, GroupForm from geoh5py.ui_json.validation import ( ErrorPool, UIJsonError, @@ -78,6 +78,8 @@ class UIJson(BaseModel): conda_environment: str | None workspace_geoh5: OptionalPath = None + out_group: GroupForm | OptionalString = None + _groups: dict[str, list[str]] def model_post_init(self, context: Any, /) -> None: @@ -117,14 +119,52 @@ def valid_geoh5_extension(cls, path: Path | None) -> Path | None: ) return path + def copy_relatives(self, parent: Workspace, clear_cache: bool = False): + """ + Copy the entities referenced in the input file to a new workspace. + + :param parent: The parent to copy the entities to. + :param clear_cache: Indicate whether to clear the cache. + """ + with fetch_active_workspace(Workspace(self.geoh5)) as geoh5: + params = self.to_params(workspace=geoh5) + params.pop("geoh5", None) + copy_dict_relatives( + params, + parent, + clear_cache=clear_cache, + ) + + @staticmethod + def infer(title="UnknownUIJson", **kwargs) -> type[UIJson]: + """ + Create a UIJson class based on inferred forms. + """ + fields = {} + for name, value in kwargs.items(): + if name in UIJson.model_fields.keys(): + continue + if isinstance(value, dict): + form_type = BaseForm.infer(value) + fields[name] = (form_type, ...) + else: + fields[name] = (type(value), ...) + + model = create_model( # type: ignore + kwargs.get("title", title), + __base__=UIJson, + **fields, + ) + return model + @staticmethod - def load(path: str | Path) -> dict: + def load(path: str | Path) -> tuple[type[UIJson], dict]: """ - Load ui json from file. + Load data and generate a UIJson class from file. :param path: Path to the .ui.json file. - :return: Dictionary representing the ui json object. + :return: UIJson class and dictionary representing the ui json object. """ if isinstance(path, str): path = Path(path) @@ -143,50 +183,31 @@ def load(path: str | Path) -> dict: key: (item if item != "" else None) for key, item in kwargs.items() } - return kwargs + ui_json_class = UIJson.infer(**kwargs) + return ui_json_class, kwargs @classmethod - def read(cls, path: str | Path, validate=True) -> UIJson: + def read(cls, path: str | Path) -> UIJson | type[UIJson]: """ - Create a UIJson object from ui.json file. + Create a UIJson instance from ui.json file. Raises errors if the file doesn't exist or is not a .ui.json file. Also validates at the Form and UIJson level whether the file is - properly formatted. If called from the BaseUIJson class, forms - will be inferred dynamically. + properly formatted. + + Consider using the `load` method to get the UIJson class and data separately + if you want to handle validation errors yourself. :param path: Path to the .ui.json file. :param validate: Whether to validate the ui json file. :returns: UIJson object. """ + uijson_class, kwargs = cls.load(path) - kwargs = cls.load(path) - - fields = {} - for name, value in kwargs.items(): - if name in UIJson.model_fields.keys(): - continue - if isinstance(value, dict): - form_type = BaseForm.infer(value) - fields[name] = (form_type, ...) - else: - fields[name] = (type(value), ...) + return uijson_class(**kwargs) - model = create_model( # type: ignore - kwargs.get("title", "UnknownUIJson"), - __base__=UIJson, - **fields, - ) - - if validate: - uijson = model(**kwargs) - else: - uijson = model.model_construct(**kwargs) - - return uijson - - def write(self, path: Path): + def write(self, path: Path) -> Path: """ Write the UIJson object to file. @@ -196,6 +217,8 @@ def write(self, path: Path): data = self.model_dump_json(indent=4, exclude_unset=True, by_alias=True) file.write(data) + return path + def get_groups(self) -> dict[str, list[str]]: """ Returns grouped forms. @@ -280,13 +303,12 @@ def set_values(self, copy: bool = False, **kwargs) -> UIJson: else: uijson = 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)) + setattr(uijson, key, dict_mapper(value, [entity2uuid])) return uijson @@ -298,10 +320,11 @@ def to_params( :param workspace: Workspace to fetch entities from. Used for passing active workspaces to avoid closing and flushing data. + :param validate: Whether to run cross validations on the data after - :returns: If the data passes validation, to_params returns a promoted and - flattened parameters/values dictionary that may be dumped into an application - specific params (options) class. + :returns: A flattened parameters/values dictionary that may be dumped into an application + specific params (options) class. If validate=True, the content is validated and errors + are raised if any validations fail. """ data = self.flatten(skip_disabled=True, active_only=True) @@ -372,15 +395,16 @@ def _cross_validations( if errors is None: errors = {k: [] for k in params} - ui_json = self.model_dump(exclude_unset=True) - for field, form in ui_json.items(): + for field, form in self.model_fields.items(): if self.is_disabled(field): continue - validations = get_validations(list(form) if isinstance(form, dict) else []) + validations = get_validations( + form.model_fields.keys() if isinstance(form, BaseForm) else [] + ) for validation in validations: try: - validation(field, params, ui_json) + validation(field, params, self) except UIJsonError as e: errors[field].append(e) diff --git a/geoh5py/ui_json/validation.py b/geoh5py/ui_json/validation.py index 9920e2365..538acd47c 100644 --- a/geoh5py/ui_json/validation.py +++ b/geoh5py/ui_json/validation.py @@ -381,15 +381,15 @@ def dependency_type_validation( ) -def get_validations(form_keys: list[str]) -> list[Callable]: +def get_validations(form: list[str]) -> list[Callable]: """ Get callable validations based on identifying form keys. - :param form_keys: List of form keys. + :param form: Form to validate sub-keys with :return: List of callable validations. """ - return [VALIDATIONS_MAP[k] for k in form_keys if k in VALIDATIONS_MAP] + return [VALIDATIONS_MAP[k] for k in form if k in VALIDATIONS_MAP] def mesh_type_validation(name: str, data: dict[str, Any], json_dict: dict[str, Any]): From c747227f9ff430daf9dc68e479f183842bf47434 Mon Sep 17 00:00:00 2001 From: domfournier Date: Wed, 1 Apr 2026 16:32:17 -0700 Subject: [PATCH 10/45] Fix validations with new serialization --- geoh5py/ui_json/ui_json.py | 6 ++--- geoh5py/ui_json/validation.py | 44 ++++++++++++++++++----------------- 2 files changed, 26 insertions(+), 24 deletions(-) diff --git a/geoh5py/ui_json/ui_json.py b/geoh5py/ui_json/ui_json.py index 63f8cc8bb..3e9a7dd8c 100644 --- a/geoh5py/ui_json/ui_json.py +++ b/geoh5py/ui_json/ui_json.py @@ -395,12 +395,12 @@ def _cross_validations( if errors is None: errors = {k: [] for k in params} - for field, form in self.model_fields.items(): + for field in self.model_fields_set: if self.is_disabled(field): continue - + form = getattr(self, field) validations = get_validations( - form.model_fields.keys() if isinstance(form, BaseForm) else [] + list(form.model_fields_set) if isinstance(form, BaseForm) else [] ) for validation in validations: try: diff --git a/geoh5py/ui_json/validation.py b/geoh5py/ui_json/validation.py index 538acd47c..474181039 100644 --- a/geoh5py/ui_json/validation.py +++ b/geoh5py/ui_json/validation.py @@ -22,12 +22,11 @@ from collections.abc import Callable from copy import deepcopy -from typing import Any, cast +from typing import TYPE_CHECKING, Any, cast from uuid import UUID from warnings import warn from geoh5py import Workspace -from geoh5py.data import Data from geoh5py.groups import PropertyGroup from geoh5py.objects import ObjectBase from geoh5py.shared import Entity @@ -47,6 +46,9 @@ from geoh5py.ui_json.utils import requires_value +if TYPE_CHECKING: + from geoh5py.ui_json.ui_json import UIJson + Validation = dict[str, Any] @@ -361,21 +363,22 @@ def throw(self): raise UIJsonError(message) -def dependency_type_validation( - name: str, data: dict[str, Any], json_dict: dict[str, Any] -): +def dependency_type_validation(name: str, data: dict[str, Any], ui_json: UIJson): """ Validate that the dependency for is optional or bool type. :param name: Name of the form :param data: Input data with known validations. - :param json_dict: A dict representation of the UIJson object. + :param ui_json: A UIJson object. """ - dependency = json_dict[name]["dependency"] - dependency_form = json_dict[dependency] + form = getattr(ui_json, name) + dependency = form.dependency + dependency_form = getattr(ui_json, dependency) - if "optional" not in dependency_form and not isinstance(data[dependency], bool): + if "optional" not in dependency_form.model_fields_set and not isinstance( + data[dependency], bool + ): raise UIJsonError( f"Dependency {dependency} must be either optional or of boolean type." ) @@ -392,40 +395,39 @@ def get_validations(form: list[str]) -> list[Callable]: return [VALIDATIONS_MAP[k] for k in form if k in VALIDATIONS_MAP] -def mesh_type_validation(name: str, data: dict[str, Any], json_dict: dict[str, Any]): +def mesh_type_validation(name: str, data: dict[str, Any], ui_json: UIJson): """ Validate that value is one of the provided mesh types. :param name: Name of the form :param data: Input data with known validations. - :param json_dict: A dict representation of the UIJson object. + :param ui_json: A UIJson object. """ - mesh_types = json_dict[name]["mesh_type"] + form = getattr(ui_json, name) + mesh_types = form.mesh_type obj = data[name] if not isinstance(obj, tuple(mesh_types)): raise UIJsonError(f"Object's mesh type must be one of {mesh_types}.") -def parent_validation(name: str, data: dict[str, Any], json_dict: dict[str, Any]): +def parent_validation(name: str, data: dict[str, Any], ui_json: UIJson): """ Validate that the data is a child of the parent object. :param name: Name of the form :param data: Input data with known validations. - :param json_dict: A dict representation of the UIJson object. + :param ui_json: A UIJson object. """ - form = json_dict[name] - + form = getattr(ui_json, name) + parent_name = form.parent child = data[name] - parent = data[form["parent"]] + parent = data[parent_name] - if not isinstance(parent, ObjectBase) or ( - isinstance(child, Data) and parent.get_entity(child.uid)[0] is None - ): - raise UIJsonError(f"{name} data is not a child of {form['parent']}.") + if not isinstance(parent, ObjectBase) or (child not in parent.children): + raise UIJsonError(f"{name} data is not a child of {parent_name}.") def promote_or_catch( From e52278beb149053bfbf1625c908e828e887c74ae Mon Sep 17 00:00:00 2001 From: domfournier Date: Thu, 2 Apr 2026 08:24:19 -0700 Subject: [PATCH 11/45] Improve typing for stringify --- geoh5py/shared/utils.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/geoh5py/shared/utils.py b/geoh5py/shared/utils.py index 5f2f18e35..8a666c538 100644 --- a/geoh5py/shared/utils.py +++ b/geoh5py/shared/utils.py @@ -837,7 +837,9 @@ def map_attributes(object_, **kwargs): set_attributes(object_, **values) -def stringify(values: dict[str, Any]) -> dict[str, Any]: +def stringify( + values: Any | dict[str, Any], +) -> dict[str, str] | dict[str, list[str]] | str | list[str]: """ Convert all values in a dictionary to string. From 13e73792c60415ef4af3b6a42d1dca66b4fa04ca Mon Sep 17 00:00:00 2001 From: domfournier Date: Thu, 2 Apr 2026 13:46:36 -0700 Subject: [PATCH 12/45] Revert to Serialization at the Field level --- geoh5py/shared/utils.py | 8 +++++-- geoh5py/ui_json/annotations.py | 30 +++++++++++++------------- geoh5py/ui_json/ui_json.py | 39 +++++++++++++++++++++++++--------- tests/ui_json/forms_test.py | 12 +++++------ 4 files changed, 55 insertions(+), 34 deletions(-) diff --git a/geoh5py/shared/utils.py b/geoh5py/shared/utils.py index 8a666c538..b46e31c0e 100644 --- a/geoh5py/shared/utils.py +++ b/geoh5py/shared/utils.py @@ -854,6 +854,7 @@ def stringify( inf2str, as_str_if_uuid, none2str, + enum_name_to_str, workspace2path, path2str, ] @@ -1421,7 +1422,7 @@ def map_to_class( return class_map -def enum_name_to_str(value: Enum) -> str: +def enum_name_to_str(value: Any | Enum) -> Any | str: """ Convert enum name to capitalized string. @@ -1429,7 +1430,10 @@ def enum_name_to_str(value: Enum) -> str: :return: Capitalized string. """ - return value.name.capitalize() + if isinstance(value, Enum): + return value.name.capitalize() + + return value def type2uuid(value: Any) -> Any | UUID: diff --git a/geoh5py/ui_json/annotations.py b/geoh5py/ui_json/annotations.py index 0c7e238ca..c09454985 100644 --- a/geoh5py/ui_json/annotations.py +++ b/geoh5py/ui_json/annotations.py @@ -52,21 +52,20 @@ def deprecate(value, info): return value +Deprecated = Annotated[ + Any, + Field(exclude=True), + BeforeValidator(deprecate), +] + AssociationOptions = Annotated[ DataAssociationEnum, - PlainSerializer(enum_name_to_str), + PlainSerializer(enum_name_to_str, when_used="json"), ] DataTypeOptions = Annotated[ DataTypeEnum, - PlainSerializer(enum_name_to_str), -] - - -Deprecated = Annotated[ - Any, - Field(exclude=True), - BeforeValidator(deprecate), + PlainSerializer(enum_name_to_str, when_used="json"), ] GroupTypes = Annotated[ @@ -74,7 +73,7 @@ def deprecate(value, info): BeforeValidator(to_class), BeforeValidator(to_type_uid_or_class), BeforeValidator(to_list), - PlainSerializer(stringify), + PlainSerializer(stringify, when_used="json"), ] MeshTypes = Annotated[ @@ -82,32 +81,32 @@ def deprecate(value, info): BeforeValidator(to_class), BeforeValidator(to_type_uid_or_class), BeforeValidator(to_list), - PlainSerializer(stringify), + PlainSerializer(stringify, when_used="json"), ] OptionalPath = Annotated[ Path | None, BeforeValidator(str2none), BeforeValidator(workspace2path), - PlainSerializer(none2str), + PlainSerializer(none2str, when_used="json"), ] OptionalString = Annotated[ str | None, BeforeValidator(str2none), - PlainSerializer(none2str), + PlainSerializer(none2str, when_used="json"), ] OptionalUUID = Annotated[ UUID | None, BeforeValidator(optional_uuid_mapper), - PlainSerializer(stringify), + PlainSerializer(stringify, when_used="json"), ] OptionalUUIDList = Annotated[ list[UUID] | None, BeforeValidator(optional_uuid_mapper), - PlainSerializer(stringify), + PlainSerializer(stringify, when_used="json"), ] OptionalValueList = Annotated[ @@ -119,4 +118,5 @@ def deprecate(value, info): list[Path], BeforeValidator(to_path), BeforeValidator(to_list), + PlainSerializer(stringify, when_used="json"), ] diff --git a/geoh5py/ui_json/ui_json.py b/geoh5py/ui_json/ui_json.py index 3e9a7dd8c..e695872e8 100644 --- a/geoh5py/ui_json/ui_json.py +++ b/geoh5py/ui_json/ui_json.py @@ -126,7 +126,10 @@ def copy_relatives(self, parent: Workspace, clear_cache: bool = False): :param parent: The parent to copy the entities to. :param clear_cache: Indicate whether to clear the cache. """ - with fetch_active_workspace(Workspace(self.geoh5)) as geoh5: + if self.geoh5 is None: + return + + with Workspace(self.geoh5, mode="r") as geoh5: params = self.to_params(workspace=geoh5) params.pop("geoh5", None) copy_dict_relatives( @@ -158,7 +161,7 @@ def infer(title="UnknownUIJson", **kwargs) -> type[UIJson]: return model @staticmethod - def load(path: str | Path) -> tuple[type[UIJson], dict]: + def _load(path: str | Path) -> dict: """ Load data and generate a UIJson class from file. @@ -179,15 +182,26 @@ def load(path: str | Path) -> tuple[type[UIJson], dict]: with open(path, encoding="utf-8") as file: kwargs = json.load(file) - kwargs = { - key: (item if item != "" else None) for key, item in kwargs.items() - } + + return kwargs + + @classmethod + def from_dict(cls, data: dict) -> UIJson: + """ + Create a UIJson instance from a dictionary. + + :param data: Dictionary representing the ui json object. + + :returns: UIJson object. + """ + kwargs = {key: (item if item != "" else None) for key, item in data.items()} ui_json_class = UIJson.infer(**kwargs) - return ui_json_class, kwargs + + return ui_json_class(**kwargs) @classmethod - def read(cls, path: str | Path) -> UIJson | type[UIJson]: + def read(cls, path: str | Path) -> UIJson: """ Create a UIJson instance from ui.json file. @@ -203,9 +217,9 @@ def read(cls, path: str | Path) -> UIJson | type[UIJson]: :returns: UIJson object. """ - uijson_class, kwargs = cls.load(path) + kwargs = cls._load(path) - return uijson_class(**kwargs) + return cls.from_dict(kwargs) def write(self, path: Path) -> Path: """ @@ -328,7 +342,12 @@ def to_params( """ data = self.flatten(skip_disabled=True, active_only=True) - with fetch_active_workspace(workspace or Workspace(self.geoh5)) as geoh5: + + with ( + fetch_active_workspace(workspace) + if workspace + else Workspace(self.geoh5, mode="r") as geoh5 + ): if geoh5 is None: raise ValueError("Workspace cannot be None.") diff --git a/tests/ui_json/forms_test.py b/tests/ui_json/forms_test.py index 393c8f816..763c413e8 100644 --- a/tests/ui_json/forms_test.py +++ b/tests/ui_json/forms_test.py @@ -20,6 +20,7 @@ from __future__ import annotations +import json import uuid import numpy as np @@ -596,7 +597,7 @@ def test_multichoice_data_form_serialization(): data_type="Float", multi_select=True, ) - data = form.model_dump() + data = json.loads(form.model_dump_json()) assert data["value"] == [data_uid_1, data_uid_2] form = MultiSelectDataForm( @@ -608,21 +609,18 @@ def test_multichoice_data_form_serialization(): multi_select=True, ) data = form.model_dump() - assert data["value"] == [data_uid_1] + assert data["value"] == [uuid.UUID(data_uid_1)] form = MultiSelectDataForm( label="name", - value=[], + value=[data_uid_1, data_uid_2], parent="my_param", association="Vertex", data_type="Float", - is_value=False, - property=[data_uid_1, data_uid_2], multi_select=True, ) data = form.model_dump() - assert data["property"] == [data_uid_1, data_uid_2] - assert data["value"] == [] + assert data["value"] == [uuid.UUID(data_uid_1), uuid.UUID(data_uid_2)] def test_data_range_form(): From a646f9be88699a289821b3503fe3efab388c89a3 Mon Sep 17 00:00:00 2001 From: domfournier Date: Thu, 2 Apr 2026 14:02:01 -0700 Subject: [PATCH 13/45] Fix typing --- geoh5py/ui_json/input_file.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/geoh5py/ui_json/input_file.py b/geoh5py/ui_json/input_file.py index b3de7d6a6..2e4f4176f 100644 --- a/geoh5py/ui_json/input_file.py +++ b/geoh5py/ui_json/input_file.py @@ -24,7 +24,7 @@ import warnings from copy import deepcopy from pathlib import Path -from typing import Any +from typing import Any, cast from uuid import UUID from geoh5py import Workspace @@ -640,7 +640,8 @@ def copy( with Workspace.create(geoh5_path) as workspace: input_file.copy_relatives(workspace, clear_cache=clear_cache) - return InputFile(ui_json=stringify(ui_json), validate=validate) + demoted = cast(dict, stringify(ui_json)) + return InputFile(ui_json=demoted, validate=validate) def copy_relatives(self, parent: Workspace, clear_cache: bool = False): """ From b103be0a0b6f6913504978fee20fad8fdfbbb9f2 Mon Sep 17 00:00:00 2001 From: Matthieu Cedou Date: Fri, 3 Apr 2026 14:45:49 -0400 Subject: [PATCH 14/45] accept multiple data. note other forms could suffer from the same mistake? --- geoh5py/ui_json/annotations.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/geoh5py/ui_json/annotations.py b/geoh5py/ui_json/annotations.py index c09454985..b9fcd52b0 100644 --- a/geoh5py/ui_json/annotations.py +++ b/geoh5py/ui_json/annotations.py @@ -98,7 +98,7 @@ def deprecate(value, info): ] OptionalUUID = Annotated[ - UUID | None, + list[UUID] | UUID | None, BeforeValidator(optional_uuid_mapper), PlainSerializer(stringify, when_used="json"), ] From 77258b26f1fdb767e57dd1cd830ada2e6480a324 Mon Sep 17 00:00:00 2001 From: Matthieu Cedou Date: Fri, 3 Apr 2026 15:47:50 -0400 Subject: [PATCH 15/45] correct the missing children to accept list correct the form as group_optiomnal can act as optional --- geoh5py/ui_json/forms.py | 12 +++++++++--- geoh5py/ui_json/validation.py | 4 +++- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/geoh5py/ui_json/forms.py b/geoh5py/ui_json/forms.py index c5f3d7cdc..284f80c22 100644 --- a/geoh5py/ui_json/forms.py +++ b/geoh5py/ui_json/forms.py @@ -156,7 +156,10 @@ def set_value(self, value: Any): """Set the form value.""" self.value = value - if "optional" in self.model_fields_set: + if ( + "optional" in self.model_fields_set + or "group_optional" in self.model_fields_set + ): self.enabled = self.value is not None @@ -567,7 +570,7 @@ def property_if_not_is_value(self): def flatten(self) -> UUID | float | int | None: """Returns the data for the form.""" if "is_value" in self.model_fields_set and not self.is_value: - return self.property + return self.property # type: ignore return self.value def set_value(self, value: Any): @@ -583,7 +586,10 @@ def set_value(self, value: Any): self.is_value = True self.property = None - if "optional" in self.model_fields_set: + if ( + "optional" in self.model_fields_set + or "group_optional" in self.model_fields_set + ): self.enabled = value is not None diff --git a/geoh5py/ui_json/validation.py b/geoh5py/ui_json/validation.py index 474181039..bbe35b016 100644 --- a/geoh5py/ui_json/validation.py +++ b/geoh5py/ui_json/validation.py @@ -426,7 +426,9 @@ def parent_validation(name: str, data: dict[str, Any], ui_json: UIJson): child = data[name] parent = data[parent_name] - if not isinstance(parent, ObjectBase) or (child not in parent.children): + child = child if isinstance(child, list) else [child] + missing_children = len(list(set(child) - set(parent.children))) > 0 + if not isinstance(parent, ObjectBase) or missing_children: raise UIJsonError(f"{name} data is not a child of {parent_name}.") From 7131c40c1c9b80c06518ee8002f2a578b502dc80 Mon Sep 17 00:00:00 2001 From: domfournier Date: Fri, 3 Apr 2026 22:50:46 -0700 Subject: [PATCH 16/45] Add special case for handlling of DataRangeForm --- geoh5py/shared/utils.py | 2 -- geoh5py/ui_json/forms.py | 8 ++++++++ geoh5py/ui_json/validation.py | 8 ++++++++ recipe.yaml | 2 +- 4 files changed, 17 insertions(+), 3 deletions(-) diff --git a/geoh5py/shared/utils.py b/geoh5py/shared/utils.py index b46e31c0e..ba9e0b986 100644 --- a/geoh5py/shared/utils.py +++ b/geoh5py/shared/utils.py @@ -39,8 +39,6 @@ from .exceptions import Geoh5FileClosedError -UidOrNumeric = UUID | float | int | None -StringOrNumeric = str | float | int # pylint: disable=too-many-lines if TYPE_CHECKING: diff --git a/geoh5py/ui_json/forms.py b/geoh5py/ui_json/forms.py index c5f3d7cdc..f903338d0 100644 --- a/geoh5py/ui_json/forms.py +++ b/geoh5py/ui_json/forms.py @@ -645,6 +645,14 @@ class DataRangeForm(DataFormMixin, BaseForm): allow_complement: bool = False is_complement: bool = False + def flatten(self) -> dict: + """Returns the property, data and is_complement values for the form.""" + return { + "is_complement": self.is_complement, + "property": self.property, + "value": self.value, + } + def all_subclasses(type_object: type[BaseForm]) -> list[type[BaseForm]]: """Recursively find all subclasses of input type object.""" diff --git a/geoh5py/ui_json/validation.py b/geoh5py/ui_json/validation.py index 474181039..254804b0b 100644 --- a/geoh5py/ui_json/validation.py +++ b/geoh5py/ui_json/validation.py @@ -424,6 +424,11 @@ def parent_validation(name: str, data: dict[str, Any], ui_json: UIJson): form = getattr(ui_json, name) parent_name = form.parent child = data[name] + + # Special case for DataRangeForm + if isinstance(child, dict): + child = data[name]["property"] + parent = data[parent_name] if not isinstance(parent, ObjectBase) or (child not in parent.children): @@ -447,6 +452,9 @@ def promote_or_catch( if isinstance(value, list | tuple): return [promote_or_catch(workspace, val) for val in value] + if isinstance(value, dict): + return {key: promote_or_catch(workspace, val) for key, val in value.items()} + if not isinstance(value, UUID): return value diff --git a/recipe.yaml b/recipe.yaml index 55952ceec..ae783f97e 100644 --- a/recipe.yaml +++ b/recipe.yaml @@ -2,7 +2,7 @@ schema_version: 1 context: name: "geoh5py" - version: "0.12.1rc2.dev279+3df1d110" # This will be replaced by the actual version in the build process + version: "0.12.1rc2.dev313+a646f9be" # This will be replaced by the actual version in the build process python_min: "3.12" module_name: ${{ name|lower|replace("-", "_") }} From ec724781090bee52537d4cbff481262d5057d98b Mon Sep 17 00:00:00 2001 From: domfournier Date: Sat, 4 Apr 2026 08:49:57 -0700 Subject: [PATCH 17/45] Add unit test for DataRangeForm flatten --- tests/ui_json/forms_test.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/ui_json/forms_test.py b/tests/ui_json/forms_test.py index 763c413e8..94e1aa28f 100644 --- a/tests/ui_json/forms_test.py +++ b/tests/ui_json/forms_test.py @@ -642,6 +642,11 @@ def test_data_range_form(): assert form.data_type.name == "FLOAT" assert form.range_label == "value range" + assert isinstance(form.flatten(), dict) + assert ( + set(form.flatten()).difference({"is_complement", "property", "value"}) == set() + ) + def test_flatten(sample_form): param = sample_form(label="my_param", value=2) From fb76b2fb094fcd18fc252e65a6f4cdc46ccde067 Mon Sep 17 00:00:00 2001 From: domfournier Date: Sat, 4 Apr 2026 10:37:16 -0700 Subject: [PATCH 18/45] Add mechanics to check for dependencies enable state --- geoh5py/ui_json/ui_json.py | 89 ++++++++++++++++++++++++++------------ 1 file changed, 62 insertions(+), 27 deletions(-) diff --git a/geoh5py/ui_json/ui_json.py b/geoh5py/ui_json/ui_json.py index e695872e8..66c330c69 100644 --- a/geoh5py/ui_json/ui_json.py +++ b/geoh5py/ui_json/ui_json.py @@ -41,7 +41,7 @@ fetch_active_workspace, ) from geoh5py.ui_json.annotations import OptionalPath, OptionalString -from geoh5py.ui_json.forms import BaseForm, GroupForm +from geoh5py.ui_json.forms import BaseForm, DependencyType, GroupForm from geoh5py.ui_json.validation import ( ErrorPool, UIJsonError, @@ -80,10 +80,10 @@ class UIJson(BaseModel): out_group: GroupForm | OptionalString = None - _groups: dict[str, list[str]] + _dependencies: dict[str, list[BaseForm]] def model_post_init(self, context: Any, /) -> None: - self._groups = self.get_groups() + self._dependencies = self.get_dependencies() def __repr__(self) -> str: """Repr level shows the title.""" @@ -233,47 +233,82 @@ def write(self, path: Path) -> Path: return path - def get_groups(self) -> dict[str, list[str]]: + def get_dependencies(self) -> dict[str, list[BaseForm]]: """ - Returns grouped forms. + Returns dependency links between forms. - :returns: Group names and the parameters belonging to each - group. + For each form, there could be a direct dependency on another form (dependency) and/or + an optional group dependency (group/group_optional). + + :returns: Dictionary of forms and there respective dependency links. """ - groups: dict[str, list[str]] = {} - for field in self.__class__.model_fields.keys(): - form = getattr(self, field) + dependencies: dict[str, list[BaseForm]] = {} + group_optionals: dict[str, BaseForm] = {} + for name in self.__class__.model_fields.keys(): + deps = [] + form = getattr(self, name) + if not isinstance(form, BaseForm): continue - name = getattr(form, "group", "") - if name: - groups[name] = [field] if name not in groups else groups[name] + [field] - return groups + # Check for direct dependency on other form + dependents_on = getattr(form, "dependency", None) + if dependents_on: + deps.append(getattr(self, dependents_on)) + + # Check for groupOptional dependency + group_name: str = getattr(form, "group", "") + + # Add the leading form to the known list once + group_optional = getattr(form, "group_optional", False) + if group_optional: + group_optionals[group_name] = form + + if ( + group_name in group_optionals + and form is not group_optionals[group_name] + ): + deps.append(group_optionals[group_name]) + + dependencies[name] = deps + + return dependencies def is_disabled(self, field: str) -> bool: """ - Checks if a field is disabled based on form status. + Checks if a field is disabled based on form status and dependency. - :param field: Field name to check. + :param field: Field name or form to check. :returns: True if the field is disabled by its own enabled status or - the groups enabled status, False otherwise. + the dependencies enabled status, False otherwise. """ + form = getattr(self, field) - value = getattr(self, field) - if not isinstance(value, BaseForm): + # Only a key:value pair, cannot be disabled + if not isinstance(form, BaseForm): return False - if value.enabled is False: + + if form.enabled is False: return True + # Can still be disabled based on dependency disabled = False - if value.group: - group = next(v for k, v in self._groups.items() if field in v) - for member in group: - form = getattr(self, member) - if form.group_optional: - disabled = not form.enabled - break + # Check if disabled based on group status or direct dependency + for depends_on in self._dependencies.get(field, []): + if getattr(depends_on, "group_optional", False) or getattr( + form, "dependency_type", None + ) in [DependencyType.ENABLED, DependencyType.HIDE]: + disabled = not depends_on.enabled + + if getattr(form, "dependency_type", None) in [ + DependencyType.DISABLED, + DependencyType.SHOW, + ]: + disabled = depends_on.enabled + + # Disabled as soon as one is encountered + if disabled: + return True return disabled From 80ccd50be44c6a2072c5c667ad90bd2164f727b2 Mon Sep 17 00:00:00 2001 From: domfournier Date: Sat, 4 Apr 2026 21:02:49 -0700 Subject: [PATCH 19/45] Fix logic, add unitest for all cases of dependencies --- geoh5py/ui_json/ui_json.py | 22 +++---- tests/ui_json/uijson_test.py | 109 +++++++++++++++++++++++++---------- 2 files changed, 88 insertions(+), 43 deletions(-) diff --git a/geoh5py/ui_json/ui_json.py b/geoh5py/ui_json/ui_json.py index 66c330c69..85a50041f 100644 --- a/geoh5py/ui_json/ui_json.py +++ b/geoh5py/ui_json/ui_json.py @@ -257,9 +257,8 @@ def get_dependencies(self) -> dict[str, list[BaseForm]]: deps.append(getattr(self, dependents_on)) # Check for groupOptional dependency + # Only the leading form should have the groupOptional field group_name: str = getattr(form, "group", "") - - # Add the leading form to the known list once group_optional = getattr(form, "group_optional", False) if group_optional: group_optionals[group_name] = form @@ -276,7 +275,7 @@ def get_dependencies(self) -> dict[str, list[BaseForm]]: def is_disabled(self, field: str) -> bool: """ - Checks if a field is disabled based on form status and dependency. + Checks if a field is disabled based on form status and dependencies. :param field: Field name or form to check. :returns: True if the field is disabled by its own enabled status or @@ -291,20 +290,21 @@ def is_disabled(self, field: str) -> bool: if form.enabled is False: return True - # Can still be disabled based on dependency - disabled = False + # Can still be disabled based on dependencies # Check if disabled based on group status or direct dependency - for depends_on in self._dependencies.get(field, []): - if getattr(depends_on, "group_optional", False) or getattr( + disabled = False + + for parent in self._dependencies.get(field, []): + if getattr(parent, "group_optional", False) or getattr( form, "dependency_type", None - ) in [DependencyType.ENABLED, DependencyType.HIDE]: - disabled = not depends_on.enabled + ) in [DependencyType.ENABLED, DependencyType.SHOW]: + disabled = not parent.enabled if getattr(form, "dependency_type", None) in [ DependencyType.DISABLED, - DependencyType.SHOW, + DependencyType.HIDE, ]: - disabled = depends_on.enabled + disabled = parent.enabled # Disabled as soon as one is encountered if disabled: diff --git a/tests/ui_json/uijson_test.py b/tests/ui_json/uijson_test.py index 17ca5bc86..7cf7ba291 100644 --- a/tests/ui_json/uijson_test.py +++ b/tests/ui_json/uijson_test.py @@ -388,6 +388,8 @@ class MyUIJson(UIJson): "my_param": { "label": "a", "value": 1, + "group": "my_group", + "groupOptional": True, }, "my_grouped_param": { "label": "b", @@ -404,20 +406,15 @@ class MyUIJson(UIJson): with Workspace(tmp_path / f"{__name__}.geoh5") as ws: uijson = generate_test_uijson(ws, uijson=MyUIJson, data=kwargs) - groups = uijson._groups - assert "my_group" in groups - assert "my_grouped_param" in groups["my_group"] - assert "my_other_grouped_param" in groups["my_group"] + dependencies = uijson._dependencies + assert dependencies.get("my_grouped_param") == [uijson.my_param] + assert dependencies.get("my_other_grouped_param") == [uijson.my_param] def test_disabled_forms(tmp_path): class MyUIJson(UIJson): my_param: IntegerForm my_other_param: IntegerForm - my_grouped_param: FloatForm - my_other_grouped_param: FloatForm - my_group_disabled_param: FloatForm - my_other_group_disabled_param: FloatForm kwargs = { "my_param": { @@ -429,43 +426,91 @@ class MyUIJson(UIJson): "value": 2, "enabled": False, }, - "my_grouped_param": { - "label": "c", - "group": "my_group", - "value": 1.0, - }, - "my_other_grouped_param": { - "label": "d", - "group": "my_group", - "value": 2.0, - }, - "my_group_disabled_param": { - "label": "e", - "group": "my_other_group", + } + + with Workspace(tmp_path / f"{__name__}.geoh5") as ws: + uijson = generate_test_uijson(ws, uijson=MyUIJson, data=kwargs) + + assert not uijson.is_disabled("my_param") + assert uijson.is_disabled("my_other_param") + + params = uijson.to_params() + assert "my_param" in params + assert "my_other_param" not in params + + +def test_disabled_group_optional_forms(tmp_path): + class MyUIJson(UIJson): + group_leader: FloatForm + dependent: FloatForm + + kwargs = { + "group_leader": { + "label": "a", + "group": "some_group", "group_optional": True, "enabled": False, "value": 3.0, }, - "my_other_group_disabled_param": { - "label": "f", - "group": "my_other_group", + "dependent": { + "label": "b", + "group": "some_group", "value": 4.0, + "enabled": True, }, } with Workspace(tmp_path / f"{__name__}.geoh5") as ws: uijson = generate_test_uijson(ws, uijson=MyUIJson, data=kwargs) - assert not uijson.is_disabled("my_param") - assert uijson.is_disabled("my_other_param") - assert not uijson.is_disabled("my_grouped_param") - assert not uijson.is_disabled("my_other_grouped_param") - assert uijson.is_disabled("my_group_disabled_param") - assert uijson.is_disabled("my_other_group_disabled_param") + assert uijson.is_disabled("group_leader") + assert uijson.is_disabled("dependent") params = uijson.to_params() - assert "my_param" in params - assert "my_other_param" not in params + assert "group_leader" not in params + assert "dependent" not in params + + +@pytest.mark.parametrize( + ("dtype", "state", "outcome"), + [ + ("enabled", True, True), + ("enabled", False, False), + ("disabled", True, False), + ("disabled", False, True), + ("show", True, True), + ("show", False, False), + ("hide", True, False), + ("hide", False, True), + ], +) +def test_disabled_dependency_forms(tmp_path, dtype, state, outcome): + class MyUIJson(UIJson): + group_leader: FloatForm + dependent: FloatForm + + kwargs = { + "group_leader": { + "label": "a", + "group": "some_group", + "enabled": state, + "optional": True, + "value": 3.0, + }, + "dependent": { + "label": "b", + "group": "some_group", + "dependency": "group_leader", + "dependencyType": dtype, + "value": 4.0, + "enabled": True, + }, + } + + with Workspace(tmp_path / f"{__name__}.geoh5") as ws: + uijson = generate_test_uijson(ws, uijson=MyUIJson, data=kwargs) + + assert (not uijson.is_disabled("dependent")) == outcome def test_unknown_uijson(tmp_path, sample_uijson): From 7b12c02b60660ecac93d4fb3d01bb62f8fa81802 Mon Sep 17 00:00:00 2001 From: domfournier Date: Sat, 4 Apr 2026 21:42:34 -0700 Subject: [PATCH 20/45] Track dependency parent's name. Augment tests --- geoh5py/ui_json/ui_json.py | 28 +++++++++----- tests/ui_json/uijson_test.py | 74 +++++++++++++++++++++++++++++------- 2 files changed, 78 insertions(+), 24 deletions(-) diff --git a/geoh5py/ui_json/ui_json.py b/geoh5py/ui_json/ui_json.py index 85a50041f..137c06e51 100644 --- a/geoh5py/ui_json/ui_json.py +++ b/geoh5py/ui_json/ui_json.py @@ -80,7 +80,7 @@ class UIJson(BaseModel): out_group: GroupForm | OptionalString = None - _dependencies: dict[str, list[BaseForm]] + _dependencies: dict[str, dict[str, BaseForm]] def model_post_init(self, context: Any, /) -> None: self._dependencies = self.get_dependencies() @@ -233,19 +233,19 @@ def write(self, path: Path) -> Path: return path - def get_dependencies(self) -> dict[str, list[BaseForm]]: + def get_dependencies(self) -> dict[str, dict[str, BaseForm]]: """ Returns dependency links between forms. For each form, there could be a direct dependency on another form (dependency) and/or an optional group dependency (group/group_optional). - :returns: Dictionary of forms and there respective dependency links. + :returns: Dictionary of forms and their respective dependency links. """ - dependencies: dict[str, list[BaseForm]] = {} + dependencies: dict[str, dict[str, BaseForm]] = {} group_optionals: dict[str, BaseForm] = {} for name in self.__class__.model_fields.keys(): - deps = [] + deps = {} form = getattr(self, name) if not isinstance(form, BaseForm): @@ -254,7 +254,7 @@ def get_dependencies(self) -> dict[str, list[BaseForm]]: # Check for direct dependency on other form dependents_on = getattr(form, "dependency", None) if dependents_on: - deps.append(getattr(self, dependents_on)) + deps[dependents_on] = getattr(self, dependents_on) # Check for groupOptional dependency # Only the leading form should have the groupOptional field @@ -267,7 +267,7 @@ def get_dependencies(self) -> dict[str, list[BaseForm]]: group_name in group_optionals and form is not group_optionals[group_name] ): - deps.append(group_optionals[group_name]) + deps[group_name] = group_optionals[group_name] dependencies[name] = deps @@ -294,13 +294,21 @@ def is_disabled(self, field: str) -> bool: # Check if disabled based on group status or direct dependency disabled = False - for parent in self._dependencies.get(field, []): - if getattr(parent, "group_optional", False) or getattr( + for name, parent in self._dependencies.get(field, {}).items(): + # Whole group is disabled + if getattr(parent, "group_optional", False): + disabled = not parent.enabled + + # Direct dependency injects enabled state + if getattr(form, "dependency", "") == name and getattr( form, "dependency_type", None ) in [DependencyType.ENABLED, DependencyType.SHOW]: disabled = not parent.enabled - if getattr(form, "dependency_type", None) in [ + # Direct dependency injects disabled state + if getattr(form, "dependency", "") == name and getattr( + form, "dependency_type", None + ) in [ DependencyType.DISABLED, DependencyType.HIDE, ]: diff --git a/tests/ui_json/uijson_test.py b/tests/ui_json/uijson_test.py index 7cf7ba291..8d7e1be08 100644 --- a/tests/ui_json/uijson_test.py +++ b/tests/ui_json/uijson_test.py @@ -407,8 +407,8 @@ class MyUIJson(UIJson): uijson = generate_test_uijson(ws, uijson=MyUIJson, data=kwargs) dependencies = uijson._dependencies - assert dependencies.get("my_grouped_param") == [uijson.my_param] - assert dependencies.get("my_other_grouped_param") == [uijson.my_param] + assert dependencies.get("my_grouped_param") == {"my_group": uijson.my_param} + assert dependencies.get("my_other_grouped_param") == {"my_group": uijson.my_param} def test_disabled_forms(tmp_path): @@ -472,19 +472,19 @@ class MyUIJson(UIJson): @pytest.mark.parametrize( - ("dtype", "state", "outcome"), + ("dtype", "lead_state", "outcome"), [ - ("enabled", True, True), - ("enabled", False, False), - ("disabled", True, False), - ("disabled", False, True), - ("show", True, True), - ("show", False, False), - ("hide", True, False), - ("hide", False, True), + ("enabled", True, False), + ("enabled", False, True), + ("disabled", True, True), + ("disabled", False, False), + ("show", True, False), + ("show", False, True), + ("hide", True, True), + ("hide", False, False), ], ) -def test_disabled_dependency_forms(tmp_path, dtype, state, outcome): +def test_disabled_dependency_forms(tmp_path, dtype, lead_state, outcome): class MyUIJson(UIJson): group_leader: FloatForm dependent: FloatForm @@ -493,7 +493,7 @@ class MyUIJson(UIJson): "group_leader": { "label": "a", "group": "some_group", - "enabled": state, + "enabled": lead_state, "optional": True, "value": 3.0, }, @@ -510,7 +510,53 @@ class MyUIJson(UIJson): with Workspace(tmp_path / f"{__name__}.geoh5") as ws: uijson = generate_test_uijson(ws, uijson=MyUIJson, data=kwargs) - assert (not uijson.is_disabled("dependent")) == outcome + assert uijson.is_disabled("dependent") == outcome + + +@pytest.mark.parametrize( + ("lead_state", "dep_state", "outcome"), + [ + (True, True, True), + (True, False, False), + (False, False, True), + ], +) +def test_double_dependency_state(tmp_path, lead_state, dep_state, outcome): + class MyUIJson(UIJson): + group_leader: FloatForm + dependent: FloatForm + sub_dependent: FloatForm + + kwargs = { + "group_leader": { + "label": "a", + "group": "some_group", + "enabled": lead_state, + "optional": True, + "group_optional": True, + "value": 3.0, + }, + "dependent": { + "label": "b", + "group": "some_group", + "value": 4.0, + "enabled": dep_state, + "optional": True, + }, + "sub_dependent": { + "label": "c", + "group": "some_group", + "dependency": "dependent", + "dependencyType": "disabled", + "value": 4.0, + "enabled": True, + }, + } + + with Workspace(tmp_path / f"{__name__}.geoh5") as ws: + uijson = generate_test_uijson(ws, uijson=MyUIJson, data=kwargs) + + assert uijson.is_disabled("sub_dependent") == outcome def test_unknown_uijson(tmp_path, sample_uijson): From 083b99309eb48e9142384d3a47dbe1c67aca93d9 Mon Sep 17 00:00:00 2001 From: domfournier Date: Sun, 5 Apr 2026 09:21:36 -0700 Subject: [PATCH 21/45] Don't change enable state of form automatically for DataValueForm --- geoh5py/ui_json/forms.py | 27 +++++++++++++++------------ tests/ui_json/forms_test.py | 2 -- 2 files changed, 15 insertions(+), 14 deletions(-) diff --git a/geoh5py/ui_json/forms.py b/geoh5py/ui_json/forms.py index f903338d0..0849661f8 100644 --- a/geoh5py/ui_json/forms.py +++ b/geoh5py/ui_json/forms.py @@ -149,11 +149,12 @@ def flatten(self): """Returns the data for the form.""" return self.value - def validate_data(self, params: dict[str, Any]): - """Validate the form data.""" - def set_value(self, value: Any): - """Set the form value.""" + """ + Set the form value. + + If the value is None, the form enabled state is changed to False. + """ self.value = value if "optional" in self.model_fields_set: @@ -571,20 +572,22 @@ def flatten(self) -> UUID | float | int | None: return self.value def set_value(self, value: Any): - """Set the form value.""" + """ + Set the form value. + + Either a Numeric value or a UUID, in which case it will be assigned + to the `property` field and the `is_value` field will be set to False. + """ try: self.value = value self.is_value = True except ValidationError: - if value is not None: + if value is None: + self.is_value = True 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 + self.property = value + self.is_value = False class MultiSelectDataForm(DataFormMixin, BaseForm): diff --git a/tests/ui_json/forms_test.py b/tests/ui_json/forms_test.py index 94e1aa28f..810d57422 100644 --- a/tests/ui_json/forms_test.py +++ b/tests/ui_json/forms_test.py @@ -526,12 +526,10 @@ def test_data_or_value_form(): 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 From 933de82a202ea6dafef3c7fb7034c20ccdeee71b Mon Sep 17 00:00:00 2001 From: domfournier Date: Sun, 5 Apr 2026 09:22:15 -0700 Subject: [PATCH 22/45] Change to positive logic is_enabled instead of double negative is_disabled --- geoh5py/ui_json/ui_json.py | 29 ++++++++++++++--------------- tests/ui_json/uijson_test.py | 12 ++++++------ 2 files changed, 20 insertions(+), 21 deletions(-) diff --git a/geoh5py/ui_json/ui_json.py b/geoh5py/ui_json/ui_json.py index 137c06e51..b120d5bc2 100644 --- a/geoh5py/ui_json/ui_json.py +++ b/geoh5py/ui_json/ui_json.py @@ -273,7 +273,7 @@ def get_dependencies(self) -> dict[str, dict[str, BaseForm]]: return dependencies - def is_disabled(self, field: str) -> bool: + def is_enabled(self, field: str) -> bool: """ Checks if a field is disabled based on form status and dependencies. @@ -281,29 +281,28 @@ def is_disabled(self, field: str) -> bool: :returns: True if the field is disabled by its own enabled status or the dependencies enabled status, False otherwise. """ + enabled = True form = getattr(self, field) # Only a key:value pair, cannot be disabled if not isinstance(form, BaseForm): - return False - - if form.enabled is False: return True + if not form.enabled: + return False + # Can still be disabled based on dependencies # Check if disabled based on group status or direct dependency - disabled = False - for name, parent in self._dependencies.get(field, {}).items(): # Whole group is disabled if getattr(parent, "group_optional", False): - disabled = not parent.enabled + enabled = parent.enabled # Direct dependency injects enabled state if getattr(form, "dependency", "") == name and getattr( form, "dependency_type", None ) in [DependencyType.ENABLED, DependencyType.SHOW]: - disabled = not parent.enabled + enabled = parent.enabled # Direct dependency injects disabled state if getattr(form, "dependency", "") == name and getattr( @@ -312,13 +311,13 @@ def is_disabled(self, field: str) -> bool: DependencyType.DISABLED, DependencyType.HIDE, ]: - disabled = parent.enabled + enabled = not parent.enabled - # Disabled as soon as one is encountered - if disabled: - return True + # Disabled as soon as one disabled is encountered + if not enabled: + return False - return disabled + return enabled def flatten(self, skip_disabled=False, active_only=False) -> dict[str, Any]: """ @@ -335,7 +334,7 @@ def flatten(self, skip_disabled=False, active_only=False) -> dict[str, Any]: data = {} fields = self.model_fields_set if active_only else self.model_fields for field in fields: - if skip_disabled and self.is_disabled(field): + if skip_disabled and not self.is_enabled(field): continue value = getattr(self, field) @@ -458,7 +457,7 @@ def _cross_validations( errors = {k: [] for k in params} for field in self.model_fields_set: - if self.is_disabled(field): + if not self.is_enabled(field): continue form = getattr(self, field) validations = get_validations( diff --git a/tests/ui_json/uijson_test.py b/tests/ui_json/uijson_test.py index 8d7e1be08..c7916ccb7 100644 --- a/tests/ui_json/uijson_test.py +++ b/tests/ui_json/uijson_test.py @@ -431,8 +431,8 @@ class MyUIJson(UIJson): with Workspace(tmp_path / f"{__name__}.geoh5") as ws: uijson = generate_test_uijson(ws, uijson=MyUIJson, data=kwargs) - assert not uijson.is_disabled("my_param") - assert uijson.is_disabled("my_other_param") + assert uijson.is_enabled("my_param") + assert not uijson.is_enabled("my_other_param") params = uijson.to_params() assert "my_param" in params @@ -463,8 +463,8 @@ class MyUIJson(UIJson): with Workspace(tmp_path / f"{__name__}.geoh5") as ws: uijson = generate_test_uijson(ws, uijson=MyUIJson, data=kwargs) - assert uijson.is_disabled("group_leader") - assert uijson.is_disabled("dependent") + assert not uijson.is_enabled("group_leader") + assert not uijson.is_enabled("dependent") params = uijson.to_params() assert "group_leader" not in params @@ -510,7 +510,7 @@ class MyUIJson(UIJson): with Workspace(tmp_path / f"{__name__}.geoh5") as ws: uijson = generate_test_uijson(ws, uijson=MyUIJson, data=kwargs) - assert uijson.is_disabled("dependent") == outcome + assert not uijson.is_enabled("dependent") == outcome @pytest.mark.parametrize( @@ -556,7 +556,7 @@ class MyUIJson(UIJson): with Workspace(tmp_path / f"{__name__}.geoh5") as ws: uijson = generate_test_uijson(ws, uijson=MyUIJson, data=kwargs) - assert uijson.is_disabled("sub_dependent") == outcome + assert not uijson.is_enabled("sub_dependent") == outcome def test_unknown_uijson(tmp_path, sample_uijson): From db22f9838ae804d3661a2895858e2babf2afb50f Mon Sep 17 00:00:00 2001 From: domfournier Date: Sun, 5 Apr 2026 09:23:06 -0700 Subject: [PATCH 23/45] Update docstrings --- geoh5py/ui_json/ui_json.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/geoh5py/ui_json/ui_json.py b/geoh5py/ui_json/ui_json.py index b120d5bc2..daa2ee0dc 100644 --- a/geoh5py/ui_json/ui_json.py +++ b/geoh5py/ui_json/ui_json.py @@ -275,11 +275,11 @@ def get_dependencies(self) -> dict[str, dict[str, BaseForm]]: def is_enabled(self, field: str) -> bool: """ - Checks if a field is disabled based on form status and dependencies. + Checks if a field is enabled based on form status and dependencies. :param field: Field name or form to check. - :returns: True if the field is disabled by its own enabled status or - the dependencies enabled status, False otherwise. + :returns: False if the field is disabled by its own enabled status or + the dependencies enabled status, True otherwise. """ enabled = True form = getattr(self, field) From a16f059d5d9f2d752d7dca7120abcbc2f0b8c780 Mon Sep 17 00:00:00 2001 From: domfournier Date: Sun, 5 Apr 2026 10:19:20 -0700 Subject: [PATCH 24/45] Add set_enabled to UIJson class. Refactor logic to mirror state --- geoh5py/ui_json/ui_json.py | 103 ++++++++++++++++++++++++++++------- tests/ui_json/uijson_test.py | 25 +++++---- 2 files changed, 97 insertions(+), 31 deletions(-) diff --git a/geoh5py/ui_json/ui_json.py b/geoh5py/ui_json/ui_json.py index daa2ee0dc..ca2722de7 100644 --- a/geoh5py/ui_json/ui_json.py +++ b/geoh5py/ui_json/ui_json.py @@ -80,10 +80,10 @@ class UIJson(BaseModel): out_group: GroupForm | OptionalString = None - _dependencies: dict[str, dict[str, BaseForm]] + _enabled_links: dict[str, dict[str, BaseForm]] def model_post_init(self, context: Any, /) -> None: - self._dependencies = self.get_dependencies() + self._enabled_links = self.get_enabled_links() def __repr__(self) -> str: """Repr level shows the title.""" @@ -233,7 +233,7 @@ def write(self, path: Path) -> Path: return path - def get_dependencies(self) -> dict[str, dict[str, BaseForm]]: + def get_enabled_links(self) -> dict[str, dict[str, BaseForm]]: """ Returns dependency links between forms. @@ -273,6 +273,42 @@ def get_dependencies(self) -> dict[str, dict[str, BaseForm]]: return dependencies + @staticmethod + def _mirror_parent_state(name, form_a, form_b): + """ + Check the type of mirroring between the form and + its parent. + + If group optional, the form enabled state mirrors the parent. + If direct dependency, the form enabled state can mirror or be opposite + of the parent depending on the dependency type. + + :param name: Name of the parental field. + :param parent: The parental form. + :param form: The dependent form + + :return: Logic whether the dependent (form) mirrors the parent + """ + # Direct dependency injects disabled state + if getattr(form_b, "dependency", "") == name and getattr( + form_b, "dependency_type", None + ) in [ + DependencyType.DISABLED, + DependencyType.HIDE, + ]: + return False + + # Other way direct dependency + if getattr(form_a, "dependency", "") == name and getattr( + form_a, "dependency_type", None + ) in [ + DependencyType.DISABLED, + DependencyType.HIDE, + ]: + return False + + return True + def is_enabled(self, field: str) -> bool: """ Checks if a field is enabled based on form status and dependencies. @@ -293,27 +329,15 @@ def is_enabled(self, field: str) -> bool: # Can still be disabled based on dependencies # Check if disabled based on group status or direct dependency - for name, parent in self._dependencies.get(field, {}).items(): - # Whole group is disabled - if getattr(parent, "group_optional", False): - enabled = parent.enabled + for name, parent in self._enabled_links.get(field, {}).items(): + mirror = self._mirror_parent_state(name, parent, form) - # Direct dependency injects enabled state - if getattr(form, "dependency", "") == name and getattr( - form, "dependency_type", None - ) in [DependencyType.ENABLED, DependencyType.SHOW]: + if mirror: enabled = parent.enabled - - # Direct dependency injects disabled state - if getattr(form, "dependency", "") == name and getattr( - form, "dependency_type", None - ) in [ - DependencyType.DISABLED, - DependencyType.HIDE, - ]: + else: enabled = not parent.enabled - # Disabled as soon as one disabled is encountered + # Disabled as soon as one is encountered if not enabled: return False @@ -352,7 +376,7 @@ def set_values(self, copy: bool = False, **kwargs) -> UIJson: If False, updates the current UIJson object with the new values and returns itself. :param kwargs: Key/value pairs to update the UIJson with. - :return: A new UIJson object with the updated values. + :return: A UIJson object with the updated values. """ if copy: uijson = self.model_copy(deep=True) @@ -368,6 +392,43 @@ def set_values(self, copy: bool = False, **kwargs) -> UIJson: return uijson + def set_enabled(self, states: dict[str, bool], copy: bool = False) -> UIJson: + """ + Set the enabled state of fields, and handle the state of dependencies. + + :param states: Dictionary of field names and their enabled state to update. + :param copy: If True, returns a new UIJson object with the updated values. + If False, updates the current UIJson object with the new values and returns itself. + + :return: A UIJson object with the updated values. + """ + if copy: + uijson = self.model_copy(deep=True) + else: + uijson = self + + for key, value in states.items(): + form = getattr(uijson, key, None) + if not isinstance(form, BaseForm): + continue + + dependencies = self._enabled_links.get(key, {}) + if not dependencies and not getattr(form, "optional", False): + raise ValueError(f"Field {key} enabled state cannot be False.") + + for name, parent in dependencies.items(): + # Set the parent dependency state + mirror = self._mirror_parent_state(name, parent, form) + + if mirror: + parent.enabled = value + else: + parent.enabled = not value + + form.enabled = value + + return uijson + def to_params( self, workspace: Workspace | None = None, validate=True ) -> dict[str, Any]: diff --git a/tests/ui_json/uijson_test.py b/tests/ui_json/uijson_test.py index c7916ccb7..dc816055e 100644 --- a/tests/ui_json/uijson_test.py +++ b/tests/ui_json/uijson_test.py @@ -406,7 +406,7 @@ class MyUIJson(UIJson): with Workspace(tmp_path / f"{__name__}.geoh5") as ws: uijson = generate_test_uijson(ws, uijson=MyUIJson, data=kwargs) - dependencies = uijson._dependencies + dependencies = uijson._enabled_links assert dependencies.get("my_grouped_param") == {"my_group": uijson.my_param} assert dependencies.get("my_other_grouped_param") == {"my_group": uijson.my_param} @@ -474,14 +474,14 @@ class MyUIJson(UIJson): @pytest.mark.parametrize( ("dtype", "lead_state", "outcome"), [ - ("enabled", True, False), - ("enabled", False, True), - ("disabled", True, True), - ("disabled", False, False), - ("show", True, False), - ("show", False, True), - ("hide", True, True), - ("hide", False, False), + ("enabled", True, True), + ("enabled", False, False), + ("disabled", True, False), + ("disabled", False, True), + ("show", True, True), + ("show", False, False), + ("hide", True, False), + ("hide", False, True), ], ) def test_disabled_dependency_forms(tmp_path, dtype, lead_state, outcome): @@ -510,7 +510,12 @@ class MyUIJson(UIJson): with Workspace(tmp_path / f"{__name__}.geoh5") as ws: uijson = generate_test_uijson(ws, uijson=MyUIJson, data=kwargs) - assert not uijson.is_enabled("dependent") == outcome + assert uijson.is_enabled("dependent") == outcome + + # Change the state and check the dependent + uijson.set_enabled({"dependent": not outcome}) + + assert uijson.group_leader.enabled is not lead_state @pytest.mark.parametrize( From 5b53e7216ab8f044371f3baadf73633a2d75c87c Mon Sep 17 00:00:00 2001 From: domfournier Date: Sun, 5 Apr 2026 10:59:22 -0700 Subject: [PATCH 25/45] Add two way state enabling --- geoh5py/ui_json/ui_json.py | 48 +++++++++++++++++++++----------------- 1 file changed, 26 insertions(+), 22 deletions(-) diff --git a/geoh5py/ui_json/ui_json.py b/geoh5py/ui_json/ui_json.py index ca2722de7..b784dbd42 100644 --- a/geoh5py/ui_json/ui_json.py +++ b/geoh5py/ui_json/ui_json.py @@ -243,7 +243,7 @@ def get_enabled_links(self) -> dict[str, dict[str, BaseForm]]: :returns: Dictionary of forms and their respective dependency links. """ dependencies: dict[str, dict[str, BaseForm]] = {} - group_optionals: dict[str, BaseForm] = {} + group_optionals: dict[str, tuple[str, BaseForm]] = {} for name in self.__class__.model_fields.keys(): deps = {} form = getattr(self, name) @@ -256,51 +256,55 @@ def get_enabled_links(self) -> dict[str, dict[str, BaseForm]]: if dependents_on: deps[dependents_on] = getattr(self, dependents_on) + # Add reverse linkage + dependencies[dependents_on].update({name: form}) + # Check for groupOptional dependency # Only the leading form should have the groupOptional field group_name: str = getattr(form, "group", "") group_optional = getattr(form, "group_optional", False) if group_optional: - group_optionals[group_name] = form + group_optionals[group_name] = (name, form) if ( group_name in group_optionals - and form is not group_optionals[group_name] + and name not in group_optionals[group_name] # Avoid self reference ): - deps[group_name] = group_optionals[group_name] + lead_name, lead_form = group_optionals[group_name] + deps[group_name] = lead_form + + # Add reverse linkage + dependencies[lead_name].update({name: form}) dependencies[name] = deps return dependencies @staticmethod - def _mirror_parent_state(name, form_a, form_b): + def _mirror_link_state(name, link, form): """ Check the type of mirroring between the form and - its parent. + its linked form. If group optional, the form enabled state mirrors the parent. If direct dependency, the form enabled state can mirror or be opposite of the parent depending on the dependency type. - :param name: Name of the parental field. - :param parent: The parental form. - :param form: The dependent form + :param name: Name of the linked field. + :param parent: Form of the linked field.. + :param form: Form currently looked at - :return: Logic whether the dependent (form) mirrors the parent + :return: Logic whether the form mirrors the state of link """ - # Direct dependency injects disabled state - if getattr(form_b, "dependency", "") == name and getattr( - form_b, "dependency_type", None - ) in [ - DependencyType.DISABLED, - DependencyType.HIDE, - ]: + # Don't change leader state for group optional dependencies + if getattr(form, "group_optional", False): return False - # Other way direct dependency - if getattr(form_a, "dependency", "") == name and getattr( - form_a, "dependency_type", None + # Either way linkage injects disabled state + if getattr( + form, "dependency", getattr(link, "dependency", "") + ) == name and getattr( + form, "dependency_type", getattr(link, "dependency_type", None) ) in [ DependencyType.DISABLED, DependencyType.HIDE, @@ -330,7 +334,7 @@ def is_enabled(self, field: str) -> bool: # Can still be disabled based on dependencies # Check if disabled based on group status or direct dependency for name, parent in self._enabled_links.get(field, {}).items(): - mirror = self._mirror_parent_state(name, parent, form) + mirror = self._mirror_link_state(name, parent, form) if mirror: enabled = parent.enabled @@ -418,7 +422,7 @@ def set_enabled(self, states: dict[str, bool], copy: bool = False) -> UIJson: for name, parent in dependencies.items(): # Set the parent dependency state - mirror = self._mirror_parent_state(name, parent, form) + mirror = self._mirror_link_state(name, parent, form) if mirror: parent.enabled = value From 9bb3206f017e4eedfc55c7bd09810fd8b17db3ee Mon Sep 17 00:00:00 2001 From: domfournier Date: Sun, 5 Apr 2026 11:02:40 -0700 Subject: [PATCH 26/45] Move methods around --- geoh5py/ui_json/ui_json.py | 122 ++++++++++++++++++------------------- 1 file changed, 61 insertions(+), 61 deletions(-) diff --git a/geoh5py/ui_json/ui_json.py b/geoh5py/ui_json/ui_json.py index b784dbd42..fb09c5ef6 100644 --- a/geoh5py/ui_json/ui_json.py +++ b/geoh5py/ui_json/ui_json.py @@ -160,31 +160,6 @@ def infer(title="UnknownUIJson", **kwargs) -> type[UIJson]: ) return model - @staticmethod - def _load(path: str | Path) -> dict: - """ - Load data and generate a UIJson class from file. - - :param path: Path to the .ui.json file. - - :return: UIJson class and dictionary representing the ui json object. - """ - if isinstance(path, str): - path = Path(path) - - path = path.resolve() - - if not path.exists(): - raise FileNotFoundError(f"File {path} does not exist.") - - if "".join(path.suffixes[-2:]) != ".ui.json": - raise ValueError(f"File {path} is not a .ui.json file.") - - with open(path, encoding="utf-8") as file: - kwargs = json.load(file) - - return kwargs - @classmethod def from_dict(cls, data: dict) -> UIJson: """ @@ -280,46 +255,13 @@ def get_enabled_links(self) -> dict[str, dict[str, BaseForm]]: return dependencies - @staticmethod - def _mirror_link_state(name, link, form): - """ - Check the type of mirroring between the form and - its linked form. - - If group optional, the form enabled state mirrors the parent. - If direct dependency, the form enabled state can mirror or be opposite - of the parent depending on the dependency type. - - :param name: Name of the linked field. - :param parent: Form of the linked field.. - :param form: Form currently looked at - - :return: Logic whether the form mirrors the state of link - """ - # Don't change leader state for group optional dependencies - if getattr(form, "group_optional", False): - return False - - # Either way linkage injects disabled state - if getattr( - form, "dependency", getattr(link, "dependency", "") - ) == name and getattr( - form, "dependency_type", getattr(link, "dependency_type", None) - ) in [ - DependencyType.DISABLED, - DependencyType.HIDE, - ]: - return False - - return True - def is_enabled(self, field: str) -> bool: """ - Checks if a field is enabled based on form status and dependencies. + Checks if a field is enabled based on form status and linkages. :param field: Field name or form to check. :returns: False if the field is disabled by its own enabled status or - the dependencies enabled status, True otherwise. + the linkages enabled status, True otherwise. """ enabled = True form = getattr(self, field) @@ -331,7 +273,7 @@ def is_enabled(self, field: str) -> bool: if not form.enabled: return False - # Can still be disabled based on dependencies + # Can still be disabled based on linkages # Check if disabled based on group status or direct dependency for name, parent in self._enabled_links.get(field, {}).items(): mirror = self._mirror_link_state(name, parent, form) @@ -535,3 +477,61 @@ def _cross_validations( errors[field].append(e) ErrorPool(errors).throw() + + @staticmethod + def _load(path: str | Path) -> dict: + """ + Load data and generate a UIJson class from file. + + :param path: Path to the .ui.json file. + + :return: UIJson class and dictionary representing the ui json object. + """ + if isinstance(path, str): + path = Path(path) + + path = path.resolve() + + if not path.exists(): + raise FileNotFoundError(f"File {path} does not exist.") + + if "".join(path.suffixes[-2:]) != ".ui.json": + raise ValueError(f"File {path} is not a .ui.json file.") + + with open(path, encoding="utf-8") as file: + kwargs = json.load(file) + + return kwargs + + @staticmethod + def _mirror_link_state(name, link, form): + """ + Check the type of mirroring between the form and + its linked form. + + If group optional, the form enabled state mirrors the parent. + If direct dependency, the form enabled state can mirror or be opposite + of the parent depending on the dependency type. + + :param name: Name of the linked field. + :param parent: Form of the linked field.. + :param form: Form currently looked at + + :return: Logic whether the form mirrors the state of link + """ + # Don't change leader state for group optional dependencies + if getattr(form, "group_optional", False): + return False + + # Either way linkage injects disabled state + if getattr( + form, "dependency", getattr(link, "dependency", "") + ) == name and getattr( + form, "dependency_type", getattr(link, "dependency_type", None) + ) in [ + DependencyType.DISABLED, + DependencyType.HIDE, + ]: + return False + + return True From ab125cd5a440ef25db6e95002e080e5141d80ae1 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sun, 5 Apr 2026 18:05:40 +0000 Subject: [PATCH 27/45] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- geoh5py/ui_json/forms.py | 1 + 1 file changed, 1 insertion(+) diff --git a/geoh5py/ui_json/forms.py b/geoh5py/ui_json/forms.py index de9b36f84..991c210ac 100644 --- a/geoh5py/ui_json/forms.py +++ b/geoh5py/ui_json/forms.py @@ -592,6 +592,7 @@ def set_value(self, value: Any): self.property = value self.is_value = False + class MultiSelectDataForm(DataFormMixin, BaseForm): """ Geoh5py uijson data form with multi-selection. From 6a9915524789d107ec23c14b93603cd6c846ff9a Mon Sep 17 00:00:00 2001 From: domfournier Date: Mon, 6 Apr 2026 11:27:20 -0700 Subject: [PATCH 28/45] Add is_optional property on form. Split group_dependencies and form_dependencies --- geoh5py/ui_json/annotations.py | 4 +- geoh5py/ui_json/forms.py | 29 ++---- geoh5py/ui_json/ui_json.py | 171 ++++++++++++++++++--------------- tests/ui_json/uijson_test.py | 47 ++++++--- 4 files changed, 137 insertions(+), 114 deletions(-) diff --git a/geoh5py/ui_json/annotations.py b/geoh5py/ui_json/annotations.py index b9fcd52b0..158f88b4d 100644 --- a/geoh5py/ui_json/annotations.py +++ b/geoh5py/ui_json/annotations.py @@ -98,13 +98,13 @@ def deprecate(value, info): ] OptionalUUID = Annotated[ - list[UUID] | UUID | None, + UUID | None, BeforeValidator(optional_uuid_mapper), PlainSerializer(stringify, when_used="json"), ] OptionalUUIDList = Annotated[ - list[UUID] | None, + list[UUID] | UUID | None, BeforeValidator(optional_uuid_mapper), PlainSerializer(stringify, when_used="json"), ] diff --git a/geoh5py/ui_json/forms.py b/geoh5py/ui_json/forms.py index 991c210ac..76ef116a0 100644 --- a/geoh5py/ui_json/forms.py +++ b/geoh5py/ui_json/forms.py @@ -152,16 +152,15 @@ def flatten(self): def set_value(self, value: Any): """ Set the form value. - - If the value is None, the form enabled state is changed to False. """ self.value = value - if ( - "optional" in self.model_fields_set - or "group_optional" in self.model_fields_set - ): - self.enabled = self.value is not None + @property + def is_optional(self) -> bool: + """ + Whether the field is optional or not. + """ + return self.optional or self.group_optional class StringForm(BaseForm): @@ -571,7 +570,7 @@ def property_if_not_is_value(self): def flatten(self) -> UUID | float | int | None: """Returns the data for the form.""" if "is_value" in self.model_fields_set and not self.is_value: - return self.property # type: ignore + return self.property return self.value def set_value(self, value: Any): @@ -613,20 +612,6 @@ def only_multi_select(cls, value: bool) -> bool: raise ValueError("MultiSelectForm must have multi_select: True.") return value - @field_validator("value", mode="before") - @classmethod - def to_list(cls, value: str | list[str]) -> list[str]: - """ - Validate that value is a list, converting it if it's a string. - - :param value: The value to validate. - - :return: A list of strings representing the value. - """ - if not isinstance(value, list): - value = [value] - return value - class DataRangeForm(DataFormMixin, BaseForm): """ diff --git a/geoh5py/ui_json/ui_json.py b/geoh5py/ui_json/ui_json.py index fb09c5ef6..275bbf818 100644 --- a/geoh5py/ui_json/ui_json.py +++ b/geoh5py/ui_json/ui_json.py @@ -64,6 +64,15 @@ class UIJson(BaseModel): :params monitoring_directory: Directory to monitor for changes. :params conda_environment: Conda environment to run the application. :params workspace_geoh5: Path to the workspace geoh5 file. + :params out_group: Optional group form to hold the UIJson group. + + :params _form_dependencies: Nested dictionaries describing the dependencies between forms, + where the key is the name of the form, and the value is a dictionary of + forms name and respective mirroring enabled state behaviour + (True: reflects, False: reverses). + + :param _group_dependencies: Dictionary holding the name of the groups and + leading form. """ model_config = ConfigDict( @@ -80,10 +89,11 @@ class UIJson(BaseModel): out_group: GroupForm | OptionalString = None - _enabled_links: dict[str, dict[str, BaseForm]] + _form_dependencies: dict[str, dict[str, bool]] + _group_dependencies: dict[str, BaseForm] def model_post_init(self, context: Any, /) -> None: - self._enabled_links = self.get_enabled_links() + self._group_dependencies, self._form_dependencies = self.get_dependency_links() def __repr__(self) -> str: """Repr level shows the title.""" @@ -208,52 +218,50 @@ def write(self, path: Path) -> Path: return path - def get_enabled_links(self) -> dict[str, dict[str, BaseForm]]: + def get_dependency_links( + self, + ) -> tuple[dict[str, BaseForm], dict[str, dict[str, bool]]]: """ Returns dependency links between forms. - For each form, there could be a direct dependency on another form (dependency) and/or - an optional group dependency (group/group_optional). + For each form, there can be a group dependency ('group') to a leading + form ('group_optional') and/or a direct dependency between forms ('dependency'). - :returns: Dictionary of forms and their respective dependency links. + A direct dependency controls the enabled state tow ways, while the group dependency + controls the enabled state only from the lead form to its dependents. + + :returns: Tuple of group dependencies and direct form dependencies. """ - dependencies: dict[str, dict[str, BaseForm]] = {} - group_optionals: dict[str, tuple[str, BaseForm]] = {} + form_dependencies: dict[str, dict[str, bool]] = {} + group_dependencies: dict[str, BaseForm] = {} for name in self.__class__.model_fields.keys(): - deps = {} + form_dependencies[name] = {} form = getattr(self, name) if not isinstance(form, BaseForm): continue - # Check for direct dependency on other form - dependents_on = getattr(form, "dependency", None) - if dependents_on: - deps[dependents_on] = getattr(self, dependents_on) - - # Add reverse linkage - dependencies[dependents_on].update({name: form}) - # Check for groupOptional dependency # Only the leading form should have the groupOptional field group_name: str = getattr(form, "group", "") group_optional = getattr(form, "group_optional", False) if group_optional: - group_optionals[group_name] = (name, form) - - if ( - group_name in group_optionals - and name not in group_optionals[group_name] # Avoid self reference - ): - lead_name, lead_form = group_optionals[group_name] - deps[group_name] = lead_form + group_dependencies[group_name] = form + # Check for direct dependency on other form + dependents_on = form.dependency + + # If optional, enabled state only influences the form + if dependents_on and not getattr(form, "optional", False): + mirrors = getattr(form, "dependency_type", None) in [ + DependencyType.ENABLED, + DependencyType.SHOW, + ] # Add reverse linkage - dependencies[lead_name].update({name: form}) - - dependencies[name] = deps + form_dependencies[dependents_on].update({name: mirrors}) + form_dependencies[name][dependents_on] = mirrors - return dependencies + return group_dependencies, form_dependencies def is_enabled(self, field: str) -> bool: """ @@ -274,18 +282,23 @@ def is_enabled(self, field: str) -> bool: return False # Can still be disabled based on linkages - # Check if disabled based on group status or direct dependency - for name, parent in self._enabled_links.get(field, {}).items(): - mirror = self._mirror_link_state(name, parent, form) + # Check if disabled based on group status + group = getattr(form, "group", "") + if group in self._group_dependencies: + enabled = self._group_dependencies[group].enabled + + # Then check on direct dependency + for name, mirror in self._form_dependencies[field].items(): + # Not enabled as soon as False is encountered + if not enabled: + return False + + codependent = getattr(self, name) if mirror: - enabled = parent.enabled + enabled = codependent.enabled else: - enabled = not parent.enabled - - # Disabled as soon as one is encountered - if not enabled: - return False + enabled = not codependent.enabled return enabled @@ -329,22 +342,23 @@ def set_values(self, copy: bool = False, **kwargs) -> UIJson: else: uijson = self - for key, value in kwargs.items(): - form = getattr(uijson, key, None) + for field, value in kwargs.items(): + form = getattr(uijson, field, None) if isinstance(form, BaseForm): form.set_value(value) + self.set_enabled(copy=False, **{field: value is not None}) else: - setattr(uijson, key, dict_mapper(value, [entity2uuid])) + setattr(uijson, field, dict_mapper(value, [entity2uuid])) return uijson - def set_enabled(self, states: dict[str, bool], copy: bool = False) -> UIJson: + def set_enabled(self, copy: bool = False, **states) -> UIJson: """ Set the enabled state of fields, and handle the state of dependencies. - :param states: Dictionary of field names and their enabled state to update. :param copy: If True, returns a new UIJson object with the updated values. If False, updates the current UIJson object with the new values and returns itself. + :param states: Dictionary of field names and their enabled state to update. :return: A UIJson object with the updated values. """ @@ -353,25 +367,29 @@ def set_enabled(self, states: dict[str, bool], copy: bool = False) -> UIJson: else: uijson = self - for key, value in states.items(): - form = getattr(uijson, key, None) + for field, value in states.items(): + form = getattr(uijson, field, None) if not isinstance(form, BaseForm): continue - dependencies = self._enabled_links.get(key, {}) - if not dependencies and not getattr(form, "optional", False): - raise ValueError(f"Field {key} enabled state cannot be False.") + if ( + not value + and not self._form_dependencies.get(field, {}) + and not form.is_optional + ): + raise ValueError(f"Field {field} enabled state cannot be False.") + + form.enabled = value - for name, parent in dependencies.items(): - # Set the parent dependency state - mirror = self._mirror_link_state(name, parent, form) + # Mirror the state to dependencies + for name, mirror in self._form_dependencies[field].items(): + # Set the link dependency state + codependent = getattr(self, name) if mirror: - parent.enabled = value + codependent.enabled = value else: - parent.enabled = not value - - form.enabled = value + codependent.enabled = not value return uijson @@ -504,34 +522,37 @@ def _load(path: str | Path) -> dict: return kwargs @staticmethod - def _mirror_link_state(name, link, form): + def _mirror_linked_state(name, form, linked_name, linked_form): """ Check the type of mirroring between the form and - its linked form. + its dependent. - If group optional, the form enabled state mirrors the parent. - If direct dependency, the form enabled state can mirror or be opposite + The form enabled state can mirror or be opposite of the parent depending on the dependency type. - :param name: Name of the linked field. - :param parent: Form of the linked field.. - :param form: Form currently looked at + :param name: Name of the form. + :param form: Form of the form. + :param linked_name: Name of the linked field. + :param linked_form: Form of the linked field. :return: Logic whether the form mirrors the state of link """ - # Don't change leader state for group optional dependencies - if getattr(form, "group_optional", False): - return False - - # Either way linkage injects disabled state - if getattr( - form, "dependency", getattr(link, "dependency", "") - ) == name and getattr( - form, "dependency_type", getattr(link, "dependency_type", None) - ) in [ - DependencyType.DISABLED, - DependencyType.HIDE, - ]: + # Two-way linkage injectinging disabled state + if ( + form.dependency == linked_name + and getattr(form, "dependency_type", None) + in [ + DependencyType.DISABLED, + DependencyType.HIDE, + ] + ) or ( + linked_form.dependency == name + and getattr(linked_form, "dependency_type", None) + in [ + DependencyType.DISABLED, + DependencyType.HIDE, + ] + ): return False return True diff --git a/tests/ui_json/uijson_test.py b/tests/ui_json/uijson_test.py index dc816055e..b78a859e8 100644 --- a/tests/ui_json/uijson_test.py +++ b/tests/ui_json/uijson_test.py @@ -38,7 +38,6 @@ DataOrValueForm, FloatForm, IntegerForm, - MultiSelectDataForm, ObjectForm, RadioLabelForm, StringForm, @@ -152,7 +151,7 @@ class MyUIJson(UIJson): my_other_object_parameter: ObjectForm my_data_parameter: DataForm my_data_or_value_parameter: DataOrValueForm - my_multi_select_data_parameter: MultiSelectDataForm + my_multi_select_data_parameter: DataOrValueForm my_faulty_data_parameter: DataForm my_absent_uid_parameter: ObjectForm my_radio_button_parameter: RadioLabelForm @@ -406,9 +405,13 @@ class MyUIJson(UIJson): with Workspace(tmp_path / f"{__name__}.geoh5") as ws: uijson = generate_test_uijson(ws, uijson=MyUIJson, data=kwargs) - dependencies = uijson._enabled_links - assert dependencies.get("my_grouped_param") == {"my_group": uijson.my_param} - assert dependencies.get("my_other_grouped_param") == {"my_group": uijson.my_param} + dependencies = uijson._group_dependencies + assert dependencies.get("my_group") == uijson.my_param + + uijson.set_enabled(my_param=False) + + assert uijson.is_enabled("my_grouped_param") is False + assert uijson.is_enabled("my_other_grouped_param") is False def test_disabled_forms(tmp_path): @@ -486,11 +489,11 @@ class MyUIJson(UIJson): ) def test_disabled_dependency_forms(tmp_path, dtype, lead_state, outcome): class MyUIJson(UIJson): - group_leader: FloatForm + leader: FloatForm dependent: FloatForm kwargs = { - "group_leader": { + "leader": { "label": "a", "group": "some_group", "enabled": lead_state, @@ -500,7 +503,7 @@ class MyUIJson(UIJson): "dependent": { "label": "b", "group": "some_group", - "dependency": "group_leader", + "dependency": "leader", "dependencyType": dtype, "value": 4.0, "enabled": True, @@ -513,17 +516,20 @@ class MyUIJson(UIJson): assert uijson.is_enabled("dependent") == outcome # Change the state and check the dependent - uijson.set_enabled({"dependent": not outcome}) + uijson.set_enabled(dependent=not outcome) + assert uijson.leader.enabled is not lead_state - assert uijson.group_leader.enabled is not lead_state + # Do reverse change + uijson.set_enabled(leader=lead_state) + assert uijson.dependent.enabled is outcome @pytest.mark.parametrize( ("lead_state", "dep_state", "outcome"), [ - (True, True, True), - (True, False, False), - (False, False, True), + (True, True, False), + (True, False, True), + (False, False, False), ], ) def test_double_dependency_state(tmp_path, lead_state, dep_state, outcome): @@ -561,7 +567,18 @@ class MyUIJson(UIJson): with Workspace(tmp_path / f"{__name__}.geoh5") as ws: uijson = generate_test_uijson(ws, uijson=MyUIJson, data=kwargs) - assert not uijson.is_enabled("sub_dependent") == outcome + assert uijson.is_enabled("sub_dependent") == outcome + + # Setting at lower level doesn't change the lead group_optional + uijson.set_enabled(dependent=not dep_state) + assert uijson.group_leader.enabled is lead_state + # But changes the codependent + assert uijson.sub_dependent.enabled is dep_state + + # Set state at the lead level doesn't change the state of low level + uijson.set_enabled(group_leader=not lead_state) + assert uijson.sub_dependent.enabled is not uijson.dependent.enabled + assert uijson.sub_dependent.enabled is dep_state def test_unknown_uijson(tmp_path, sample_uijson): @@ -574,7 +591,7 @@ def test_unknown_uijson(tmp_path, sample_uijson): assert isinstance(uijson.my_object_parameter, ObjectForm) assert isinstance(uijson.my_data_parameter, DataForm) assert isinstance(uijson.my_data_or_value_parameter, DataOrValueForm) - assert isinstance(uijson.my_multi_select_data_parameter, MultiSelectDataForm) + assert isinstance(uijson.my_multi_select_data_parameter, DataOrValueForm) params = uijson.to_params(validate=False) From a711a105eef8ff6d88076564dd4935b2a133ee69 Mon Sep 17 00:00:00 2001 From: domfournier Date: Mon, 6 Apr 2026 11:28:55 -0700 Subject: [PATCH 29/45] Fix tests --- tests/ui_json/forms_test.py | 4 ++-- tests/ui_json/uijson_test.py | 5 +++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/tests/ui_json/forms_test.py b/tests/ui_json/forms_test.py index 810d57422..db5de803e 100644 --- a/tests/ui_json/forms_test.py +++ b/tests/ui_json/forms_test.py @@ -548,7 +548,7 @@ def test_multichoice_data_form(): multi_select=True, ) assert form.label == "name" - assert form.value == [uuid.UUID(data_uid_1)] + assert form.value == uuid.UUID(data_uid_1) assert form.parent == "my_param" assert form.association.name == "VERTEX" assert form.data_type.name == "FLOAT" @@ -607,7 +607,7 @@ def test_multichoice_data_form_serialization(): multi_select=True, ) data = form.model_dump() - assert data["value"] == [uuid.UUID(data_uid_1)] + assert data["value"] == uuid.UUID(data_uid_1) form = MultiSelectDataForm( label="name", diff --git a/tests/ui_json/uijson_test.py b/tests/ui_json/uijson_test.py index b78a859e8..4fc76ea32 100644 --- a/tests/ui_json/uijson_test.py +++ b/tests/ui_json/uijson_test.py @@ -38,6 +38,7 @@ DataOrValueForm, FloatForm, IntegerForm, + MultiSelectDataForm, ObjectForm, RadioLabelForm, StringForm, @@ -151,7 +152,7 @@ class MyUIJson(UIJson): my_other_object_parameter: ObjectForm my_data_parameter: DataForm my_data_or_value_parameter: DataOrValueForm - my_multi_select_data_parameter: DataOrValueForm + my_multi_select_data_parameter: MultiSelectDataForm my_faulty_data_parameter: DataForm my_absent_uid_parameter: ObjectForm my_radio_button_parameter: RadioLabelForm @@ -591,7 +592,7 @@ def test_unknown_uijson(tmp_path, sample_uijson): assert isinstance(uijson.my_object_parameter, ObjectForm) assert isinstance(uijson.my_data_parameter, DataForm) assert isinstance(uijson.my_data_or_value_parameter, DataOrValueForm) - assert isinstance(uijson.my_multi_select_data_parameter, DataOrValueForm) + assert isinstance(uijson.my_multi_select_data_parameter, MultiSelectDataForm) params = uijson.to_params(validate=False) From d4c112660126516f41e95d7f87358750ff62bee0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 6 Apr 2026 19:29:45 +0000 Subject: [PATCH 30/45] Fix docstrings, typing, and DataForm multiselect support - Add type annotations and docstring to str2none in utils.py - Improve UIJson.infer docstring with params and return docs - Change DataForm.value to OptionalUUIDList to support list of UUIDs when multiselect is True - Add test for DataForm with list of UUIDs Agent-Logs-Url: https://github.com/MiraGeoscience/geoh5py/sessions/59d9d0e9-5ed8-44ad-b621-90e66658a9e9 Co-authored-by: domfournier <55204635+domfournier@users.noreply.github.com> --- geoh5py/shared/utils.py | 9 ++++++++- geoh5py/ui_json/forms.py | 5 ++++- geoh5py/ui_json/ui_json.py | 12 +++++++++++- recipe.yaml | 2 +- tests/ui_json/forms_test.py | 9 +++++++++ 5 files changed, 33 insertions(+), 4 deletions(-) diff --git a/geoh5py/shared/utils.py b/geoh5py/shared/utils.py index ba9e0b986..dfa6eb8bf 100644 --- a/geoh5py/shared/utils.py +++ b/geoh5py/shared/utils.py @@ -921,7 +921,14 @@ def nan2str(value): return value -def str2none(value): +def str2none(value: Any) -> Any: + """ + Convert an empty string or zero UUID string to None. + + :param value: Value to convert. + + :return: None if value is an empty string or zero UUID, original value otherwise. + """ if value in ("", "{00000000-0000-0000-0000-000000000000}"): return None return value diff --git a/geoh5py/ui_json/forms.py b/geoh5py/ui_json/forms.py index 76ef116a0..83f16e7f5 100644 --- a/geoh5py/ui_json/forms.py +++ b/geoh5py/ui_json/forms.py @@ -475,9 +475,12 @@ class DataForm(DataFormMixin, BaseForm): Geoh5py uijson form for data associated with an object. Shares documented attributes with the BaseForm and DataFormMixin. + + When ``multiselect`` is ``True`` the value may be a list of UUIDs; otherwise + a single UUID or ``None`` is expected. """ - value: OptionalUUID + value: OptionalUUIDList class DataGroupForm(DataForm): diff --git a/geoh5py/ui_json/ui_json.py b/geoh5py/ui_json/ui_json.py index 275bbf818..79c1c21dd 100644 --- a/geoh5py/ui_json/ui_json.py +++ b/geoh5py/ui_json/ui_json.py @@ -151,7 +151,17 @@ def copy_relatives(self, parent: Workspace, clear_cache: bool = False): @staticmethod def infer(title="UnknownUIJson", **kwargs) -> type[UIJson]: """ - Create a UIJson class based on inferred forms. + Create a UIJson subclass dynamically based on inferred form types. + + For each keyword argument that is not already a field on :class:`UIJson`, + the function tries to infer the appropriate :class:`~geoh5py.ui_json.forms.BaseForm` + subclass from the value if it is a dict, or uses the value's type directly + otherwise. + + :param title: Name for the generated model class. Defaults to ``"UnknownUIJson"``. + :param kwargs: Named form data to include in the generated class. + + :return: A new :class:`UIJson` subclass whose extra fields match the inferred types. """ fields = {} for name, value in kwargs.items(): diff --git a/recipe.yaml b/recipe.yaml index ae783f97e..b6082499a 100644 --- a/recipe.yaml +++ b/recipe.yaml @@ -2,7 +2,7 @@ schema_version: 1 context: name: "geoh5py" - version: "0.12.1rc2.dev313+a646f9be" # This will be replaced by the actual version in the build process + version: "0.12.1rc2.dev332+a711a105" # 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 db5de803e..f6318217d 100644 --- a/tests/ui_json/forms_test.py +++ b/tests/ui_json/forms_test.py @@ -459,6 +459,15 @@ def test_data_form(): assert form.association == [DataAssociationEnum.VERTEX, DataAssociationEnum.CELL] assert form.data_type == [DataTypeEnum.FLOAT, DataTypeEnum.INTEGER] + data_uid_2 = str(uuid.uuid4()) + form = DataForm( + label="name", + value=[data_uid, data_uid_2], + parent="my_param", + association="Vertex", + data_type="Float", + ) + assert form.value == [uuid.UUID(data_uid), uuid.UUID(data_uid_2)] def test_data_group_form(): group_uid = str(uuid.uuid4()) From 1ee64543943f665fb99b0ab10be636ed7f3f47b3 Mon Sep 17 00:00:00 2001 From: domfournier Date: Mon, 6 Apr 2026 12:49:10 -0700 Subject: [PATCH 31/45] Remove unused _mirror_linked_state --- geoh5py/ui_json/ui_json.py | 36 ------------------------------------ 1 file changed, 36 deletions(-) diff --git a/geoh5py/ui_json/ui_json.py b/geoh5py/ui_json/ui_json.py index 275bbf818..475812cb4 100644 --- a/geoh5py/ui_json/ui_json.py +++ b/geoh5py/ui_json/ui_json.py @@ -520,39 +520,3 @@ def _load(path: str | Path) -> dict: kwargs = json.load(file) return kwargs - - @staticmethod - def _mirror_linked_state(name, form, linked_name, linked_form): - """ - Check the type of mirroring between the form and - its dependent. - - The form enabled state can mirror or be opposite - of the parent depending on the dependency type. - - :param name: Name of the form. - :param form: Form of the form. - :param linked_name: Name of the linked field. - :param linked_form: Form of the linked field. - - :return: Logic whether the form mirrors the state of link - """ - # Two-way linkage injectinging disabled state - if ( - form.dependency == linked_name - and getattr(form, "dependency_type", None) - in [ - DependencyType.DISABLED, - DependencyType.HIDE, - ] - ) or ( - linked_form.dependency == name - and getattr(linked_form, "dependency_type", None) - in [ - DependencyType.DISABLED, - DependencyType.HIDE, - ] - ): - return False - - return True From 5f593031729e8fc5e9f35382df25df93add864f0 Mon Sep 17 00:00:00 2001 From: domfournier Date: Mon, 6 Apr 2026 12:51:20 -0700 Subject: [PATCH 32/45] Reset version in yaml --- recipe.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/recipe.yaml b/recipe.yaml index ae783f97e..852c3d019 100644 --- a/recipe.yaml +++ b/recipe.yaml @@ -2,7 +2,7 @@ schema_version: 1 context: name: "geoh5py" - version: "0.12.1rc2.dev313+a646f9be" # This will be replaced by the actual version in the build process + version: "0.0.0" # This will be replaced by the actual version in the build process python_min: "3.12" module_name: ${{ name|lower|replace("-", "_") }} From 4fc433a0a89058ba637765dd64d68393910592dc Mon Sep 17 00:00:00 2001 From: domfournier Date: Mon, 6 Apr 2026 12:55:39 -0700 Subject: [PATCH 33/45] Sort methods in alphabetic order --- geoh5py/ui_json/ui_json.py | 342 ++++++++++++++++++------------------- 1 file changed, 171 insertions(+), 171 deletions(-) diff --git a/geoh5py/ui_json/ui_json.py b/geoh5py/ui_json/ui_json.py index 4842c4da7..70452ab4b 100644 --- a/geoh5py/ui_json/ui_json.py +++ b/geoh5py/ui_json/ui_json.py @@ -92,43 +92,6 @@ class UIJson(BaseModel): _form_dependencies: dict[str, dict[str, bool]] _group_dependencies: dict[str, BaseForm] - def model_post_init(self, context: Any, /) -> None: - self._group_dependencies, self._form_dependencies = self.get_dependency_links() - - def __repr__(self) -> str: - """Repr level shows the title.""" - return f"UIJson('{self.title}')" - - def __str__(self) -> str: - """String level shows the full json representation.""" - - json_string = self.model_dump_json(indent=4, exclude_unset=True) - for field in type(self).model_fields.keys(): - value = getattr(self, field) - if isinstance(value, BaseForm): - type_string = type(value).__name__ - json_string = json_string.replace( - f'"{field}": {{', f'"{field}": {type_string} {{' - ) - - return f"{self!r} -> {json_string}" - - @field_validator("geoh5", mode="after") - @classmethod - def workspace_path_exists(cls, path: Path | None) -> Path | None: - if path is not None and not path.exists(): - raise FileNotFoundError(f"geoh5 path {path} does not exist.") - return path - - @field_validator("geoh5", mode="after") - @classmethod - def valid_geoh5_extension(cls, path: Path | None) -> Path | None: - if path is not None and path.suffix != ".geoh5": - raise ValueError( - f"Workspace path: {path} must have a '.geoh5' file extension." - ) - return path - def copy_relatives(self, parent: Workspace, clear_cache: bool = False): """ Copy the entities referenced in the input file to a new workspace. @@ -148,6 +111,46 @@ def copy_relatives(self, parent: Workspace, clear_cache: bool = False): clear_cache=clear_cache, ) + def flatten(self, skip_disabled=False, active_only=False) -> dict[str, Any]: + """ + Flatten the UIJson data to dictionary of key/value pairs. + + Chooses between value/property in data forms depending on the is_value + field. + + :param skip_disabled: If True, skips fields with 'enabled' set to False. + :param active_only: If True, skips fields that have not been explicitly set. + + :return: Flattened dictionary of key/value pairs. + """ + data = {} + fields = self.model_fields_set if active_only else self.model_fields + for field in fields: + if skip_disabled and not self.is_enabled(field): + continue + + value = getattr(self, field) + if isinstance(value, BaseForm): + value = value.flatten() + data[field] = value + + return data + + @classmethod + def from_dict(cls, data: dict) -> UIJson: + """ + Create a UIJson instance from a dictionary. + + :param data: Dictionary representing the ui json object. + + :returns: UIJson object. + """ + kwargs = {key: (item if item != "" else None) for key, item in data.items()} + + ui_json_class = UIJson.infer(**kwargs) + + return ui_json_class(**kwargs) + @staticmethod def infer(title="UnknownUIJson", **kwargs) -> type[UIJson]: """ @@ -180,99 +183,6 @@ def infer(title="UnknownUIJson", **kwargs) -> type[UIJson]: ) return model - @classmethod - def from_dict(cls, data: dict) -> UIJson: - """ - Create a UIJson instance from a dictionary. - - :param data: Dictionary representing the ui json object. - - :returns: UIJson object. - """ - kwargs = {key: (item if item != "" else None) for key, item in data.items()} - - ui_json_class = UIJson.infer(**kwargs) - - return ui_json_class(**kwargs) - - @classmethod - def read(cls, path: str | Path) -> UIJson: - """ - Create a UIJson instance from ui.json file. - - Raises errors if the file doesn't exist or is not a .ui.json file. - Also validates at the Form and UIJson level whether the file is - properly formatted. - - Consider using the `load` method to get the UIJson class and data separately - if you want to handle validation errors yourself. - - :param path: Path to the .ui.json file. - :param validate: Whether to validate the ui json file. - - :returns: UIJson object. - """ - kwargs = cls._load(path) - - return cls.from_dict(kwargs) - - def write(self, path: Path) -> Path: - """ - Write the UIJson object to file. - - :param path: Path to write the .ui.json file. - """ - with open(path, "w", encoding="utf-8") as file: - data = self.model_dump_json(indent=4, exclude_unset=True, by_alias=True) - file.write(data) - - return path - - def get_dependency_links( - self, - ) -> tuple[dict[str, BaseForm], dict[str, dict[str, bool]]]: - """ - Returns dependency links between forms. - - For each form, there can be a group dependency ('group') to a leading - form ('group_optional') and/or a direct dependency between forms ('dependency'). - - A direct dependency controls the enabled state tow ways, while the group dependency - controls the enabled state only from the lead form to its dependents. - - :returns: Tuple of group dependencies and direct form dependencies. - """ - form_dependencies: dict[str, dict[str, bool]] = {} - group_dependencies: dict[str, BaseForm] = {} - for name in self.__class__.model_fields.keys(): - form_dependencies[name] = {} - form = getattr(self, name) - - if not isinstance(form, BaseForm): - continue - - # Check for groupOptional dependency - # Only the leading form should have the groupOptional field - group_name: str = getattr(form, "group", "") - group_optional = getattr(form, "group_optional", False) - if group_optional: - group_dependencies[group_name] = form - - # Check for direct dependency on other form - dependents_on = form.dependency - - # If optional, enabled state only influences the form - if dependents_on and not getattr(form, "optional", False): - mirrors = getattr(form, "dependency_type", None) in [ - DependencyType.ENABLED, - DependencyType.SHOW, - ] - # Add reverse linkage - form_dependencies[dependents_on].update({name: mirrors}) - form_dependencies[name][dependents_on] = mirrors - - return group_dependencies, form_dependencies - def is_enabled(self, field: str) -> bool: """ Checks if a field is enabled based on form status and linkages. @@ -312,55 +222,29 @@ def is_enabled(self, field: str) -> bool: return enabled - def flatten(self, skip_disabled=False, active_only=False) -> dict[str, Any]: - """ - Flatten the UIJson data to dictionary of key/value pairs. - - Chooses between value/property in data forms depending on the is_value - field. - - :param skip_disabled: If True, skips fields with 'enabled' set to False. - :param active_only: If True, skips fields that have not been explicitly set. + def model_post_init(self, context: Any, /) -> None: + self._group_dependencies, self._form_dependencies = self._get_dependency_links() - :return: Flattened dictionary of key/value pairs. + @classmethod + def read(cls, path: str | Path) -> UIJson: """ - data = {} - fields = self.model_fields_set if active_only else self.model_fields - for field in fields: - if skip_disabled and not self.is_enabled(field): - continue - - value = getattr(self, field) - if isinstance(value, BaseForm): - value = value.flatten() - data[field] = value + Create a UIJson instance from ui.json file. - return data + Raises errors if the file doesn't exist or is not a .ui.json file. + Also validates at the Form and UIJson level whether the file is + properly formatted. - def set_values(self, copy: bool = False, **kwargs) -> UIJson: - """ - Fill the UIJson with new values. + Consider using the `load` method to get the UIJson class and data separately + if you want to handle validation errors yourself. - :param copy: If True, returns a new UIJson object with the updated values. - If False, updates the current UIJson object with the new values and returns itself. - :param kwargs: Key/value pairs to update the UIJson with. + :param path: Path to the .ui.json file. + :param validate: Whether to validate the ui json file. - :return: A UIJson object with the updated values. + :returns: UIJson object. """ - if copy: - uijson = self.model_copy(deep=True) - else: - uijson = self - - for field, value in kwargs.items(): - form = getattr(uijson, field, None) - if isinstance(form, BaseForm): - form.set_value(value) - self.set_enabled(copy=False, **{field: value is not None}) - else: - setattr(uijson, field, dict_mapper(value, [entity2uuid])) + kwargs = cls._load(path) - return uijson + return cls.from_dict(kwargs) def set_enabled(self, copy: bool = False, **states) -> UIJson: """ @@ -403,6 +287,31 @@ def set_enabled(self, copy: bool = False, **states) -> UIJson: return uijson + def set_values(self, copy: bool = False, **kwargs) -> UIJson: + """ + Fill the UIJson with new values. + + :param copy: If True, returns a new UIJson object with the updated values. + If False, updates the current UIJson object with the new values and returns itself. + :param kwargs: Key/value pairs to update the UIJson with. + + :return: A UIJson object with the updated values. + """ + if copy: + uijson = self.model_copy(deep=True) + else: + uijson = self + + for field, value in kwargs.items(): + form = getattr(uijson, field, None) + if isinstance(form, BaseForm): + form.set_value(value) + self.set_enabled(copy=False, **{field: value is not None}) + else: + setattr(uijson, field, dict_mapper(value, [entity2uuid])) + + return uijson + def to_params( self, workspace: Workspace | None = None, validate=True ) -> dict[str, Any]: @@ -475,6 +384,34 @@ def to_ui_json_group( return ui_json_group + @field_validator("geoh5", mode="after") + @classmethod + def valid_geoh5_extension(cls, path: Path | None) -> Path | None: + if path is not None and path.suffix != ".geoh5": + raise ValueError( + f"Workspace path: {path} must have a '.geoh5' file extension." + ) + return path + + @field_validator("geoh5", mode="after") + @classmethod + def workspace_path_exists(cls, path: Path | None) -> Path | None: + if path is not None and not path.exists(): + raise FileNotFoundError(f"geoh5 path {path} does not exist.") + return path + + def write(self, path: Path) -> Path: + """ + Write the UIJson object to file. + + :param path: Path to write the .ui.json file. + """ + with open(path, "w", encoding="utf-8") as file: + data = self.model_dump_json(indent=4, exclude_unset=True, by_alias=True) + file.write(data) + + return path + def _cross_validations( self, params: dict[str, Any], errors: dict[str, Any] | None = None ) -> None: @@ -506,6 +443,51 @@ def _cross_validations( ErrorPool(errors).throw() + def _get_dependency_links( + self, + ) -> tuple[dict[str, BaseForm], dict[str, dict[str, bool]]]: + """ + Returns dependency links between forms. + + For each form, there can be a group dependency ('group') to a leading + form ('group_optional') and/or a direct dependency between forms ('dependency'). + + A direct dependency controls the enabled state tow ways, while the group dependency + controls the enabled state only from the lead form to its dependents. + + :returns: Tuple of group dependencies and direct form dependencies. + """ + form_dependencies: dict[str, dict[str, bool]] = {} + group_dependencies: dict[str, BaseForm] = {} + for name in self.__class__.model_fields.keys(): + form_dependencies[name] = {} + form = getattr(self, name) + + if not isinstance(form, BaseForm): + continue + + # Check for groupOptional dependency + # Only the leading form should have the groupOptional field + group_name: str = getattr(form, "group", "") + group_optional = getattr(form, "group_optional", False) + if group_optional: + group_dependencies[group_name] = form + + # Check for direct dependency on other form + dependents_on = form.dependency + + # If optional, enabled state only influences the form + if dependents_on and not getattr(form, "optional", False): + mirrors = getattr(form, "dependency_type", None) in [ + DependencyType.ENABLED, + DependencyType.SHOW, + ] + # Add reverse linkage + form_dependencies[dependents_on].update({name: mirrors}) + form_dependencies[name][dependents_on] = mirrors + + return group_dependencies, form_dependencies + @staticmethod def _load(path: str | Path) -> dict: """ @@ -530,3 +512,21 @@ def _load(path: str | Path) -> dict: kwargs = json.load(file) return kwargs + + def __repr__(self) -> str: + """Repr level shows the title.""" + return f"UIJson('{self.title}')" + + def __str__(self) -> str: + """String level shows the full json representation.""" + + json_string = self.model_dump_json(indent=4, exclude_unset=True) + for field in type(self).model_fields.keys(): + value = getattr(self, field) + if isinstance(value, BaseForm): + type_string = type(value).__name__ + json_string = json_string.replace( + f'"{field}": {{', f'"{field}": {type_string} {{' + ) + + return f"{self!r} -> {json_string}" From 96387c76085e96b84334441a14e84393ef43da8f Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 6 Apr 2026 19:56:12 +0000 Subject: [PATCH 34/45] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- tests/ui_json/forms_test.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/ui_json/forms_test.py b/tests/ui_json/forms_test.py index f6318217d..32e5319ce 100644 --- a/tests/ui_json/forms_test.py +++ b/tests/ui_json/forms_test.py @@ -469,6 +469,7 @@ def test_data_form(): ) assert form.value == [uuid.UUID(data_uid), uuid.UUID(data_uid_2)] + def test_data_group_form(): group_uid = str(uuid.uuid4()) form = DataGroupForm( From 876891bbe92b5937ad01887c441b7d57b7514fdd Mon Sep 17 00:00:00 2001 From: domfournier Date: Mon, 6 Apr 2026 12:56:46 -0700 Subject: [PATCH 35/45] Change classmethod from_dict --- geoh5py/ui_json/ui_json.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/geoh5py/ui_json/ui_json.py b/geoh5py/ui_json/ui_json.py index 70452ab4b..1424c43f2 100644 --- a/geoh5py/ui_json/ui_json.py +++ b/geoh5py/ui_json/ui_json.py @@ -147,7 +147,7 @@ def from_dict(cls, data: dict) -> UIJson: """ kwargs = {key: (item if item != "" else None) for key, item in data.items()} - ui_json_class = UIJson.infer(**kwargs) + ui_json_class = cls.infer(**kwargs) return ui_json_class(**kwargs) From 3503943ee0a679c6fe819b1cac9d3d6e2037e20e Mon Sep 17 00:00:00 2001 From: domfournier Date: Mon, 6 Apr 2026 13:11:59 -0700 Subject: [PATCH 36/45] Add set_value for DataRangeForm to mirror the flatten() --- geoh5py/ui_json/forms.py | 14 ++++++++++++++ tests/ui_json/forms_test.py | 18 ++++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/geoh5py/ui_json/forms.py b/geoh5py/ui_json/forms.py index 83f16e7f5..252fc8818 100644 --- a/geoh5py/ui_json/forms.py +++ b/geoh5py/ui_json/forms.py @@ -647,6 +647,20 @@ def flatten(self) -> dict: "value": self.value, } + def set_value(self, value: Any): + """ + Set the form value. + """ + if isinstance(value, dict): + for key, val in value.items(): + setattr(self, key, val) + + if isinstance(value, list): + self.value = value + + if isinstance(value, UUID | None): + self.property = value + def all_subclasses(type_object: type[BaseForm]) -> list[type[BaseForm]]: """Recursively find all subclasses of input type object.""" diff --git a/tests/ui_json/forms_test.py b/tests/ui_json/forms_test.py index 32e5319ce..f59243e01 100644 --- a/tests/ui_json/forms_test.py +++ b/tests/ui_json/forms_test.py @@ -655,6 +655,24 @@ def test_data_range_form(): set(form.flatten()).difference({"is_complement", "property", "value"}) == set() ) + # Set only the range + form.set_value([0.5, 1.5]) + assert form.value == [0.5, 1.5] + + # Set only the property + form.set_value(None) + assert form.property is None + + # Set value as coming from form.flatten() + values = { + "is_complement": False, + "property": uuid.uuid4(), + "value": [0.0, 1.0], + } + form.set_value(values) + + assert all(val == getattr(form, key) for key, val in values.items()) + def test_flatten(sample_form): param = sample_form(label="my_param", value=2) From 2f4a63157f4701b0810e910144ae8e161a9c32a1 Mon Sep 17 00:00:00 2001 From: domfournier Date: Mon, 6 Apr 2026 21:55:13 -0700 Subject: [PATCH 37/45] Fix set_values for is_optional, clean ups --- geoh5py/ui_json/forms.py | 2 +- geoh5py/ui_json/ui_json.py | 10 ++++------ 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/geoh5py/ui_json/forms.py b/geoh5py/ui_json/forms.py index 252fc8818..ea2aa9727 100644 --- a/geoh5py/ui_json/forms.py +++ b/geoh5py/ui_json/forms.py @@ -160,7 +160,7 @@ def is_optional(self) -> bool: """ Whether the field is optional or not. """ - return self.optional or self.group_optional + return self.optional or self.group_optional or len(self.dependency) > 0 class StringForm(BaseForm): diff --git a/geoh5py/ui_json/ui_json.py b/geoh5py/ui_json/ui_json.py index 1424c43f2..4ceffc995 100644 --- a/geoh5py/ui_json/ui_json.py +++ b/geoh5py/ui_json/ui_json.py @@ -266,11 +266,7 @@ def set_enabled(self, copy: bool = False, **states) -> UIJson: if not isinstance(form, BaseForm): continue - if ( - not value - and not self._form_dependencies.get(field, {}) - and not form.is_optional - ): + if not value and not form.is_optional: raise ValueError(f"Field {field} enabled state cannot be False.") form.enabled = value @@ -305,7 +301,9 @@ def set_values(self, copy: bool = False, **kwargs) -> UIJson: for field, value in kwargs.items(): form = getattr(uijson, field, None) if isinstance(form, BaseForm): - form.set_value(value) + if not (form.is_optional and value is None): + form.set_value(value) + self.set_enabled(copy=False, **{field: value is not None}) else: setattr(uijson, field, dict_mapper(value, [entity2uuid])) From a615d3d04473dd5bed1bce70898f620aa910326e Mon Sep 17 00:00:00 2001 From: Matthieu Cedou Date: Tue, 7 Apr 2026 11:04:39 -0400 Subject: [PATCH 38/45] not perfect yet.... --- geoh5py/ui_json/ui_json.py | 20 ++++++++++++++++---- geoh5py/ui_json/validation.py | 5 ++++- 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/geoh5py/ui_json/ui_json.py b/geoh5py/ui_json/ui_json.py index 4ceffc995..5a8c736d3 100644 --- a/geoh5py/ui_json/ui_json.py +++ b/geoh5py/ui_json/ui_json.py @@ -125,7 +125,8 @@ def flatten(self, skip_disabled=False, active_only=False) -> dict[str, Any]: """ data = {} fields = self.model_fields_set if active_only else self.model_fields - for field in fields: + # todo: strange ype issue: I did not changed this part + for field in fields: # pylint: disable=not-an-iterable if skip_disabled and not self.is_enabled(field): continue @@ -324,8 +325,7 @@ def to_params( specific params (options) class. If validate=True, the content is validated and errors are raised if any validations fail. """ - - data = self.flatten(skip_disabled=True, active_only=True) + data = self.flatten(skip_disabled=True, active_only=False) with ( fetch_active_workspace(workspace) @@ -457,6 +457,8 @@ def _get_dependency_links( """ form_dependencies: dict[str, dict[str, bool]] = {} group_dependencies: dict[str, BaseForm] = {} + to_update: dict[str, dict[str, bool]] = {} + for name in self.__class__.model_fields.keys(): form_dependencies[name] = {} form = getattr(self, name) @@ -480,8 +482,18 @@ def _get_dependency_links( DependencyType.ENABLED, DependencyType.SHOW, ] + # Add reverse linkage - form_dependencies[dependents_on].update({name: mirrors}) + if dependents_on in form_dependencies: + form_dependencies[dependents_on].update({name: mirrors}) + else: + to_update[dependents_on] = {name: mirrors} + + # add saved reverse linkages if they exist + if name in to_update: + form_dependencies[name].update(to_update[name]) + del to_update[name] + form_dependencies[name][dependents_on] = mirrors return group_dependencies, form_dependencies diff --git a/geoh5py/ui_json/validation.py b/geoh5py/ui_json/validation.py index 8b1fe635d..bc539b35b 100644 --- a/geoh5py/ui_json/validation.py +++ b/geoh5py/ui_json/validation.py @@ -376,8 +376,11 @@ def dependency_type_validation(name: str, data: dict[str, Any], ui_json: UIJson) dependency = form.dependency dependency_form = getattr(ui_json, dependency) + # note: if the data is unset, it does not appear in the data dictionary + # so it only works with dependency disabled + # should we do another check => if dependency enabled and False raises error? if "optional" not in dependency_form.model_fields_set and not isinstance( - data[dependency], bool + data.get(dependency, False), bool ): raise UIJsonError( f"Dependency {dependency} must be either optional or of boolean type." From 07cab697656160e92c7ff26fbf68aa1115def4ac Mon Sep 17 00:00:00 2001 From: domfournier Date: Tue, 7 Apr 2026 10:25:31 -0700 Subject: [PATCH 39/45] OUtift flatten and set_values for GroupMultiDataForm --- geoh5py/ui_json/forms.py | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/geoh5py/ui_json/forms.py b/geoh5py/ui_json/forms.py index ea2aa9727..16b1e13f4 100644 --- a/geoh5py/ui_json/forms.py +++ b/geoh5py/ui_json/forms.py @@ -542,6 +542,27 @@ def to_list(cls, value: str | list[str]) -> str | list[str]: raise TypeError(f"'value' must be a list of strings; got '{type(value)}'") return value + def flatten(self) -> dict: + """Returns the property, data and is_complement values for the form.""" + return { + "group_value": self.group_value, + "value": self.value, + } + + def set_value(self, value: Any): + """ + Set the form value. + """ + if isinstance(value, dict): + for key, val in value.items(): + setattr(self, key, val) + + if isinstance(value, list | str): + self.value = value + + if isinstance(value, UUID | None): + self.group_value = value + class DataOrValueForm(DataFormMixin, BaseForm): """ From 341651bda4c2287c14cf40ed56e6b43b94f7e3be Mon Sep 17 00:00:00 2001 From: domfournier Date: Wed, 8 Apr 2026 11:21:28 -0700 Subject: [PATCH 40/45] Switch back active only in to_params --- geoh5py/ui_json/ui_json.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/geoh5py/ui_json/ui_json.py b/geoh5py/ui_json/ui_json.py index 5a8c736d3..f72624d08 100644 --- a/geoh5py/ui_json/ui_json.py +++ b/geoh5py/ui_json/ui_json.py @@ -325,7 +325,7 @@ def to_params( specific params (options) class. If validate=True, the content is validated and errors are raised if any validations fail. """ - data = self.flatten(skip_disabled=True, active_only=False) + data = self.flatten(skip_disabled=True, active_only=True) with ( fetch_active_workspace(workspace) From 270db73134b78d5e99ac8acd61c802c044754f46 Mon Sep 17 00:00:00 2001 From: domfournier Date: Wed, 8 Apr 2026 11:22:04 -0700 Subject: [PATCH 41/45] Remove pylint disable --- geoh5py/ui_json/ui_json.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/geoh5py/ui_json/ui_json.py b/geoh5py/ui_json/ui_json.py index f72624d08..b9b62bfa8 100644 --- a/geoh5py/ui_json/ui_json.py +++ b/geoh5py/ui_json/ui_json.py @@ -126,7 +126,7 @@ def flatten(self, skip_disabled=False, active_only=False) -> dict[str, Any]: data = {} fields = self.model_fields_set if active_only else self.model_fields # todo: strange ype issue: I did not changed this part - for field in fields: # pylint: disable=not-an-iterable + for field in fields: if skip_disabled and not self.is_enabled(field): continue From 754db702feb1b94e1bb8b30fe153de6aec765424 Mon Sep 17 00:00:00 2001 From: domfournier Date: Wed, 8 Apr 2026 11:24:29 -0700 Subject: [PATCH 42/45] Simplify logic is dependencies not ordered --- geoh5py/ui_json/ui_json.py | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/geoh5py/ui_json/ui_json.py b/geoh5py/ui_json/ui_json.py index b9b62bfa8..d4f671fa1 100644 --- a/geoh5py/ui_json/ui_json.py +++ b/geoh5py/ui_json/ui_json.py @@ -457,7 +457,6 @@ def _get_dependency_links( """ form_dependencies: dict[str, dict[str, bool]] = {} group_dependencies: dict[str, BaseForm] = {} - to_update: dict[str, dict[str, bool]] = {} for name in self.__class__.model_fields.keys(): form_dependencies[name] = {} @@ -484,16 +483,10 @@ def _get_dependency_links( ] # Add reverse linkage - if dependents_on in form_dependencies: - form_dependencies[dependents_on].update({name: mirrors}) - else: - to_update[dependents_on] = {name: mirrors} - - # add saved reverse linkages if they exist - if name in to_update: - form_dependencies[name].update(to_update[name]) - del to_update[name] + if dependents_on not in form_dependencies: + form_dependencies[dependents_on] = {} + form_dependencies[dependents_on].update({name: mirrors}) form_dependencies[name][dependents_on] = mirrors return group_dependencies, form_dependencies From 2f22c9dfb8eb1b8a005a133e847a284626497935 Mon Sep 17 00:00:00 2001 From: domfournier Date: Wed, 8 Apr 2026 15:22:14 -0700 Subject: [PATCH 43/45] Docstrings and unknown logic --- geoh5py/ui_json/ui_json.py | 18 +++++++++++++++++- geoh5py/ui_json/validation.py | 11 +++-------- 2 files changed, 20 insertions(+), 9 deletions(-) diff --git a/geoh5py/ui_json/ui_json.py b/geoh5py/ui_json/ui_json.py index d4f671fa1..da65cd8bf 100644 --- a/geoh5py/ui_json/ui_json.py +++ b/geoh5py/ui_json/ui_json.py @@ -385,6 +385,12 @@ def to_ui_json_group( @field_validator("geoh5", mode="after") @classmethod def valid_geoh5_extension(cls, path: Path | None) -> Path | None: + """ + Check if the input has a valid geoh5 extension. + + :param path: Path to the file to check. + :return: Return Path if provided + """ if path is not None and path.suffix != ".geoh5": raise ValueError( f"Workspace path: {path} must have a '.geoh5' file extension." @@ -394,6 +400,12 @@ def valid_geoh5_extension(cls, path: Path | None) -> Path | None: @field_validator("geoh5", mode="after") @classmethod def workspace_path_exists(cls, path: Path | None) -> Path | None: + """ + Check if the workspace path exists. + + :param path: Path to the file to check. + :return: Return Path if provided + """ if path is not None and not path.exists(): raise FileNotFoundError(f"geoh5 path {path} does not exist.") return path @@ -403,6 +415,8 @@ def write(self, path: Path) -> Path: Write the UIJson object to file. :param path: Path to write the .ui.json file. + + :return: Return path to the ui_json file. """ with open(path, "w", encoding="utf-8") as file: data = self.model_dump_json(indent=4, exclude_unset=True, by_alias=True) @@ -459,7 +473,9 @@ def _get_dependency_links( group_dependencies: dict[str, BaseForm] = {} for name in self.__class__.model_fields.keys(): - form_dependencies[name] = {} + if name not in form_dependencies: + form_dependencies[name] = {} + form = getattr(self, name) if not isinstance(form, BaseForm): diff --git a/geoh5py/ui_json/validation.py b/geoh5py/ui_json/validation.py index bc539b35b..556673a7d 100644 --- a/geoh5py/ui_json/validation.py +++ b/geoh5py/ui_json/validation.py @@ -363,12 +363,12 @@ def throw(self): raise UIJsonError(message) -def dependency_type_validation(name: str, data: dict[str, Any], ui_json: UIJson): +def dependency_type_validation(name: str, _, ui_json: UIJson): """ Validate that the dependency for is optional or bool type. :param name: Name of the form - :param data: Input data with known validations. + :param _: Input data with known validations. :param ui_json: A UIJson object. """ @@ -376,12 +376,7 @@ def dependency_type_validation(name: str, data: dict[str, Any], ui_json: UIJson) dependency = form.dependency dependency_form = getattr(ui_json, dependency) - # note: if the data is unset, it does not appear in the data dictionary - # so it only works with dependency disabled - # should we do another check => if dependency enabled and False raises error? - if "optional" not in dependency_form.model_fields_set and not isinstance( - data.get(dependency, False), bool - ): + if "optional" not in dependency_form.model_fields_set: raise UIJsonError( f"Dependency {dependency} must be either optional or of boolean type." ) From 2a77d39a5991330ee5f409c5730ed1531973316c Mon Sep 17 00:00:00 2001 From: domfournier Date: Wed, 8 Apr 2026 15:27:00 -0700 Subject: [PATCH 44/45] Bring back logic --- geoh5py/ui_json/validation.py | 5 ++++- tests/ui_json/uijson_test.py | 1 + 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/geoh5py/ui_json/validation.py b/geoh5py/ui_json/validation.py index 556673a7d..05c59f664 100644 --- a/geoh5py/ui_json/validation.py +++ b/geoh5py/ui_json/validation.py @@ -43,6 +43,7 @@ UUIDValidator, ValueValidator, ) +from geoh5py.ui_json.forms import BoolForm from geoh5py.ui_json.utils import requires_value @@ -376,7 +377,9 @@ def dependency_type_validation(name: str, _, ui_json: UIJson): dependency = form.dependency dependency_form = getattr(ui_json, dependency) - if "optional" not in dependency_form.model_fields_set: + if "optional" not in dependency_form.model_fields_set and not isinstance( + dependency_form, BoolForm + ): raise UIJsonError( f"Dependency {dependency} must be either optional or of boolean type." ) diff --git a/tests/ui_json/uijson_test.py b/tests/ui_json/uijson_test.py index 4fc76ea32..dd68ed235 100644 --- a/tests/ui_json/uijson_test.py +++ b/tests/ui_json/uijson_test.py @@ -270,6 +270,7 @@ class MyUIJson(UIJson): }, } uijson = generate_test_uijson(ws, uijson=MyUIJson, data=kwargs) + params = uijson.to_params() assert params["my_dependent_parameter"] == "test" From efe92865f79d414966f2517c27b88bc084e70712 Mon Sep 17 00:00:00 2001 From: domfournier Date: Thu, 9 Apr 2026 07:57:57 -0700 Subject: [PATCH 45/45] Move dependency_type_validation inside link checkers --- geoh5py/ui_json/ui_json.py | 2 ++ geoh5py/ui_json/validation.py | 13 ++++--------- tests/ui_json/forms_test.py | 9 --------- tests/ui_json/uijson_test.py | 6 ++---- 4 files changed, 8 insertions(+), 22 deletions(-) diff --git a/geoh5py/ui_json/ui_json.py b/geoh5py/ui_json/ui_json.py index da65cd8bf..3903906c8 100644 --- a/geoh5py/ui_json/ui_json.py +++ b/geoh5py/ui_json/ui_json.py @@ -45,6 +45,7 @@ from geoh5py.ui_json.validation import ( ErrorPool, UIJsonError, + dependency_type_validation, get_validations, promote_or_catch, ) @@ -493,6 +494,7 @@ def _get_dependency_links( # If optional, enabled state only influences the form if dependents_on and not getattr(form, "optional", False): + dependency_type_validation(dependents_on, self) mirrors = getattr(form, "dependency_type", None) in [ DependencyType.ENABLED, DependencyType.SHOW, diff --git a/geoh5py/ui_json/validation.py b/geoh5py/ui_json/validation.py index 05c59f664..5e93f4715 100644 --- a/geoh5py/ui_json/validation.py +++ b/geoh5py/ui_json/validation.py @@ -364,24 +364,20 @@ def throw(self): raise UIJsonError(message) -def dependency_type_validation(name: str, _, ui_json: UIJson): +def dependency_type_validation(name: str, ui_json: UIJson): """ - Validate that the dependency for is optional or bool type. + Validate that the for depending on is optional or bool type. :param name: Name of the form - :param _: Input data with known validations. :param ui_json: A UIJson object. """ - - form = getattr(ui_json, name) - dependency = form.dependency - dependency_form = getattr(ui_json, dependency) + dependency_form = getattr(ui_json, name) if "optional" not in dependency_form.model_fields_set and not isinstance( dependency_form, BoolForm ): raise UIJsonError( - f"Dependency {dependency} must be either optional or of boolean type." + f"Dependency form '{name}' must be either optional or of boolean type." ) @@ -469,7 +465,6 @@ def promote_or_catch( VALIDATIONS_MAP = { - "dependency": dependency_type_validation, "mesh_type": mesh_type_validation, "parent": parent_validation, } diff --git a/tests/ui_json/forms_test.py b/tests/ui_json/forms_test.py index f59243e01..7b8ff0c48 100644 --- a/tests/ui_json/forms_test.py +++ b/tests/ui_json/forms_test.py @@ -139,15 +139,6 @@ def test_base_form_serieralization(sample_form): assert "dependencyType" in json -def test_hide_dependency_type(tmp_path): - with Workspace.create(tmp_path / "test.geoh5") as ws: - form = StringForm( - label="name", value="test", dependency="my_param", dependency_type="show" - ) - form = setup_from_uijson(ws, form) - assert form.dependency_type == "show" - - def test_string_form(): form = StringForm(label="name", value="test") assert form.label == "name" diff --git a/tests/ui_json/uijson_test.py b/tests/ui_json/uijson_test.py index dd68ed235..90841a4f0 100644 --- a/tests/ui_json/uijson_test.py +++ b/tests/ui_json/uijson_test.py @@ -222,6 +222,7 @@ class MyUIJson(UIJson): "label": "other test", "mesh_type": [Points], "value": other_pts.uid, + "optional": True, }, "my_data_parameter": { "label": "data", @@ -245,9 +246,6 @@ class MyUIJson(UIJson): "Object's mesh type must be one of []" in str(err.value) ) - assert "Dependency my_other_object_parameter must be either optional or" in str( - err.value - ) def test_validate_dependency_type_validation(tmp_path): @@ -287,7 +285,7 @@ class MyUIJson(UIJson): # Non-optional non-bool dependency is invalid kwargs["my_parameter"].pop("optional") - msg = "Dependency my_parameter must be either optional or of boolean type" + msg = "Dependency form 'my_parameter' must be either optional or of boolean type" with pytest.raises(UIJsonError, match=msg): uijson = generate_test_uijson(ws, uijson=MyUIJson, data=kwargs) _ = uijson.to_params()