Source code for geoh5py.ui_json.validation
# Copyright (c) 2024 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 <https://www.gnu.org/licenses/>.
from __future__ import annotations
from copy import deepcopy
from typing import Any, cast
from uuid import UUID
from warnings import warn
from geoh5py.groups import PropertyGroup
from geoh5py.shared import Entity
from geoh5py.shared.exceptions import RequiredValidationError
from geoh5py.shared.validators import (
AssociationValidator,
AtLeastOneValidator,
BaseValidator,
OptionalValidator,
PropertyGroupValidator,
RequiredValidator,
ShapeValidator,
TypeValidator,
UUIDValidator,
ValueValidator,
)
from geoh5py.ui_json.utils import requires_value
Validation = dict[str, Any]
[docs]
class InputValidation:
"""
Validations on dictionary of parameters.
Attributes
----------
validations : Validations dictionary with matching set of input parameter keys.
ignore_requirements (optional): Omit raising error on 'required' validator.
Methods
-------
validate_data(data)
Validates data of params and contents/type/shape/keys/reqs of values.
"""
def __init__(
self,
validators: dict[str, BaseValidator] | None = None,
validations: dict[str, Any] | None = None,
ui_json: dict[str, Any] | None = None,
validation_options: dict[str, Any] | None = None,
):
self.validations = self.infer_validations(ui_json, validations=validations)
self.validators: dict[str, BaseValidator] = validators
if validation_options is None:
validation_options = {}
self.ignore_list: tuple = validation_options.get("ignore_list", ())
self.ignore_requirements: bool = validation_options.get(
"ignore_requirements", False
)
@property
def validations(self):
return self._validations
@validations.setter
def validations(self, val: dict[str, Any]):
self._validations = val
@property
def validators(self):
return self._validators
@validators.setter
def validators(self, val: dict[str, BaseValidator]):
if val is None:
val = {}
elif not all(isinstance(v, BaseValidator) for v in val.values()):
raise TypeError("Validators should be subclass of BaseValidator.")
if self.validations is not None:
required_validators = InputValidation._required_validators(self.validations)
val = dict(required_validators, **val)
self._validators = val
@staticmethod
def _unique_validators(validations: dict[str, Any]) -> list[str]:
"""Return names of validators required by a validations dictionary."""
return list({key for item in validations.values() for key in item})
@staticmethod
def _required_validators(validations):
"""Returns dictionary of validators required by validations."""
unique_validators = InputValidation._unique_validators(validations)
sub_classes: list[type[BaseValidator]] = BaseValidator.__subclasses__()
all_validators: dict[str, Any] = {k.validator_type: k() for k in sub_classes}
val = {}
for k in unique_validators:
if k not in all_validators:
raise ValueError(f"No validator implemented for argument '{k}'.")
val[k] = all_validators[k]
return val
@staticmethod
def _validations_from_uijson( # pylint: disable=too-many-branches
ui_json: dict[str, Any],
) -> dict[str, dict]:
"""Determine base set of validations from ui.json structure."""
validations: dict[str, dict] = {}
for key, item in ui_json.items():
if not isinstance(item, dict):
check_type = cast(Any, type(item))
validations[key] = {
"types": [check_type],
}
continue
if "isValue" in item:
validations[key] = {
"types": [str, UUID, int, float, Entity],
}
try:
validations[key]["association"] = item["parent"]
validations[key]["uuid"] = None
except KeyError as error:
warn(
f"Failed to set association for {key}. {error}",
)
elif "rangeLabel" in item:
validations[key] = {
"types": [list],
}
elif "groupValue" in item and "value" in item:
validations[key] = {
"types": [dict],
}
elif "choiceList" in item:
validations[key] = {
"types": [str],
"values": item["choiceList"],
}
elif "fileType" in item:
validations[key] = {
"types": [str],
}
elif "meshType" in item or "groupType" in item:
validations[key] = {
"types": [str, UUID, Entity],
"association": "geoh5",
"uuid": None,
}
elif "parent" in item:
validations[key] = {
"types": [str, UUID, Entity],
"association": item["parent"],
"uuid": None,
}
if "dataGroupType" in item:
validations[key]["property_group_type"] = item["dataGroupType"]
validations[key]["types"] = [str, UUID, PropertyGroup]
elif "value" in item:
check_type = str
if item["value"] is not None:
check_type = cast(Any, type(item["value"]))
validations[key] = {
"types": (
[check_type, Entity] if check_type is UUID else [check_type]
),
}
validations[key].update({"optional": not requires_value(ui_json, key)})
if "types" in validations[key]:
if not requires_value(ui_json, key):
validations[key]["types"] += [type(None)]
if item.get("multiSelect", False):
validations[key]["types"] += [list]
return validations
[docs]
@staticmethod
def infer_validations(
ui_json: dict[str, Any] | None, validations: dict[str, dict] | None = None
) -> dict:
"""Infer necessary validations from ui json structure."""
inferred_validations = (
{} if ui_json is None else InputValidation._validations_from_uijson(ui_json)
)
if validations is not None:
inferred_validations = InputValidation._merge_validations(
inferred_validations, validations
)
return inferred_validations
@staticmethod
def _merge_validations(
validations_a: dict[str, dict], validations_b: dict[str, dict]
):
"""Merge validations_b into validations_a."""
out = deepcopy(validations_a)
if validations_b is not None:
for key, val in validations_b.items():
if key not in out:
out[key] = val
else:
out[key].update(val)
return out
[docs]
def validate(
self, name: str, value: Any, validations: dict[str, Any] | None = None
):
"""
Run validations on a given key and value.
:param name: Parameter identifier.
:param value: Input parameter value.
:param validations: [Optional] Validations provided on runtime
"""
if validations is None:
if name not in self.validations:
raise KeyError(f"{name} is missing from the known validations.")
validations = self.validations[name]
for validator in [
RequiredValidator,
AtLeastOneValidator,
OptionalValidator,
TypeValidator,
UUIDValidator,
AssociationValidator,
PropertyGroupValidator,
ValueValidator,
ShapeValidator,
]:
val = str(validator.validator_type)
if (
val not in validations
or (val == "required" and self.ignore_requirements)
or name in self.ignore_list
):
continue
self.validators[val](name, value, validations[val])
[docs]
def validate_data(self, data: dict[str, Any]) -> None:
"""
Calls validate method on ui.json data structure for cross-dependencies.
Individual key, value pairs are validated for expected type.
:param data: Input data with known validations.
"""
one_of_validations: dict[str, Any] = {}
local_validations = self.validations.copy()
for param, validations in local_validations.items():
if param not in data.keys():
if "required" in validations and not self.ignore_requirements:
raise RequiredValidationError(param)
continue
if "one_of" in validations:
one_of_group = validations.pop("one_of")
val = {param: data[param] is not None}
if one_of_group in one_of_validations:
one_of_validations[one_of_group].update(val)
else:
one_of_validations[one_of_group] = val
if "association" in validations and validations["association"] in data:
valid = validations.copy()
valid["association"] = data[validations["association"]]
self.validate(param, data[param], valid)
else:
self.validate(param, data.get(param, None), validations)
for name, val in one_of_validations.items():
self.validate(name, val, {"one_of": None})
def __call__(self, data, *args):
if isinstance(data, dict):
self.validate_data(data)
elif isinstance(data, str) and args is not None:
self.validate(data, args)
else:
raise ValueError(
"InputValidators can only be called with dictionary of data or "
"(key, value) pair."
)