diff --git a/geoh5py/shared/utils.py b/geoh5py/shared/utils.py index 1310342b0..dfa6eb8bf 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: @@ -837,7 +835,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. @@ -846,11 +846,13 @@ def stringify(values: dict[str, Any]) -> dict[str, Any]: :return: Dictionary of string values. """ mappers = [ + type2uuid, entity2uuid, nan2str, inf2str, as_str_if_uuid, none2str, + enum_name_to_str, workspace2path, path2str, ] @@ -889,31 +891,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 @@ -944,8 +921,15 @@ def nan2str(value): return value -def str2none(value): - if 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 @@ -1443,7 +1427,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. @@ -1451,4 +1435,18 @@ 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: + """ + 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/__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/annotations.py b/geoh5py/ui_json/annotations.py index 3cc38f48f..158f88b4d 100644 --- a/geoh5py/ui_json/annotations.py +++ b/geoh5py/ui_json/annotations.py @@ -27,13 +27,18 @@ 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, to_path, to_type_uid_or_class, - types_to_string, ) from geoh5py.ui_json.utils import optional_uuid_mapper @@ -47,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[ @@ -69,7 +73,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, when_used="json"), ] MeshTypes = Annotated[ @@ -77,25 +81,32 @@ 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, when_used="json"), ] OptionalPath = Annotated[ Path | None, BeforeValidator(str2none), - PlainSerializer(none2str), + BeforeValidator(workspace2path), + PlainSerializer(none2str, when_used="json"), +] + +OptionalString = Annotated[ + str | None, + BeforeValidator(str2none), + 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, + list[UUID] | UUID | None, BeforeValidator(optional_uuid_mapper), - PlainSerializer(stringify), + PlainSerializer(stringify, when_used="json"), ] OptionalValueList = Annotated[ @@ -107,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/forms.py b/geoh5py/ui_json/forms.py index c5f3d7cdc..16b1e13f4 100644 --- a/geoh5py/ui_json/forms.py +++ b/geoh5py/ui_json/forms.py @@ -149,15 +149,18 @@ 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. + """ self.value = value - if "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 or len(self.dependency) > 0 class StringForm(BaseForm): @@ -472,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): @@ -536,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): """ @@ -571,20 +598,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): @@ -607,20 +636,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): """ @@ -645,6 +660,28 @@ 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 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/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): """ diff --git a/geoh5py/ui_json/ui_json.py b/geoh5py/ui_json/ui_json.py index 450bca890..3903906c8 100644 --- a/geoh5py/ui_json/ui_json.py +++ b/geoh5py/ui_json/ui_json.py @@ -24,7 +24,6 @@ import logging from pathlib import Path from typing import Any -from uuid import UUID from pydantic import ( BaseModel, @@ -34,23 +33,28 @@ ) from geoh5py import Workspace -from geoh5py.groups import PropertyGroup, UIJsonGroup -from geoh5py.shared import Entity +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.validation import ErrorPool, UIJsonError, get_validations +from geoh5py.ui_json.annotations import OptionalPath, OptionalString +from geoh5py.ui_json.forms import BaseForm, DependencyType, GroupForm +from geoh5py.ui_json.validation import ( + ErrorPool, + UIJsonError, + dependency_type_validation, + get_validations, + promote_or_catch, +) logger = logging.getLogger(__name__) -class BaseUIJson(BaseModel): +class UIJson(BaseModel): """ Base class for storing ui.json data on disk. @@ -61,197 +65,227 @@ class BaseUIJson(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( 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 - _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)}')" + geoh5: OptionalPath + run_command: str | None + monitoring_directory: OptionalPath = None + conda_environment: str | None + workspace_geoh5: OptionalPath = None - def __str__(self) -> str: - """String level shows the full json representation.""" + out_group: GroupForm | OptionalString = None - json_string = self.model_dump_json(indent=4, exclude_unset=True) - for field in type(self).model_fields: - value = getattr(self, field) - if isinstance(value, BaseForm): - type_string = type(value).__name__ - json_string = json_string.replace( - f'"{field}": {{', f'"{field}": {type_string} {{' - ) + _form_dependencies: dict[str, dict[str, bool]] + _group_dependencies: dict[str, BaseForm] - return f"{self!r} -> {json_string}" + def copy_relatives(self, parent: Workspace, clear_cache: bool = False): + """ + Copy the entities referenced in the input file to a new workspace. - @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 + :param parent: The parent to copy the entities to. + :param clear_cache: Indicate whether to clear the cache. + """ + if self.geoh5 is None: + return - @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." + with Workspace(self.geoh5, mode="r") as geoh5: + params = self.to_params(workspace=geoh5) + params.pop("geoh5", None) + copy_dict_relatives( + params, + parent, + clear_cache=clear_cache, ) - return path - @classmethod - def read(cls, path: str | Path) -> BaseUIJson: + def flatten(self, skip_disabled=False, active_only=False) -> dict[str, Any]: """ - Create a UIJson object from ui.json file. + Flatten the UIJson data to dictionary of key/value pairs. - 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. + Chooses between value/property in data forms depending on the is_value + field. - :param path: Path to the .ui.json file. - :returns: UIJson object. + :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 + # todo: strange ype issue: I did not changed this part + for field in fields: + if skip_disabled and not self.is_enabled(field): + continue - if isinstance(path, str): - path = Path(path) + value = getattr(self, field) + if isinstance(value, BaseForm): + value = value.flatten() + data[field] = value - path = path.resolve() + return data - if not path.exists(): - raise FileNotFoundError(f"File {path} does not exist.") + @classmethod + def from_dict(cls, data: dict) -> UIJson: + """ + Create a UIJson instance from a dictionary. - if "".join(path.suffixes[-2:]) != ".ui.json": - raise ValueError(f"File {path} is not a .ui.json file.") + :param data: Dictionary representing the ui json object. - 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() - } - - if cls == BaseUIJson: - fields = {} - for name, value in kwargs.items(): - if name in BaseUIJson.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), ...) + :returns: UIJson object. + """ + kwargs = {key: (item if item != "" else None) for key, item in data.items()} - model = create_model( # type: ignore - "UnknownUIJson", - __base__=BaseUIJson, - **fields, - ) - uijson = model(**kwargs) - else: - uijson = cls(**kwargs) + ui_json_class = cls.infer(**kwargs) - uijson._path = path # pylint: disable=protected-access - return uijson + return ui_json_class(**kwargs) - def write(self, path: Path): + @staticmethod + def infer(title="UnknownUIJson", **kwargs) -> type[UIJson]: """ - Write the UIJson object to file. + Create a UIJson subclass dynamically based on inferred form types. - :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) + 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. - def get_groups(self) -> dict[str, list[str]]: - """ - Returns grouped forms. + :param title: Name for the generated model class. Defaults to ``"UnknownUIJson"``. + :param kwargs: Named form data to include in the generated class. - :returns: Group names and the parameters belonging to each - group. + :return: A new :class:`UIJson` subclass whose extra fields match the inferred types. """ - groups: dict[str, list[str]] = {} - for field in self.__class__.model_fields: - form = getattr(self, field) - if not isinstance(form, BaseForm): + fields = {} + for name, value in kwargs.items(): + if name in UIJson.model_fields.keys(): continue - name = getattr(form, "group", "") - if name: - groups[name] = [field] if name not in groups else groups[name] + [field] + if isinstance(value, dict): + form_type = BaseForm.infer(value) + fields[name] = (form_type, ...) + else: + fields[name] = (type(value), ...) - return groups + model = create_model( # type: ignore + kwargs.get("title", title), + __base__=UIJson, + **fields, + ) + return model - def is_disabled(self, field: str) -> bool: + def is_enabled(self, field: str) -> bool: """ - Checks if a field is disabled based on form status. + Checks if a field is enabled based on form status and linkages. - :param field: Field name to check. - :returns: True if the field is disabled by its own enabled status or - the groups enabled status, False otherwise. + :param field: Field name or form to check. + :returns: False if the field is disabled by its own enabled status or + the linkages enabled status, True otherwise. """ + enabled = True + form = getattr(self, field) - value = getattr(self, field) - if not isinstance(value, BaseForm): - return False - if value.enabled is False: + # Only a key:value pair, cannot be disabled + if not isinstance(form, BaseForm): return True - 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 + if not form.enabled: + return False - return disabled + # Can still be disabled based on linkages + # Check if disabled based on group status + group = getattr(form, "group", "") + if group in self._group_dependencies: + enabled = self._group_dependencies[group].enabled - def flatten(self, skip_disabled=False, active_only=False) -> dict[str, Any]: + # 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 = codependent.enabled + else: + enabled = not codependent.enabled + + return enabled + + def model_post_init(self, context: Any, /) -> None: + self._group_dependencies, self._form_dependencies = self._get_dependency_links() + + @classmethod + def read(cls, path: str | Path) -> UIJson: """ - Flatten the UIJson data to dictionary of key/value pairs. + Create a UIJson instance from ui.json file. - Chooses between value/property in data forms depending on the is_value - field. + 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. - :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. + Consider using the `load` method to get the UIJson class and data separately + if you want to handle validation errors yourself. - :return: Flattened dictionary of key/value pairs. + :param path: Path to the .ui.json file. + :param validate: Whether to validate the ui json file. + + :returns: UIJson object. """ - 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): + kwargs = cls._load(path) + + return cls.from_dict(kwargs) + + def set_enabled(self, copy: bool = False, **states) -> UIJson: + """ + Set the enabled state of fields, and handle the state of dependencies. + + :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. + """ + if copy: + uijson = self.model_copy(deep=True) + else: + uijson = self + + for field, value in states.items(): + form = getattr(uijson, field, None) + if not isinstance(form, BaseForm): continue - value = getattr(self, field) - if isinstance(value, BaseForm): - value = value.flatten() - data[field] = value + if not value and not form.is_optional: + raise ValueError(f"Field {field} enabled state cannot be False.") - return data + form.enabled = value + + # 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: + codependent.enabled = value + else: + codependent.enabled = not value + + return uijson - def set_values(self, copy: bool = False, **kwargs) -> BaseUIJson: + def set_values(self, copy: bool = False, **kwargs) -> UIJson: """ Fill the UIJson with new values. @@ -259,37 +293,46 @@ def set_values(self, copy: bool = False, **kwargs) -> BaseUIJson: 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) else: uijson = self - demotion = [entity2uuid, as_str_if_uuid] - 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) + 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, key, dict_mapper(value, demotion)) + setattr(uijson, field, dict_mapper(value, [entity2uuid])) 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. :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) - 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.") @@ -299,17 +342,15 @@ def to_params(self, workspace: Workspace | None = None) -> dict[str, Any]: data[field] = geoh5 continue - if isinstance(value, UUID): - value = self._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] - - if isinstance(value, UIJsonError): + try: + value = promote_or_catch(geoh5, value) + except UIJsonError: errors[field].append(value) data[field] = value - self.validate_data(data, errors) + if validate: + self._cross_validations(data, errors) return data @@ -342,11 +383,53 @@ def to_ui_json_group( return ui_json_group - def validate_data( - self, params: dict[str, Any] | None = None, errors: dict[str, Any] | None = None + @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." + ) + return path + + @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 + + 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) + file.write(data) + + return path + + 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. @@ -355,46 +438,116 @@ 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: - if self.is_disabled(field): + if not self.is_enabled(field): continue - form = ui_json[field] - validations = get_validations(list(form) if isinstance(form, dict) else []) + form = getattr(self, field) + validations = get_validations( + list(form.model_fields_set) 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) ErrorPool(errors).throw() - def _object_or_catch( + def _get_dependency_links( self, - workspace: Workspace, - uuid: UUID, - ) -> Entity | PropertyGroup | UIJsonError: + ) -> 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. """ - Returns an object if it exists in the workspace or an error if not. + form_dependencies: dict[str, dict[str, bool]] = {} + group_dependencies: dict[str, BaseForm] = {} - :param workspace: Workspace to fetch entities from. - :param uuid: UUID of the object to fetch. + for name in self.__class__.model_fields.keys(): + if name not in form_dependencies: + form_dependencies[name] = {} - :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. + 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): + dependency_type_validation(dependents_on, self) + mirrors = getattr(form, "dependency_type", None) in [ + DependencyType.ENABLED, + DependencyType.SHOW, + ] + + # Add reverse linkage + 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 + + @staticmethod + def _load(path: str | Path) -> dict: """ + Load data and generate a UIJson class from file. - obj = workspace.get_entity(uuid) - if obj[0] is not None: - return obj[0] + :param path: Path to the .ui.json file. - return UIJsonError(f"Workspace does not contain an entity with uid: {uuid}.") + :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 + + 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}" diff --git a/geoh5py/ui_json/validation.py b/geoh5py/ui_json/validation.py index bb8ca0ef4..5e93f4715 100644 --- a/geoh5py/ui_json/validation.py +++ b/geoh5py/ui_json/validation.py @@ -22,11 +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.data import Data +from geoh5py import Workspace from geoh5py.groups import PropertyGroup from geoh5py.objects import ObjectBase from geoh5py.shared import Entity @@ -43,9 +43,13 @@ UUIDValidator, ValueValidator, ) +from geoh5py.ui_json.forms import BoolForm from geoh5py.ui_json.utils import requires_value +if TYPE_CHECKING: + from geoh5py.ui_json.ui_json import UIJson + Validation = dict[str, Any] @@ -360,75 +364,107 @@ 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, 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 data: Input data with known validations. - :param json_dict: A dict representation of the UIJson object. + :param ui_json: A UIJson object. """ + dependency_form = getattr(ui_json, name) - dependency = json_dict[name]["dependency"] - dependency_form = json_dict[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( + 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." ) -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]): +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"]] - 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']}.") + # Special case for DataRangeForm + if isinstance(child, dict): + child = data[name]["property"] + + parent = data[parent_name] + + 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}.") + + +def promote_or_catch( + workspace: Workspace, + value: Any, +) -> Any: + """ + Returns an object if it exists in the workspace or an error if not. + + :param workspace: Workspace to fetch entities from. + :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, 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 + + obj = workspace.get_entity(value) + if obj[0] is not None: + return obj[0] + + raise UIJsonError(f"Workspace does not contain an entity with uid: {value}.") VALIDATIONS_MAP = { - "dependency": dependency_type_validation, "mesh_type": mesh_type_validation, "parent": parent_validation, } diff --git a/recipe.yaml b/recipe.yaml index 55952ceec..852c3d019 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.0.0" # 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_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 diff --git a/tests/ui_json/forms_test.py b/tests/ui_json/forms_test.py index 9def33ce4..7b8ff0c48 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 @@ -53,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( @@ -138,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" @@ -458,6 +450,16 @@ 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()) @@ -525,12 +527,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 @@ -549,7 +549,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" @@ -596,7 +596,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 +608,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(): @@ -644,6 +641,29 @@ 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() + ) + + # 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) 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 diff --git a/tests/ui_json/uijson_test.py b/tests/ui_json/uijson_test.py index 82fcfc1d6..90841a4f0 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 @@ -42,97 +43,111 @@ RadioLabelForm, StringForm, ) -from geoh5py.ui_json.ui_json import BaseUIJson +from geoh5py.ui_json.ui_json import UIJson 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 def test_uijson(sample_uijson): - class MyUIJson(BaseUIJson): + 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(BaseUIJson): 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,9 +180,9 @@ 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(BaseUIJson): + class MyUIJson(UIJson): my_string_parameter: StringForm kwargs = { @@ -186,12 +202,12 @@ class MyUIJson(BaseUIJson): 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)}}) - class MyUIJson(BaseUIJson): + class MyUIJson(UIJson): my_object_parameter: ObjectForm my_other_object_parameter: ObjectForm my_data_parameter: DataForm @@ -206,6 +222,7 @@ class MyUIJson(BaseUIJson): "label": "other test", "mesh_type": [Points], "value": other_pts.uid, + "optional": True, }, "my_data_parameter": { "label": "data", @@ -229,16 +246,13 @@ class MyUIJson(BaseUIJson): "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): - ws = Workspace(tmp_path / "test.geoh5") + ws = Workspace(tmp_path / f"{__name__}.geoh5") # BoolForm dependency is valid - class MyUIJson(BaseUIJson): + class MyUIJson(UIJson): my_parameter: BoolForm my_dependent_parameter: StringForm @@ -254,11 +268,12 @@ class MyUIJson(BaseUIJson): }, } uijson = generate_test_uijson(ws, uijson=MyUIJson, data=kwargs) + params = uijson.to_params() 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 @@ -270,19 +285,19 @@ class MyUIJson(BaseUIJson): # 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() 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") - class MyUIJson(BaseUIJson): + class MyUIJson(UIJson): my_object_parameter: ObjectForm my_data_parameter: DataForm @@ -316,10 +331,10 @@ class MyUIJson(BaseUIJson): 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(BaseUIJson): + class MyUIJson(UIJson): my_object_parameter: ObjectForm kwargs = { @@ -343,9 +358,9 @@ class MyUIJson(BaseUIJson): def test_deprecated_annotation(tmp_path, caplog): - geoh5 = Workspace(tmp_path / "test.geoh5") + geoh5 = Workspace(tmp_path / f"{__name__}.geoh5") - class MyUIJson(BaseUIJson): + class MyUIJson(UIJson): my_parameter: Deprecated with caplog.at_level(logging.WARNING): @@ -363,7 +378,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 @@ -372,6 +387,8 @@ class MyUIJson(BaseUIJson): "my_param": { "label": "a", "value": 1, + "group": "my_group", + "groupOptional": True, }, "my_grouped_param": { "label": "b", @@ -385,23 +402,22 @@ class MyUIJson(BaseUIJson): }, } - 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 - assert "my_group" in groups - assert "my_grouped_param" in groups["my_group"] - assert "my_other_grouped_param" in groups["my_group"] + 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): - class MyUIJson(BaseUIJson): + 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": { @@ -413,122 +429,160 @@ class MyUIJson(BaseUIJson): "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 uijson.is_enabled("my_param") + assert not uijson.is_enabled("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 / "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") - 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 not uijson.is_enabled("group_leader") + assert not uijson.is_enabled("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", "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), + ], +) +def test_disabled_dependency_forms(tmp_path, dtype, lead_state, outcome): + class MyUIJson(UIJson): + leader: FloatForm + dependent: FloatForm -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, + "leader": { + "label": "a", + "group": "some_group", + "enabled": lead_state, + "optional": True, + "value": 3.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, + "dependent": { + "label": "b", + "group": "some_group", + "dependency": "leader", + "dependencyType": dtype, + "value": 4.0, + "enabled": True, }, - "my_optional_parameter": { - "label": "my optional parameter", - "value": 2.0, + } + + with Workspace(tmp_path / f"{__name__}.geoh5") as ws: + uijson = generate_test_uijson(ws, uijson=MyUIJson, data=kwargs) + + assert uijson.is_enabled("dependent") == outcome + + # Change the state and check the dependent + uijson.set_enabled(dependent=not outcome) + assert uijson.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, False), + (True, False, True), + (False, False, False), + ], +) +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, - "enabled": False, - }, - "my_group_optional_parameter": { - "label": "my group optional parameter", - "value": 3.0, - "group": "my group", "group_optional": True, - "enabled": False, + "value": 3.0, }, - "my_grouped_parameter": { - "label": "my grouped parameter", + "dependent": { + "label": "b", + "group": "some_group", "value": 4.0, - "group": "my group", + "enabled": dep_state, + "optional": True, + }, + "sub_dependent": { + "label": "c", + "group": "some_group", + "dependency": "dependent", + "dependencyType": "disabled", + "value": 4.0, + "enabled": True, }, } - 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") + + with Workspace(tmp_path / f"{__name__}.geoh5") as ws: + uijson = generate_test_uijson(ws, uijson=MyUIJson, data=kwargs) + + 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): + uijson = UIJson.read(sample_uijson) uijson.write(tmp_path / "test_copy.ui.json") assert isinstance(uijson.my_string_parameter, StringForm) @@ -537,32 +591,38 @@ 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) - re_loaded = BaseUIJson.read(tmp_path / "test_copy.ui.json") + 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") 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") + Workspace.create(tmp_path / f"{__name__}.geoh5") - class MyUIJson(BaseUIJson): + class MyUIJson(UIJson): param: StringForm 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 +634,6 @@ class MyUIJson(BaseUIJson): 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): @@ -586,7 +641,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), @@ -603,9 +658,9 @@ 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(BaseUIJson): + class MyUIJson(UIJson): my_string_parameter: StringForm my_int_parameter: IntegerForm @@ -625,9 +680,9 @@ class MyUIJson(BaseUIJson): def test_fill_copy(tmp_path): - ws = Workspace(tmp_path / "test.geoh5") + ws = Workspace(tmp_path / f"{__name__}.geoh5") - class MyUIJson(BaseUIJson): + class MyUIJson(UIJson): my_string_parameter: StringForm uijson = generate_test_uijson( @@ -648,9 +703,9 @@ class MyUIJson(BaseUIJson): 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(BaseUIJson): + class MyUIJson(UIJson): my_param: FloatForm uijson = generate_test_uijson( @@ -666,9 +721,9 @@ class MyUIJson(BaseUIJson): def test_fill_kwargs_re_enables_form(tmp_path): - ws = Workspace(tmp_path / "test.geoh5") + ws = Workspace(tmp_path / f"{__name__}.geoh5") - class MyUIJson(BaseUIJson): + class MyUIJson(UIJson): my_param: FloatForm uijson = generate_test_uijson( @@ -685,11 +740,11 @@ class MyUIJson(BaseUIJson): 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))) - class MyUIJson(BaseUIJson): + class MyUIJson(UIJson): my_object_parameter: ObjectForm uijson = generate_test_uijson( @@ -714,9 +769,9 @@ class MyUIJson(BaseUIJson): 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(BaseUIJson): + class MyUIJson(UIJson): my_string_parameter: StringForm uijson = generate_test_uijson( @@ -731,9 +786,9 @@ class MyUIJson(BaseUIJson): 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(BaseUIJson): + class MyUIJson(UIJson): my_string_parameter: StringForm uijson = generate_test_uijson( @@ -747,9 +802,9 @@ class MyUIJson(BaseUIJson): 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(BaseUIJson): + class MyUIJson(UIJson): my_string_parameter: StringForm uijson = generate_test_uijson( @@ -763,9 +818,9 @@ class MyUIJson(BaseUIJson): 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(BaseUIJson): + class MyUIJson(UIJson): my_string_parameter: StringForm uijson = generate_test_uijson( @@ -780,10 +835,10 @@ class MyUIJson(BaseUIJson): 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(BaseUIJson): + class MyUIJson(UIJson): my_string_parameter: StringForm uijson = MyUIJson(