From d129c2e58f866d5a04b799916a95979ab1557715 Mon Sep 17 00:00:00 2001 From: domfournier Date: Fri, 27 Mar 2026 09:27:49 -0700 Subject: [PATCH 01/10] Move annotations to common module --- geoh5py/ui_json/annotations.py | 73 ++++++++++++++++++++++++----- geoh5py/ui_json/forms.py | 85 ++++++++-------------------------- 2 files changed, 81 insertions(+), 77 deletions(-) diff --git a/geoh5py/ui_json/annotations.py b/geoh5py/ui_json/annotations.py index 33f9c982e..52baf8fa8 100644 --- a/geoh5py/ui_json/annotations.py +++ b/geoh5py/ui_json/annotations.py @@ -18,27 +18,30 @@ # '''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''' import logging +from pathlib import Path from typing import Annotated, Any from uuid import UUID from pydantic import BeforeValidator, Field, PlainSerializer -from geoh5py.ui_json.validations.form import empty_string_to_none, uuid_to_string +from geoh5py.groups import Group +from geoh5py.objects import ObjectBase +from geoh5py.shared.validators import ( + to_class, + to_list, + to_path, + to_type_uid_or_class, + types_to_string, +) +from geoh5py.ui_json.validations.form import ( + empty_string_to_none, + uuid_to_string, + uuid_to_string_or_numeric, +) logger = logging.getLogger(__name__) -OptionalUUIDList = Annotated[ - list[UUID] | None, # pylint: disable=unsupported-binary-operation - BeforeValidator(empty_string_to_none), - PlainSerializer(uuid_to_string), -] - -OptionalValueList = Annotated[ - float | list[float] | None, - BeforeValidator(empty_string_to_none), -] - def deprecate(value, info): """Issue deprecation warning.""" @@ -51,3 +54,49 @@ def deprecate(value, info): Field(exclude=True), BeforeValidator(deprecate), ] + +GroupTypes = Annotated[ + list[type[Group]], + BeforeValidator(to_class), + BeforeValidator(to_type_uid_or_class), + BeforeValidator(to_list), + PlainSerializer(types_to_string, when_used="json"), +] + +MeshTypes = Annotated[ + list[type[ObjectBase]], + BeforeValidator(to_class), + BeforeValidator(to_type_uid_or_class), + BeforeValidator(to_list), + PlainSerializer(types_to_string, when_used="json"), +] + +OptionalUUID = Annotated[ + UUID | None, + BeforeValidator(empty_string_to_none), + PlainSerializer(uuid_to_string), +] + +OptionalUUIDList = Annotated[ + list[UUID] | None, + BeforeValidator(empty_string_to_none), + PlainSerializer(uuid_to_string), +] + + +OptionalValueList = Annotated[ + float | list[float] | None, + BeforeValidator(empty_string_to_none), +] + +PathList = Annotated[ + list[Path], + BeforeValidator(to_path), + BeforeValidator(to_list), +] + +UUIDOrNumber = Annotated[ + UUID | float | int | None, + BeforeValidator(empty_string_to_none), + PlainSerializer(uuid_to_string_or_numeric), +] diff --git a/geoh5py/ui_json/forms.py b/geoh5py/ui_json/forms.py index a0b327c32..892c05a88 100644 --- a/geoh5py/ui_json/forms.py +++ b/geoh5py/ui_json/forms.py @@ -22,14 +22,13 @@ from enum import Enum from pathlib import Path -from typing import Annotated, Any +from typing import Any from uuid import UUID import numpy as np from pydantic import ( BaseModel, ConfigDict, - PlainSerializer, TypeAdapter, ValidationError, field_serializer, @@ -37,23 +36,16 @@ model_validator, ) from pydantic.alias_generators import to_camel, to_snake -from pydantic.functional_validators import BeforeValidator from geoh5py.data import DataAssociationEnum, DataTypeEnum -from geoh5py.groups import Group, GroupTypeEnum -from geoh5py.objects import ObjectBase -from geoh5py.shared.validators import ( - to_class, - to_list, - to_path, - to_type_uid_or_class, - types_to_string, -) -from geoh5py.ui_json.annotations import OptionalUUIDList, OptionalValueList -from geoh5py.ui_json.validations.form import ( - empty_string_to_none, - uuid_to_string, - uuid_to_string_or_numeric, +from geoh5py.groups import GroupTypeEnum +from geoh5py.ui_json.annotations import ( + GroupTypes, + MeshTypes, + OptionalUUID, + OptionalUUIDList, + OptionalValueList, + PathList, ) @@ -64,6 +56,17 @@ class DependencyType(str, Enum): HIDE = "hide" +Association = Enum( # type: ignore + "Association", + [(k.name, k.name.capitalize()) for k in DataAssociationEnum], + type=str, +) + +DataType = Enum( # type: ignore + "DataType", [(k.name, k.name.capitalize()) for k in DataTypeEnum], type=str +) + + class BaseForm(BaseModel): """ Base class for uijson forms @@ -304,13 +307,6 @@ def valid_choice(self): return self -PathList = Annotated[ - list[Path], - BeforeValidator(to_path), - BeforeValidator(to_list), -] - - class FileForm(BaseForm): """ File path uijson form. @@ -428,21 +424,6 @@ def force_file_description(cls, _): return ["Directory"] -MeshTypes = Annotated[ - list[type[ObjectBase]], - BeforeValidator(to_class), - BeforeValidator(to_type_uid_or_class), - BeforeValidator(to_list), - PlainSerializer(types_to_string, when_used="json"), -] - -OptionalUUID = Annotated[ - UUID | None, # pylint: disable=unsupported-binary-operation - BeforeValidator(empty_string_to_none), - PlainSerializer(uuid_to_string), -] - - class ObjectForm(BaseForm): """ Geoh5py object uijson form. @@ -457,15 +438,6 @@ class ObjectForm(BaseForm): mesh_type: MeshTypes -GroupTypes = Annotated[ - list[type[Group]], - BeforeValidator(to_class), - BeforeValidator(to_type_uid_or_class), - BeforeValidator(to_list), - PlainSerializer(types_to_string, when_used="json"), -] - - class GroupForm(BaseForm): """ Geoh5py group uijson form. @@ -480,23 +452,6 @@ class GroupForm(BaseForm): group_type: GroupTypes -Association = Enum( # type: ignore - "Association", - [(k.name, k.name.capitalize()) for k in DataAssociationEnum], - type=str, -) - -DataType = Enum( # type: ignore - "DataType", [(k.name, k.name.capitalize()) for k in DataTypeEnum], type=str -) - -UUIDOrNumber = Annotated[ - UUID | float | int | None, # pylint: disable=unsupported-binary-operation - BeforeValidator(empty_string_to_none), - PlainSerializer(uuid_to_string_or_numeric), -] - - class DataFormMixin(BaseModel): """ Mixin class to add common attributes a series of data classes. From cbbfedcc696b8cb7bf7268b7bc145d997b8a3f2c Mon Sep 17 00:00:00 2001 From: domfournier Date: Fri, 27 Mar 2026 11:20:07 -0700 Subject: [PATCH 02/10] Add entity_to_uuid as BeforeValidators --- geoh5py/ui_json/annotations.py | 10 +++---- geoh5py/ui_json/validations/form.py | 41 +++++++++++++---------------- 2 files changed, 21 insertions(+), 30 deletions(-) diff --git a/geoh5py/ui_json/annotations.py b/geoh5py/ui_json/annotations.py index 52baf8fa8..283fde29b 100644 --- a/geoh5py/ui_json/annotations.py +++ b/geoh5py/ui_json/annotations.py @@ -35,8 +35,8 @@ ) from geoh5py.ui_json.validations.form import ( empty_string_to_none, + entity_to_uuid, uuid_to_string, - uuid_to_string_or_numeric, ) @@ -74,12 +74,14 @@ def deprecate(value, info): OptionalUUID = Annotated[ UUID | None, BeforeValidator(empty_string_to_none), + BeforeValidator(entity_to_uuid), PlainSerializer(uuid_to_string), ] OptionalUUIDList = Annotated[ list[UUID] | None, BeforeValidator(empty_string_to_none), + BeforeValidator(entity_to_uuid), PlainSerializer(uuid_to_string), ] @@ -94,9 +96,3 @@ def deprecate(value, info): BeforeValidator(to_path), BeforeValidator(to_list), ] - -UUIDOrNumber = Annotated[ - UUID | float | int | None, - BeforeValidator(empty_string_to_none), - PlainSerializer(uuid_to_string_or_numeric), -] diff --git a/geoh5py/ui_json/validations/form.py b/geoh5py/ui_json/validations/form.py index 734ba3a6b..280011f10 100644 --- a/geoh5py/ui_json/validations/form.py +++ b/geoh5py/ui_json/validations/form.py @@ -17,26 +17,10 @@ # along with geoh5py. If not, see . ' # '''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''' +from typing import Any from uuid import UUID - -UidOrNumeric = UUID | float | int | None -StringOrNumeric = str | float | int - - -def uuid_to_string(value: UUID | list[UUID] | None) -> str | list[str]: - """Serialize UUID(s) as a string.""" - - def convert(value: UUID | None) -> str: - if value is None: - return "" - if isinstance(value, UUID): - return f"{{{value!s}}}" - return value - - if isinstance(value, list): - return [convert(v) for v in value] - return convert(value) +from geoh5py.shared import Entity def empty_string_to_none(value): @@ -46,14 +30,25 @@ def empty_string_to_none(value): return value -def uuid_to_string_or_numeric( - value: UidOrNumeric | list[UidOrNumeric], -) -> StringOrNumeric | list[StringOrNumeric]: - def convert(value: UidOrNumeric) -> StringOrNumeric: +def entity_to_uuid(value: Any | list[Entity] | Entity) -> Any | list[UUID] | UUID: + """Demote an Entity to its UUID, and pass all other values.""" + if isinstance(value, list | tuple): + return [entity_to_uuid(val) for val in value] + + if isinstance(value, Entity): + return value.uid + + return value + + +def uuid_to_string(value: UUID | list[UUID] | None) -> str | list[str]: + """Serialize UUID(s) as a string.""" + + def convert(value: UUID | None) -> str: if value is None: return "" if isinstance(value, UUID): - return f"{{{value}}}" + return f"{{{value!s}}}" return value if isinstance(value, list): From e82db86acc4ebb12aea943591513cd3bea9e9d39 Mon Sep 17 00:00:00 2001 From: domfournier Date: Fri, 27 Mar 2026 11:20:37 -0700 Subject: [PATCH 03/10] Augment tests --- tests/ui_json/forms_test.py | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/tests/ui_json/forms_test.py b/tests/ui_json/forms_test.py index fae8010f7..5f3e8fc22 100644 --- a/tests/ui_json/forms_test.py +++ b/tests/ui_json/forms_test.py @@ -430,6 +430,10 @@ def test_object_form_mesh_type_as_classes(tmp_path): assert isinstance(ws.get_entity(form.value)[0], tuple(form.mesh_type)) + form_entity = ObjectForm(label="name", value=points, mesh_type=[Points]) + + assert form.value == form_entity.value + def test_object_form_empty_string_handling(): form = ObjectForm(label="name", value="", mesh_type=[Points, Surface]) @@ -541,6 +545,25 @@ def test_multichoice_data_form(): ) assert form.value == [uuid.UUID(data_uid_1), uuid.UUID(data_uid_2)] + ws = Workspace() + obj = Points.create(ws, vertices=np.random.randn(100, 3)) + data_list = obj.add_data( + {f"data{i}": {"values": np.random.randn(100)} for i in range(5)} + ) + + form = MultiSelectDataForm( + label="name", + value=data_list, + parent="my_param", + association="Vertex", + data_type="Float", + multi_select=True, + ) + + assert all( + data.uid == val for data, val in zip(data_list, form.value, strict=False) + ) + def test_multichoice_data_form_serialization(): data_uid_1 = f"{{{uuid.uuid4()!s}}}" From dfe9abb7430357a9ccbfbf2471eeeb3b1b0918ad Mon Sep 17 00:00:00 2001 From: domfournier Date: Mon, 30 Mar 2026 08:02:46 -0700 Subject: [PATCH 04/10] Update tests/ui_json/forms_test.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- tests/ui_json/forms_test.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/tests/ui_json/forms_test.py b/tests/ui_json/forms_test.py index 5f3e8fc22..854bda18f 100644 --- a/tests/ui_json/forms_test.py +++ b/tests/ui_json/forms_test.py @@ -560,9 +560,8 @@ def test_multichoice_data_form(): multi_select=True, ) - assert all( - data.uid == val for data, val in zip(data_list, form.value, strict=False) - ) + assert len(data_list) == len(form.value) + assert all(data.uid == val for data, val in zip(data_list, form.value)) def test_multichoice_data_form_serialization(): From 2cd3d0f9dc2b5b0b743ae3e243ec326d62589bc1 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 30 Mar 2026 15:03:09 +0000 Subject: [PATCH 05/10] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- tests/ui_json/forms_test.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/ui_json/forms_test.py b/tests/ui_json/forms_test.py index 854bda18f..212643f58 100644 --- a/tests/ui_json/forms_test.py +++ b/tests/ui_json/forms_test.py @@ -561,7 +561,9 @@ def test_multichoice_data_form(): ) assert len(data_list) == len(form.value) - assert all(data.uid == val for data, val in zip(data_list, form.value)) + assert all( + data.uid == val for data, val in zip(data_list, form.value, strict=False) + ) def test_multichoice_data_form_serialization(): From 0c8ff2502c254346ecddb269b4883e1e92667d69 Mon Sep 17 00:00:00 2001 From: domfournier Date: Mon, 30 Mar 2026 11:55:57 -0700 Subject: [PATCH 06/10] MOve things around. Remove duplicated functions for stringify # Conflicts: # geoh5py/ui_json/annotations.py # geoh5py/ui_json/forms.py # geoh5py/ui_json/validations/form.py --- geoh5py/shared/utils.py | 2 + geoh5py/ui_json/annotations.py | 20 ++---- geoh5py/ui_json/forms.py | 11 +++ geoh5py/ui_json/ui_json.py | 12 ++-- geoh5py/ui_json/utils.py | 17 ++++- geoh5py/ui_json/validation.py | 83 +++++++++++++++++++++++ geoh5py/ui_json/validations/__init__.py | 40 ----------- geoh5py/ui_json/validations/form.py | 56 --------------- geoh5py/ui_json/validations/uijson.py | 90 ------------------------- tests/ui_json/uijson_test.py | 2 +- 10 files changed, 128 insertions(+), 205 deletions(-) delete mode 100644 geoh5py/ui_json/validations/__init__.py delete mode 100644 geoh5py/ui_json/validations/form.py delete mode 100644 geoh5py/ui_json/validations/uijson.py diff --git a/geoh5py/shared/utils.py b/geoh5py/shared/utils.py index cec533ea6..93768e5d1 100644 --- a/geoh5py/shared/utils.py +++ b/geoh5py/shared/utils.py @@ -39,6 +39,8 @@ 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/annotations.py b/geoh5py/ui_json/annotations.py index 283fde29b..7bb0f1d0e 100644 --- a/geoh5py/ui_json/annotations.py +++ b/geoh5py/ui_json/annotations.py @@ -26,6 +26,7 @@ from geoh5py.groups import Group from geoh5py.objects import ObjectBase +from geoh5py.shared.utils import stringify from geoh5py.shared.validators import ( to_class, to_list, @@ -33,11 +34,7 @@ to_type_uid_or_class, types_to_string, ) -from geoh5py.ui_json.validations.form import ( - empty_string_to_none, - entity_to_uuid, - uuid_to_string, -) +from geoh5py.ui_json.utils import optional_uuid_mapper logger = logging.getLogger(__name__) @@ -73,22 +70,19 @@ def deprecate(value, info): OptionalUUID = Annotated[ UUID | None, - BeforeValidator(empty_string_to_none), - BeforeValidator(entity_to_uuid), - PlainSerializer(uuid_to_string), + BeforeValidator(optional_uuid_mapper), + PlainSerializer(stringify), ] OptionalUUIDList = Annotated[ list[UUID] | None, - BeforeValidator(empty_string_to_none), - BeforeValidator(entity_to_uuid), - PlainSerializer(uuid_to_string), + BeforeValidator(optional_uuid_mapper), + PlainSerializer(stringify), ] - OptionalValueList = Annotated[ float | list[float] | None, - BeforeValidator(empty_string_to_none), + BeforeValidator(optional_uuid_mapper), ] PathList = Annotated[ diff --git a/geoh5py/ui_json/forms.py b/geoh5py/ui_json/forms.py index 892c05a88..63ce920c5 100644 --- a/geoh5py/ui_json/forms.py +++ b/geoh5py/ui_json/forms.py @@ -452,6 +452,17 @@ class GroupForm(BaseForm): group_type: GroupTypes +Association = Enum( # type: ignore + "Association", + [(k.name, k.name.capitalize()) for k in DataAssociationEnum], + type=str, +) + +DataType = Enum( # type: ignore + "DataType", [(k.name, k.name.capitalize()) for k in DataTypeEnum], type=str +) + + class DataFormMixin(BaseModel): """ Mixin class to add common attributes a series of data classes. diff --git a/geoh5py/ui_json/ui_json.py b/geoh5py/ui_json/ui_json.py index 30a4fdf89..ffc8650ff 100644 --- a/geoh5py/ui_json/ui_json.py +++ b/geoh5py/ui_json/ui_json.py @@ -38,18 +38,22 @@ from geoh5py import Workspace from geoh5py.groups import PropertyGroup, UIJsonGroup from geoh5py.shared import Entity -from geoh5py.shared.utils import fetch_active_workspace, str2uuid, stringify +from geoh5py.shared.utils import ( + fetch_active_workspace, + str2none, + str2uuid, + stringify, +) 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.form import empty_string_to_none +from geoh5py.ui_json.validation import ErrorPool, UIJsonError, get_validations logger = logging.getLogger(__name__) OptionalPath = Annotated[ Path | None, # pylint: disable=unsupported-binary-operation - BeforeValidator(empty_string_to_none), + BeforeValidator(str2none), PlainSerializer(none_to_empty_string), ] diff --git a/geoh5py/ui_json/utils.py b/geoh5py/ui_json/utils.py index e6c4b601a..64d24fe3a 100644 --- a/geoh5py/ui_json/utils.py +++ b/geoh5py/ui_json/utils.py @@ -31,7 +31,12 @@ from geoh5py import Workspace from geoh5py.groups import ContainerGroup, Group from geoh5py.objects import ObjectBase -from geoh5py.shared.utils import fetch_active_workspace +from geoh5py.shared.utils import ( + dict_mapper, + entity2uuid, + fetch_active_workspace, + str2none, +) logger = getLogger(__name__) @@ -531,3 +536,13 @@ def monitored_directory_copy( move(working_path / temp_geoh5, directory_path / temp_geoh5, copy) return str(directory_path / temp_geoh5) + + +def optional_uuid_mapper(value: Any): + """ + Take values and convert them into UUID or None (or list of). + + :param value: Either a string of entity, or list of. + :return: UUID, Nont or list of. + """ + return dict_mapper(value, [str2none, entity2uuid]) diff --git a/geoh5py/ui_json/validation.py b/geoh5py/ui_json/validation.py index b971df522..61d9d9e8f 100644 --- a/geoh5py/ui_json/validation.py +++ b/geoh5py/ui_json/validation.py @@ -20,12 +20,15 @@ from __future__ import annotations +from collections.abc import Callable from copy import deepcopy from typing import Any, cast from uuid import UUID from warnings import warn +from geoh5py.data import Data from geoh5py.groups import PropertyGroup +from geoh5py.objects import ObjectBase from geoh5py.shared import Entity from geoh5py.shared.exceptions import RequiredValidationError from geoh5py.shared.validators import ( @@ -315,3 +318,83 @@ def __call__(self, data, *args): "InputValidators can only be called with dictionary of data or " "(key, value) pair." ) + + +## Validation utility for UIJson class ## +class UIJsonError(Exception): + """Exception raised for errors in the UIJson object.""" + + def __init__(self, message: str): + super().__init__(message) + + +class ErrorPool: # pylint: disable=too-few-public-methods + """Stores validation errors for all UIJson members.""" + + def __init__(self, errors: dict[str, list[Exception]]): + self.pool = errors + + def _format_error_message(self): + """Format the error message for the UIJsonError.""" + + msg = "" + for key, errors in self.pool.items(): + if errors: + msg += f"\t{key}:\n" + for i, error in enumerate(errors): + msg += f"\t\t{i}. {error}\n" + + return msg + + def throw(self): + """Raise the UIJsonError with detailed list of errors per parameter.""" + + message = self._format_error_message() + if message: + message = "Collected UIJson errors:\n" + message + raise UIJsonError(message) + + +def dependency_type_validation(name: str, data: dict[str, Any], params: dict[str, Any]): + """Dependency is optional or bool type.""" + + dependency = params[name]["dependency"] + dependency_form = params[dependency] + if "optional" not in dependency_form and not isinstance(data[dependency], bool): + raise UIJsonError( + f"Dependency {dependency} must be either optional or of boolean type." + ) + + +def get_validations(form_keys: list[str]) -> list[Callable]: + """Returns a list of callable validations based on identifying form keys.""" + return [VALIDATIONS_MAP[k] for k in form_keys if k in VALIDATIONS_MAP] + + +def mesh_type_validation(name: str, data: dict[str, Any], params: dict[str, Any]): + """Promoted value is one of the provided mesh types.""" + + mesh_types = params[name]["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], params: dict[str, Any]): + """Data is a child of the parent object.""" + + form = params[name] + child = data[name] + parent = data[form["parent"]] + + 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']}.") + + +VALIDATIONS_MAP = { + "dependency": dependency_type_validation, + "mesh_type": mesh_type_validation, + "parent": parent_validation, +} diff --git a/geoh5py/ui_json/validations/__init__.py b/geoh5py/ui_json/validations/__init__.py deleted file mode 100644 index 315aea7c9..000000000 --- a/geoh5py/ui_json/validations/__init__.py +++ /dev/null @@ -1,40 +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 collections.abc import Callable - -from .uijson import ( - ErrorPool, - UIJsonError, - dependency_type_validation, - mesh_type_validation, - parent_validation, -) - - -VALIDATIONS_MAP = { - "dependency": dependency_type_validation, - "mesh_type": mesh_type_validation, - "parent": parent_validation, -} - - -def get_validations(form_keys: list[str]) -> list[Callable]: - """Returns a list of callable validations based on identifying form keys.""" - return [VALIDATIONS_MAP[k] for k in form_keys if k in VALIDATIONS_MAP] diff --git a/geoh5py/ui_json/validations/form.py b/geoh5py/ui_json/validations/form.py deleted file mode 100644 index 280011f10..000000000 --- a/geoh5py/ui_json/validations/form.py +++ /dev/null @@ -1,56 +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 typing import Any -from uuid import UUID - -from geoh5py.shared import Entity - - -def empty_string_to_none(value): - """Promote empty string to uid, and pass all other values.""" - if value == "": - return None - return value - - -def entity_to_uuid(value: Any | list[Entity] | Entity) -> Any | list[UUID] | UUID: - """Demote an Entity to its UUID, and pass all other values.""" - if isinstance(value, list | tuple): - return [entity_to_uuid(val) for val in value] - - if isinstance(value, Entity): - return value.uid - - return value - - -def uuid_to_string(value: UUID | list[UUID] | None) -> str | list[str]: - """Serialize UUID(s) as a string.""" - - def convert(value: UUID | None) -> str: - if value is None: - return "" - if isinstance(value, UUID): - return f"{{{value!s}}}" - return value - - if isinstance(value, list): - return [convert(v) for v in value] - return convert(value) diff --git a/geoh5py/ui_json/validations/uijson.py b/geoh5py/ui_json/validations/uijson.py deleted file mode 100644 index f0e61e927..000000000 --- a/geoh5py/ui_json/validations/uijson.py +++ /dev/null @@ -1,90 +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 typing import Any - -from geoh5py.data import Data -from geoh5py.objects import ObjectBase - - -class UIJsonError(Exception): - """Exception raised for errors in the UIJson object.""" - - def __init__(self, message: str): - super().__init__(message) - - -class ErrorPool: # pylint: disable=too-few-public-methods - """Stores validation errors for all UIJson members.""" - - def __init__(self, errors: dict[str, list[Exception]]): - self.pool = errors - - def _format_error_message(self): - """Format the error message for the UIJsonError.""" - - msg = "" - for key, errors in self.pool.items(): - if errors: - msg += f"\t{key}:\n" - for i, error in enumerate(errors): - msg += f"\t\t{i}. {error}\n" - - return msg - - def throw(self): - """Raise the UIJsonError with detailed list of errors per parameter.""" - - message = self._format_error_message() - if message: - message = "Collected UIJson errors:\n" + message - raise UIJsonError(message) - - -def dependency_type_validation(name: str, data: dict[str, Any], params: dict[str, Any]): - """Dependency is optional or bool type.""" - - dependency = params[name]["dependency"] - dependency_form = params[dependency] - if "optional" not in dependency_form and not isinstance(data[dependency], bool): - raise UIJsonError( - f"Dependency {dependency} must be either optional or of boolean type." - ) - - -def mesh_type_validation(name: str, data: dict[str, Any], params: dict[str, Any]): - """Promoted value is one of the provided mesh types.""" - - mesh_types = params[name]["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], params: dict[str, Any]): - """Data is a child of the parent object.""" - - form = params[name] - child = data[name] - parent = data[form["parent"]] - - 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']}.") diff --git a/tests/ui_json/uijson_test.py b/tests/ui_json/uijson_test.py index 383e54a58..5a199f822 100644 --- a/tests/ui_json/uijson_test.py +++ b/tests/ui_json/uijson_test.py @@ -43,7 +43,7 @@ StringForm, ) from geoh5py.ui_json.ui_json import BaseUIJson -from geoh5py.ui_json.validations import UIJsonError +from geoh5py.ui_json.validation import UIJsonError @pytest.fixture From 973a0364c94379b3565e705ffc4542531f6e9be2 Mon Sep 17 00:00:00 2001 From: domfournier Date: Mon, 30 Mar 2026 12:05:39 -0700 Subject: [PATCH 07/10] Use annotated DataTypeEnum instead of new Enum. Remove duplicate function # Conflicts: # geoh5py/shared/utils.py # geoh5py/ui_json/annotations.py # geoh5py/ui_json/forms.py # geoh5py/ui_json/ui_json.py --- geoh5py/data/__init__.py | 15 +++++++++++++++ geoh5py/data/data_association_enum.py | 15 +++++++++++++++ geoh5py/shared/utils.py | 11 +++++++++++ geoh5py/shared/validators.py | 7 ------- geoh5py/ui_json/annotations.py | 14 +++++++++++++- geoh5py/ui_json/forms.py | 19 +++++-------------- geoh5py/ui_json/ui_json.py | 6 +++--- 7 files changed, 62 insertions(+), 25 deletions(-) diff --git a/geoh5py/data/__init__.py b/geoh5py/data/__init__.py index e369bcac2..db5272bd8 100644 --- a/geoh5py/data/__init__.py +++ b/geoh5py/data/__init__.py @@ -86,3 +86,18 @@ def from_primitive_type(cls, primitive_type: PrimitiveTypeEnum) -> type: :return: The data type. """ return DataTypeEnum[primitive_type.name].value + + @classmethod + def _missing_(cls, value) -> DataTypeEnum: + """ + Allows for case-insensitive matching of enum members. + + For example, "Integer" will match "INTEGER". + + :param value: The value to match against the enum members. + """ + if isinstance(value, str): + normalized = value.upper() + if normalized in cls.__members__: + return cls[normalized] + return super()._missing_(value) diff --git a/geoh5py/data/data_association_enum.py b/geoh5py/data/data_association_enum.py index 84e0987c0..1cbec080d 100644 --- a/geoh5py/data/data_association_enum.py +++ b/geoh5py/data/data_association_enum.py @@ -37,3 +37,18 @@ class DataAssociationEnum(Enum): FACE = 4 GROUP = 5 DEPTH = 6 + + @classmethod + def _missing_(cls, value) -> DataAssociationEnum: + """ + Allows for case-insensitive matching of enum members. + + For example, "Cell" will match "CELL". + + :param value: The value to match against the enum members. + """ + if isinstance(value, str): + normalized = value.upper() + if normalized in cls.__members__: + return cls[normalized] + return super()._missing_(value) diff --git a/geoh5py/shared/utils.py b/geoh5py/shared/utils.py index 93768e5d1..1310342b0 100644 --- a/geoh5py/shared/utils.py +++ b/geoh5py/shared/utils.py @@ -1441,3 +1441,14 @@ def map_to_class( class_map[identifier] = member return class_map + + +def enum_name_to_str(value: Enum) -> str: + """ + Convert enum name to capitalized string. + + :param value: Enum value to convert. + + :return: Capitalized string. + """ + return value.name.capitalize() diff --git a/geoh5py/shared/validators.py b/geoh5py/shared/validators.py index 0cd7ecdb0..7efe98e2f 100644 --- a/geoh5py/shared/validators.py +++ b/geoh5py/shared/validators.py @@ -145,13 +145,6 @@ def to_class( return out -def none_to_empty_string(value): - """None transforms to empty string for serialization.""" - if value is None: - return "" - return value - - def types_to_string(types: list) -> list[str] | str: if len(types) > 1: return [f"{{{k.default_type_uid()!s}}}" for k in types] diff --git a/geoh5py/ui_json/annotations.py b/geoh5py/ui_json/annotations.py index 7bb0f1d0e..9de8c97b0 100644 --- a/geoh5py/ui_json/annotations.py +++ b/geoh5py/ui_json/annotations.py @@ -24,9 +24,10 @@ from pydantic import BeforeValidator, Field, PlainSerializer +from geoh5py.data import DataAssociationEnum, DataTypeEnum from geoh5py.groups import Group from geoh5py.objects import ObjectBase -from geoh5py.shared.utils import stringify +from geoh5py.shared.utils import enum_name_to_str, stringify from geoh5py.shared.validators import ( to_class, to_list, @@ -46,6 +47,17 @@ def deprecate(value, info): return value +AssociationOptions = Annotated[ + DataAssociationEnum, + PlainSerializer(enum_name_to_str), +] + +DataTypeOptions = Annotated[ + DataTypeEnum, + PlainSerializer(enum_name_to_str), +] + + Deprecated = Annotated[ Any, Field(exclude=True), diff --git a/geoh5py/ui_json/forms.py b/geoh5py/ui_json/forms.py index 63ce920c5..4deba5b81 100644 --- a/geoh5py/ui_json/forms.py +++ b/geoh5py/ui_json/forms.py @@ -40,6 +40,8 @@ from geoh5py.data import DataAssociationEnum, DataTypeEnum from geoh5py.groups import GroupTypeEnum from geoh5py.ui_json.annotations import ( + AssociationOptions, + DataTypeOptions, GroupTypes, MeshTypes, OptionalUUID, @@ -452,17 +454,6 @@ class GroupForm(BaseForm): group_type: GroupTypes -Association = Enum( # type: ignore - "Association", - [(k.name, k.name.capitalize()) for k in DataAssociationEnum], - type=str, -) - -DataType = Enum( # type: ignore - "DataType", [(k.name, k.name.capitalize()) for k in DataTypeEnum], type=str -) - - class DataFormMixin(BaseModel): """ Mixin class to add common attributes a series of data classes. @@ -479,8 +470,8 @@ class DataFormMixin(BaseModel): """ parent: str - association: Association | list[Association] - data_type: DataType | list[DataType] + association: AssociationOptions | list[AssociationOptions] + data_type: DataTypeOptions | list[DataTypeOptions] class DataForm(DataFormMixin, BaseForm): @@ -528,7 +519,7 @@ class GroupMultiDataForm(BaseForm): group_type: GroupTypes group_value: OptionalUUID - data_type: DataType | list[DataType] + data_type: DataTypeOptions | list[DataTypeOptions] value: str | list[str] multi_select: bool = True diff --git a/geoh5py/ui_json/ui_json.py b/geoh5py/ui_json/ui_json.py index ffc8650ff..17dc3bfd7 100644 --- a/geoh5py/ui_json/ui_json.py +++ b/geoh5py/ui_json/ui_json.py @@ -40,11 +40,11 @@ from geoh5py.shared import Entity from geoh5py.shared.utils import ( fetch_active_workspace, + none2str, str2none, str2uuid, stringify, ) -from geoh5py.shared.validators import none_to_empty_string from geoh5py.ui_json.forms import BaseForm from geoh5py.ui_json.validation import ErrorPool, UIJsonError, get_validations @@ -52,9 +52,9 @@ logger = logging.getLogger(__name__) OptionalPath = Annotated[ - Path | None, # pylint: disable=unsupported-binary-operation + Path | None, BeforeValidator(str2none), - PlainSerializer(none_to_empty_string), + PlainSerializer(none2str), ] From 7ddb8e6340d1edd40e12f945e19d96d0106e7740 Mon Sep 17 00:00:00 2001 From: domfournier Date: Mon, 30 Mar 2026 12:06:09 -0700 Subject: [PATCH 08/10] Update tests --- tests/ui_json/forms_test.py | 29 ++++++++++++++--------------- tests/ui_json/uijson_test.py | 5 +++++ 2 files changed, 19 insertions(+), 15 deletions(-) diff --git a/tests/ui_json/forms_test.py b/tests/ui_json/forms_test.py index 212643f58..b22384973 100644 --- a/tests/ui_json/forms_test.py +++ b/tests/ui_json/forms_test.py @@ -27,10 +27,10 @@ from pydantic import BaseModel, ValidationError from geoh5py import Workspace +from geoh5py.data import DataAssociationEnum, DataTypeEnum from geoh5py.groups import GroupTypeEnum, PropertyGroup from geoh5py.objects import Curve, DrapeModel, Points, Surface from geoh5py.ui_json.forms import ( - Association, BaseForm, BoolForm, ChoiceForm, @@ -38,7 +38,6 @@ DataGroupForm, DataOrValueForm, DataRangeForm, - DataType, DirectoryForm, FileForm, FloatForm, @@ -452,8 +451,8 @@ def test_data_form(): assert form.label == "name" assert form.value == uuid.UUID(data_uid) assert form.parent == "my_param" - assert form.association == "Vertex" - assert form.data_type == "Float" + assert form.association.name == "VERTEX" + assert form.data_type.name == "FLOAT" form = DataForm( label="name", @@ -462,8 +461,8 @@ def test_data_form(): association=["Vertex", "Cell"], data_type=["Float", "Integer"], ) - assert form.association == [Association.VERTEX, Association.CELL] - assert form.data_type == [DataType.FLOAT, DataType.INTEGER] + assert form.association == [DataAssociationEnum.VERTEX, DataAssociationEnum.CELL] + assert form.data_type == [DataTypeEnum.FLOAT, DataTypeEnum.INTEGER] def test_data_group_form(): @@ -480,8 +479,8 @@ def test_data_group_form(): assert form.value == uuid.UUID(group_uid) assert form.data_group_type == GroupTypeEnum.STRIKEDIP assert form.parent == "Da-da" - assert form.association == [Association.VERTEX, Association.CELL] - assert form.data_type == [DataType.FLOAT, DataType.INTEGER] + assert form.association == [DataAssociationEnum.VERTEX, DataAssociationEnum.CELL] + assert form.data_type == [DataTypeEnum.FLOAT, DataTypeEnum.INTEGER] def test_data_or_value_form(): @@ -498,8 +497,8 @@ def test_data_or_value_form(): assert form.label == "name" assert form.value == 0.0 assert form.parent == "my_param" - assert form.association == "Vertex" - assert form.data_type == "Float" + assert form.association.name == "VERTEX" + assert form.data_type.name == "FLOAT" assert not form.is_value assert form.property == uuid.UUID(data_uid) @@ -532,8 +531,8 @@ def test_multichoice_data_form(): assert form.label == "name" assert form.value == [uuid.UUID(data_uid_1)] assert form.parent == "my_param" - assert form.association == "Vertex" - assert form.data_type == "Float" + assert form.association.name == "VERTEX" + assert form.data_type.name == "FLOAT" form = MultiSelectDataForm( label="name", @@ -621,8 +620,8 @@ def test_data_range_form(): assert form.property == uuid.UUID(data_uid) assert form.value == [0.0, 1.0] assert form.parent == "my_param" - assert form.association == "Vertex" - assert form.data_type == "Float" + assert form.association.name == "VERTEX" + assert form.data_type.name == "FLOAT" assert form.range_label == "value range" @@ -886,7 +885,7 @@ def test_multi_data_group_form(): assert form.label == "name" assert form.value == [data_uid_1, data_uid_2] assert form.group_value == uuid.UUID(group_uid) - assert form.data_type == [DataType.FLOAT, DataType.INTEGER] + assert form.data_type == [DataTypeEnum.FLOAT, DataTypeEnum.INTEGER] assert form.multi_select assert form.tooltip == ["some ", "tooltip ", "text"] diff --git a/tests/ui_json/uijson_test.py b/tests/ui_json/uijson_test.py index 5a199f822..cd47c5ea9 100644 --- a/tests/ui_json/uijson_test.py +++ b/tests/ui_json/uijson_test.py @@ -547,6 +547,11 @@ def test_unknown_uijson(tmp_path): assert "my_group_optional_parameter" not in params assert "my_grouped_parameter" not in params + re_loaded = BaseUIJson.read(tmp_path / "test_copy.ui.json") + + for name in uijson.model_fields_set: + assert getattr(re_loaded, name) == getattr(uijson, name) + def test_str_and_repr(tmp_path): Workspace.create(tmp_path / "test.geoh5") From 493d06733330fbc36a159557e933daa807f51b4b Mon Sep 17 00:00:00 2001 From: domfournier Date: Mon, 30 Mar 2026 12:11:58 -0700 Subject: [PATCH 09/10] Remove unused Enums --- geoh5py/ui_json/forms.py | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/geoh5py/ui_json/forms.py b/geoh5py/ui_json/forms.py index 4deba5b81..6897b3f66 100644 --- a/geoh5py/ui_json/forms.py +++ b/geoh5py/ui_json/forms.py @@ -37,7 +37,6 @@ ) from pydantic.alias_generators import to_camel, to_snake -from geoh5py.data import DataAssociationEnum, DataTypeEnum from geoh5py.groups import GroupTypeEnum from geoh5py.ui_json.annotations import ( AssociationOptions, @@ -58,17 +57,6 @@ class DependencyType(str, Enum): HIDE = "hide" -Association = Enum( # type: ignore - "Association", - [(k.name, k.name.capitalize()) for k in DataAssociationEnum], - type=str, -) - -DataType = Enum( # type: ignore - "DataType", [(k.name, k.name.capitalize()) for k in DataTypeEnum], type=str -) - - class BaseForm(BaseModel): """ Base class for uijson forms From e962783a50140f03efe5ef9c01091eaafffeeae5 Mon Sep 17 00:00:00 2001 From: domfournier Date: Mon, 30 Mar 2026 12:52:48 -0700 Subject: [PATCH 10/10] Add docstrings --- geoh5py/ui_json/validation.py | 55 +++++++++++++++++++++++++++-------- 1 file changed, 43 insertions(+), 12 deletions(-) diff --git a/geoh5py/ui_json/validation.py b/geoh5py/ui_json/validation.py index 61d9d9e8f..956579528 100644 --- a/geoh5py/ui_json/validation.py +++ b/geoh5py/ui_json/validation.py @@ -329,7 +329,12 @@ def __init__(self, message: str): class ErrorPool: # pylint: disable=too-few-public-methods - """Stores validation errors for all UIJson members.""" + """ + Stores validation errors for all UIJson members. + + :param errors: Dictionary mapping parameter names to lists of + exceptions encountered during validation. + """ def __init__(self, errors: dict[str, list[Exception]]): self.pool = errors @@ -355,11 +360,19 @@ def throw(self): raise UIJsonError(message) -def dependency_type_validation(name: str, data: dict[str, Any], params: dict[str, Any]): - """Dependency is optional or bool type.""" +def dependency_type_validation( + name: str, data: dict[str, Any], json_dict: dict[str, Any] +): + """ + 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. + """ - dependency = params[name]["dependency"] - dependency_form = params[dependency] + dependency = json_dict[name]["dependency"] + dependency_form = json_dict[dependency] if "optional" not in dependency_form and not isinstance(data[dependency], bool): raise UIJsonError( f"Dependency {dependency} must be either optional or of boolean type." @@ -367,23 +380,41 @@ def dependency_type_validation(name: str, data: dict[str, Any], params: dict[str def get_validations(form_keys: list[str]) -> list[Callable]: - """Returns a list of callable validations based on identifying form keys.""" + """ + Get callable validations based on identifying form keys. + + :param form_keys: List of form keys. + + :return: List of callable validations. + """ return [VALIDATIONS_MAP[k] for k in form_keys if k in VALIDATIONS_MAP] -def mesh_type_validation(name: str, data: dict[str, Any], params: dict[str, Any]): - """Promoted value is one of the provided mesh types.""" +def mesh_type_validation(name: str, data: dict[str, Any], json_dict: dict[str, Any]): + """ + 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. + """ - mesh_types = params[name]["mesh_type"] + mesh_types = json_dict[name]["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], params: dict[str, Any]): - """Data is a child of the parent object.""" +def parent_validation(name: str, data: dict[str, Any], json_dict: dict[str, Any]): + """ + 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. + """ - form = params[name] + form = json_dict[name] child = data[name] parent = data[form["parent"]]