diff --git a/ods_tools/data/analysis_settings_schema.json b/ods_tools/data/analysis_settings_schema.json index dfe061d..cb81e1f 100644 --- a/ods_tools/data/analysis_settings_schema.json +++ b/ods_tools/data/analysis_settings_schema.json @@ -353,6 +353,24 @@ "title": "User set quantile points", "description": "List of quantiles as float values '[0.0, 0.2, 0.4 .. etc]'" }, + "computation_settings": { + "type": "object", + "title": "Computation setting options", + "description": "List of oasislmf package settings, options are dependent on the package version. To see these look at releases 2.4.0 and up (https://github.com/OasisLMF/OasisLMF/releases)", + "patternProperties": { + "^.*$": { + "anyOf": [ + { "type": "integer" }, + { "type": "string" }, + { "type": "boolean" }, + { "type": "object" }, + { "type": "number" }, + { "type": "array" } + ] + } + }, + "additionalProperties": true + }, "model_settings": { "type": "object", "title": "Model settings", diff --git a/ods_tools/data/model_settings_schema.json b/ods_tools/data/model_settings_schema.json index 3e6d322..3d26c59 100644 --- a/ods_tools/data/model_settings_schema.json +++ b/ods_tools/data/model_settings_schema.json @@ -1,8 +1,693 @@ { + "$schema": "https://json-schema.org/draft/2020-12/schema", "type":"object", "title":"Model settings", "description":"Specifies the model resource schema", "additionalProperties":false, + "definition": { + "extensible_parameters": { + "type": "object", + "description":"sub schema that allow a extend properties of json object", + "properties": { + "string_parameters": { + "title": "Single string paramters", + "type": "array", + "uniqueItems": true, + "items": { + "type": "object", + "uniqueItems": false, + "title": "String options", + "description": "User selected string value", + "additionalProperties": false, + "properties": { + "name": { + "type": "string", + "title": "UI Option", + "description": "UI name for selection", + "minLength": 1 + }, + "desc": { + "type": "string", + "title": "Short description", + "description": "UI description for selection", + "minLength": 1 + }, + "used_for": { + "type": "string", + "title": "Where the setting is applied", + "description": "Set if this parameter is ONLY used at input 'generation' or for output 'losses'", + "enum": [ + "all", + "generation", + "losses" + ] + }, + "tooltip": { + "type": "string", + "title": "UI tooltip", + "description": "Long description (optional)" + }, + "default": { + "type": "string", + "title": "Initial string", + "description": "Default 'string' for variable" + }, + "visible": { + "type": "boolean", + "title": "Is visible", + "description": "tell if parameter should be visible in UI" + }, + "editable": { + "type": "boolean", + "title": "Is editable", + "description": "tell if parameter should be editable in UI" + } + }, + "required": [ + "name", + "desc", + "default" + ] + } + }, + "list_parameters": { + "title": "List of strings parameters", + "type": "array", + "uniqueItems": true, + "items": { + "type": "object", + "uniqueItems": false, + "title": "List options", + "description": "User selected list values", + "additionalProperties": false, + "properties": { + "name": { + "type": "string", + "title": "UI Option", + "description": "UI name for selection", + "minLength": 1 + }, + "desc": { + "type": "string", + "title": "Short description", + "description": "UI description for selection", + "minLength": 1 + }, + "used_for": { + "type": "string", + "title": "Where the setting is applied", + "description": "Set if this parameter is ONLY used at input 'generation' or for output 'losses'", + "enum": [ + "all", + "generation", + "losses" + ] + }, + "tooltip": { + "type": "string", + "title": "UI tooltip", + "description": "Long description (optional)" + }, + "default": { + "type": "array", + "title": "Default List value", + "description": "Default 'list' set for variable", + "items": { + "type": "string" + } + }, + "visible": { + "type": "boolean", + "title": "Is visible", + "description": "tell if parameter should be visible in UI" + }, + "editable": { + "type": "boolean", + "title": "Is editable", + "description": "tell if parameter should be editable in UI" + } + }, + "required": [ + "name", + "desc", + "default" + ] + } + }, + "dictionary_parameters": { + "title": "Generic dictionary parameters", + "type": "array", + "uniqueItems": true, + "items": { + "type": "object", + "uniqueItems": false, + "title": "Dictionary option", + "description": "User selected dictionarys", + "additionalProperties": false, + "properties": { + "name": { + "type": "string", + "title": "UI Option", + "description": "UI name for selection", + "minLength": 1 + }, + "desc": { + "type": "string", + "title": "Short description", + "description": "UI description for selection", + "minLength": 1 + }, + "used_for": { + "type": "string", + "title": "Where the setting is applied", + "description": "Set if this parameter is ONLY used at input 'generation' or for output 'losses'", + "enum": [ + "all", + "generation", + "losses" + ] + }, + "tooltip": { + "type": "string", + "title": "UI tooltip", + "description": "Long description (optional)" + }, + "default": { + "type": "object", + "title": "Default dictionary", + "description": "Defaults set for variable" + }, + "visible": { + "type": "boolean", + "title": "Is visible", + "description": "tell if parameter should be visible in UI" + }, + "editable": { + "type": "boolean", + "title": "Is editable", + "description": "tell if parameter should be editable in UI" + } + }, + "required": [ + "name", + "desc", + "default" + ] + } + }, + "boolean_parameters": { + "title": "Boolean parameters", + "type": "array", + "uniqueItems": true, + "items": { + "type": "object", + "uniqueItems": false, + "title": "Boolean option", + "description": "User selected boolean option", + "additionalProperties": false, + "properties": { + "name": { + "type": "string", + "title": "UI Option", + "description": "UI name for selection", + "minLength": 1 + }, + "desc": { + "type": "string", + "title": "Short description", + "description": "UI description for selection", + "minLength": 1 + }, + "used_for": { + "type": "string", + "title": "Where the setting is applied", + "description": "Set if this parameter is ONLY used at input 'generation' or for output 'losses'", + "enum": [ + "all", + "generation", + "losses" + ] + }, + "tooltip": { + "type": "string", + "title": "UI tooltip", + "description": "Long description (optional)" + }, + "default": { + "type": "boolean", + "title": "Initial value", + "description": "Default 'value' set for variable" + }, + "visible": { + "type": "boolean", + "title": "Is visible", + "description": "tell if parameter should be visible in UI" + }, + "editable": { + "type": "boolean", + "title": "Is editable", + "description": "tell if parameter should be editable in UI" + } + }, + "required": [ + "name", + "desc", + "default" + ] + } + }, + "float_parameters": { + "title": "Bounded float paramters", + "type": "array", + "uniqueItems": true, + "items": { + "type": "object", + "uniqueItems": false, + "title": "Float option", + "description": "Select float value", + "additionalProperties": false, + "properties": { + "name": { + "type": "string", + "title": "UI Option", + "description": "UI name for selection", + "minLength": 1 + }, + "desc": { + "type": "string", + "title": "Short description", + "description": "UI description for selection", + "minLength": 1 + }, + "used_for": { + "type": "string", + "title": "Where the setting is applied", + "description": "Set if this parameter is ONLY used at input 'generation' or for output 'losses'", + "enum": [ + "all", + "generation", + "losses" + ] + }, + "tooltip": { + "type": "string", + "title": "UI tooltip", + "description": "Long description (optional)" + }, + "default": { + "type": "number", + "title": "Initial value", + "description": "Default 'value' set for float variable" + }, + "max": { + "type": "number", + "title": "Maximum value", + "description": "Maximum Value for float variable" + }, + "min": { + "type": "number", + "title": "Minimum value", + "description": "Minimum Value for float variable" + }, + "stepsize": { + "type": "number", + "title": "Interval step size", + "description": "The slider widget's step interval for adjusting the float parameter." + }, + "visible": { + "type": "boolean", + "title": "Is visible", + "description": "tell if parameter should be visible in UI" + }, + "editable": { + "type": "boolean", + "title": "Is editable", + "description": "tell if parameter should be editable in UI" + } + }, + "required": [ + "name", + "desc", + "default", + "max", + "min" + ] + } + }, + "numeric_parameters": { + "title": "unbounded numeric paramters", + "type": "array", + "description": "WARNING: option flagged for removal, superseded by `integer_parameters` and `float_parameters`", + "uniqueItems": true, + "items": { + "type": "object", + "uniqueItems": false, + "title": "Numeric option", + "description": "Select float value", + "additionalProperties": false, + "properties": { + "name": { + "type": "string", + "title": "UI Option", + "description": "UI name for selection", + "minLength": 1 + }, + "desc": { + "type": "string", + "title": "Short description", + "description": "UI description for selection", + "minLength": 1 + }, + "used_for": { + "type": "string", + "title": "Where the setting is applied", + "description": "Set if this parameter is ONLY used at input 'generation' or for output 'losses'", + "enum": [ + "all", + "generation", + "losses" + ] + }, + "tooltip": { + "type": "string", + "title": "UI tooltip", + "description": "Long description (optional)" + }, + "default": { + "type": "number", + "title": "Initial value, integer or float", + "description": "Default integer or float 'value' set for variable" + }, + "visible": { + "type": "boolean", + "title": "Is visible", + "description": "tell if parameter should be visible in UI" + }, + "editable": { + "type": "boolean", + "title": "Is editable", + "description": "tell if parameter should be editable in UI" + } + }, + "required": [ + "name", + "desc", + "default" + ] + } + }, + "integer_parameters": { + "title": "Integer paramters", + "type": "array", + "uniqueItems": true, + "items": { + "type": "object", + "uniqueItems": false, + "title": "Integer option", + "description": "Select float value", + "additionalProperties": false, + "properties": { + "name": { + "type": "string", + "title": "UI Option", + "description": "UI name for selection", + "minLength": 1 + }, + "desc": { + "type": "string", + "title": "Short description", + "description": "UI description for selection", + "minLength": 1 + }, + "used_for": { + "type": "string", + "title": "Where the setting is applied", + "description": "Set if this parameter is ONLY used at input 'generation' or for output 'losses'", + "enum": [ + "all", + "generation", + "losses" + ] + }, + "tooltip": { + "type": "string", + "title": "UI tooltip", + "description": "Long description (optional)" + }, + "default": { + "type": "integer", + "title": "Initial integer value", + "description": "Default integer 'value' set for variable" + }, + "visible": { + "type": "boolean", + "title": "Is visible", + "description": "tell if parameter should be visible in UI" + }, + "editable": { + "type": "boolean", + "title": "Is editable", + "description": "tell if parameter should be editable in UI" + } + }, + "required": [ + "name", + "desc", + "default" + ] + } + }, + "dropdown_parameters": { + "title": "Generic dropdown paramters", + "type": "array", + "uniqueItems": true, + "items": { + "title": "dropdown option selector", + "description": "The 'id' field is mapped into the analysis settings as 'paramter_name': ''", + "type": "object", + "uniqueItems": false, + "additionalProperties": false, + "properties": { + "name": { + "type": "string", + "title": "UI Option", + "description": "UI name for selection", + "minLength": 1 + }, + "desc": { + "type": "string", + "title": "Short description", + "description": "UI description for selection", + "minLength": 1 + }, + "used_for": { + "type": "string", + "title": "Where the setting is applied", + "description": "Set if this parameter is ONLY used at input 'generation' or for output 'losses'", + "enum": [ + "all", + "generation", + "losses" + ] + }, + "tooltip": { + "type": "string", + "title": "UI tooltip", + "description": "Long description (optional)" + }, + "default": { + "type": "string", + "title": "Default Event set", + "description": "Initial setting for dropdown option" + }, + "options": { + "type": "array", + "title": "Selection options", + "description": "Array of possible event sets", + "items": { + "type": "object", + "title": "Option element", + "description": "Dropdown option", + "additionalProperties": false, + "properties": { + "id": { + "type": "string", + "title": "event set suffix", + "description": "String value used to select an event set", + "minLength": 1 + }, + "desc": { + "type": "string", + "title": "Short description", + "description": "UI description for selection", + "minLength": 1 + }, + "tooltip": { + "type": "string", + "title": "UI tooltip", + "description": "Long description (optional)" + } + }, + "required": [ + "id", + "desc" + ] + } + }, + "visible": { + "type": "boolean", + "title": "Is visible", + "description": "tell if parameter should be visible in UI" + }, + "editable": { + "type": "boolean", + "title": "Is editable", + "description": "tell if parameter should be editable in UI" + } + }, + "required": [ + "name", + "desc", + "default", + "options" + ] + } + }, + "multi_parameter_options": { + "title": "Multiple Parameter option", + "description": "Sets of parameters with pre-assigned values", + "type": "array", + "uniqueItems": true, + "items": { + "type": "object", + "uniqueItems": false, + "title": "Parameter group option", + "additionalProperties": false, + "properties": { + "name": { + "type": "string", + "title": "UI Option", + "description": "UI name for selection", + "minLength": 1 + }, + "desc": { + "type": "string", + "title": "Short group description", + "description": "UI description for selection", + "minLength": 1 + }, + "used_for": { + "type": "string", + "title": "Where the setting is applied", + "description": "Set if this parameter is ONLY used at input 'generation' or for output 'losses'", + "enum": [ + "all", + "generation", + "losses" + ] + }, + "tooltip": { + "type": "string", + "title": "UI tooltip", + "description": "Long description (optional)" + }, + "config": { + "type": "object", + "title": "Parameter Group configuration", + "description": "JSON object holding : pairs" + }, + "visible": { + "type": "boolean", + "title": "Is visible", + "description": "tell if parameter should be visible in UI" + }, + "editable": { + "type": "boolean", + "title": "Is editable", + "description": "tell if parameter should be editable in UI" + } + }, + "required": [ + "name", + "desc", + "config" + ] + } + }, + "parameter_groups": { + "title": "Parameter Groups", + "type": "array", + "uniqueItems": true, + "items": { + "title": "Grouping element", + "description": "Defines which parameter are related", + "type": "object", + "uniqueItems": false, + "additionalProperties": false, + "properties": { + "name": { + "type": "string", + "title": "Group name", + "description": "Reference for the group element", + "minLength": 1 + }, + "desc": { + "type": "string", + "title": "Short description", + "description": "UI description for selection", + "minLength": 1 + }, + "used_for": { + "type": "string", + "title": "Where the setting is applied", + "description": "Set if this parameter is ONLY used at input 'generation' or for output 'losses'", + "enum": [ + "all", + "generation", + "losses" + ] + }, + "tooltip": { + "type": "string", + "title": "UI tooltip", + "description": "Long description (optional)" + }, + "priority_id": { + "type": "integer", + "title": "Display priority", + "description": "Set which parameter groups to display first" + }, + "presentation_order": { + "type": "array", + "title": "presentation of grouped parameters", + "description": "List of parameters reference by their 'name' property", + "items": { + "type": "string", + "minItems": 1 + } + }, + "collapsible": { + "title": "Collapsible option for UI", + "description": "Boolean to mark if this parameter group is collapsible", + "type": "boolean" + }, + "default_collapsed": { + "title": "Default Collapsed State", + "description": "Boolean to mark if parameter group starts collapsed", + "type": "boolean" + } + }, + "required": [ + "name", + "desc", + "priority_id", + "presentation_order" + ] + } + } + } + } + }, "properties":{ "version": { "type": "string", @@ -65,7 +750,8 @@ "uniqueItems":false, "title":"Model setting options", "description":"Runtime settings available to a model", - "additionalProperties":false, + "unevaluatedProperties":false, + "allOf": [{"$ref": "#/definition/extensible_parameters"}], "properties":{ "event_set":{ "title":"Event set selector", @@ -1591,6 +2277,13 @@ } } } + }, + "computation_settings": { + "type":"object", + "title":"Computation setting options", + "description":"List of parameters that will be passed as arguments to the MDK, options are dependent on the package version. To see these look at releases 2.4.0 and up (https://github.com/OasisLMF/OasisLMF/releases)", + "unevaluatedProperties":false, + "allOf": [{"$ref": "#/definition/extensible_parameters"}] } }, "required":[ diff --git a/ods_tools/oed/__init__.py b/ods_tools/oed/__init__.py index 1ddbd29..f4be54e 100644 --- a/ods_tools/oed/__init__.py +++ b/ods_tools/oed/__init__.py @@ -1,7 +1,8 @@ from .exposure import OedExposure from .oed_schema import OedSchema -from .setting_schema import ModelSettingSchema, AnalysisSettingSchema from .source import OedSource +from .settings import Settings, SettingHandler, AnalysisSettingHandler, ModelSettingHandler +from .setting_schema import ModelSettingSchema, AnalysisSettingSchema from .common import ( OdsException, PANDAS_COMPRESSION_MAP, PANDAS_DEFAULT_NULL_VALUES, USUAL_FILE_NAME, OED_TYPE_TO_NAME, OED_NAME_TO_TYPE, OED_IDENTIFIER_FIELDS, VALIDATOR_ON_ERROR_ACTION, DEFAULT_VALIDATION_CONFIG, OED_PERIL_COLUMNS, fill_empty, @@ -10,8 +11,9 @@ __all__ = [ - 'OedExposure', 'OedSchema', 'OedSource', 'ModelSettingSchema', 'AnalysisSettingSchema', + 'OedExposure', 'OedSchema', 'OedSource', 'Settings', 'SettingHandler', + 'AnalysisSettingHandler', 'ModelSettingHandler', 'ModelSettingSchema', 'AnalysisSettingSchema', 'OdsException', 'PANDAS_COMPRESSION_MAP', 'PANDAS_DEFAULT_NULL_VALUES', 'USUAL_FILE_NAME', 'OED_TYPE_TO_NAME', 'OED_NAME_TO_TYPE', 'OED_IDENTIFIER_FIELDS', 'VALIDATOR_ON_ERROR_ACTION', 'DEFAULT_VALIDATION_CONFIG', 'OED_PERIL_COLUMNS', 'fill_empty', 'UnknownColumnSaveOption', 'BLANK_VALUES', 'is_empty', 'ClassOfBusiness' -] +] # this is necessary for flake8 to pass, otherwise you get an unused import error diff --git a/ods_tools/oed/setting_schema.py b/ods_tools/oed/setting_schema.py index 750d8eb..301d3ea 100644 --- a/ods_tools/oed/setting_schema.py +++ b/ods_tools/oed/setting_schema.py @@ -255,7 +255,8 @@ def get(self, settings_fp, key=None, validate=True): The entire settings data as a dictionary if key is None, otherwise the value for the given key. """ settings_data = self.load(settings_fp) - self.validate(settings_data, raise_error=True) + if validate: + self.validate(settings_data, raise_error=True) return settings_data if not key else settings_data.get(key) diff --git a/ods_tools/oed/settings.py b/ods_tools/oed/settings.py new file mode 100644 index 0000000..c6ca2cb --- /dev/null +++ b/ods_tools/oed/settings.py @@ -0,0 +1,572 @@ +import copy +from pathlib import Path +import json +import jsonschema +import jsonref +import os + +from ods_tools.oed.common import OdsException + +import logging +settings_logger = logging.getLogger(__name__) + +ROOT_USER_ROLE = {'admin'} +DATA_PATH = Path(Path(__file__).parent.parent, 'data') + + +def json_dict_walk(json_dict, key_path): + """ + Yield all the object present under a certain path for a json like dict going through each element + + >>> test_dict = {'a' : [{'b': 1}, {'b': [2, 3]}], 'c': [{'b': 4}, {'b': 5}]} + >>> list(json_dict_walk(test_dict, ['a', 'b'])) + [1, 2, 3] + + >>> test_dict = {'a' : [{'b': 1}, {'b': [2, 3]}], 'c': [{'b': 4}, {'b': 5}]} + >>> list(json_dict_walk(test_dict, 'a')) + [{'b': 1}, {'b': [2, 3]}] + + >>> test_dict = {'a' : [{'b': 1}, {'b': [2, 3]}], 'c': [{'b': 4}, {'b': 5}]} + >>> list(json_dict_walk(test_dict, ['d'])) + [] + + """ + dict_stack = [(json_dict, 0)] # where 0 is the key index in key_path + while dict_stack: + sub_config, key_i = dict_stack.pop() + while True: + if isinstance(sub_config, list): + for elm in reversed(sub_config): + dict_stack.append((elm, key_i)) + break + if key_i >= len(key_path): # we are at the end of the key_path we yield the object we found + yield sub_config + break + else: + sub_config = sub_config.get(key_path[key_i], {}) + key_i += 1 + if not sub_config: # dead end, no object for this key_path + break + + +class Settings: + """ + The Setting class allow you to combine several settings file into one consistent one. + when merging a new settings file, key value will be overwritten if the user role specified has access. + if no user role is ever specified, the default behavior is that newer added setting will overwrite the values in the setting object. + otherwise access can be restricted to certain user role: + - using the keyword 'restricted' in the key info. + - setting a value to a key and a user role in add_settings (all key that have a value will have the 'restricted' info set to the user role value) + Validation info such as min, max and options are check when overwriting a key. + + sub_settings are also merge in the same way. + """ + parameters_mapping = { + "string_parameters": ("type", "string"), + "list_parameters": ("type", "array"), + "dictionary_parameters": ("type", "object"), + "boolean_parameters": ("type", "boolean"), + "float_parameters": ("type", "number"), + "numeric_parameters": ("type", "number"), + "integer_parameters": ("type", "integer"), + "dropdown_parameters": ("oneOf", "options"), + } + + def __init__(self): + self._settings = {} + self._sub_parts = set() + + @classmethod + def is_sub_settings(cls, key, key_info): + return key.endswith('_settings') and isinstance(key_info, dict) and not cls.is_key_info(key_info) + + @classmethod + def is_parameters(cls, key, key_info): + """check if a key, key_info pair is describe list of parameters""" + return key.endswith("_parameters") or key == "datafile_selectors" + + @classmethod + def is_parameter_grouping(cls, key, key_info): + """check if key, key_info pair is describing how to group parameters (for UI purpose for example)""" + return key == "parameter_groups" or key == "multi_parameter_options" + + @classmethod + def is_valid_value(cls, key_info, value): + if key_info is None: + return True + is_valid = True + if "options" in key_info: + is_valid &= value in key_info["options"] + if "min" in key_info: + is_valid &= value >= key_info["min"] + if "max" in key_info: + is_valid &= value <= key_info["max"] + return is_valid + + @staticmethod + def to_user_role(user_role): + if user_role is None: + return [] + elif isinstance(user_role, str): + return [user_role] + else: + return list(user_role) + + @classmethod + def is_key_info(cls, obj): + return isinstance(obj, dict) and 'default' in obj + + @classmethod + def to_key_info(cls, key_info, user_role): + if not cls.is_key_info(key_info): + key_info = { + "default": key_info, + } + if user_role: + key_info['restricted'] = list(user_role) + return key_info + + @staticmethod + def has_access(key_info, user_role): + if key_info is None: + return True + restricted = key_info.get('restricted', set()) + return bool((not restricted) or ROOT_USER_ROLE.union(restricted).intersection(user_role)) + + @classmethod + def update_key(cls, settings, key, key_info, user_role): + """update the value of a key with enrich key_info""" + current_info = settings.get(key) + if cls.has_access(current_info, user_role): # If True can overwrite key + key_info = cls.to_key_info(key_info, user_role) + if cls.is_valid_value(current_info, key_info["default"]): + settings[key] = key_info + else: + settings_logger.info(f"Value {key_info['default']} for {key} is not valid {current_info}") + else: + settings_logger.info(f"user_role {user_role} cannot overwrite {key}, role {current_info['restricted']} required") + + def add_key(self, key, key_info, user_role=None): + user_role = self.to_user_role(user_role) + self.update_key(self._settings, key, key_info, user_role) + + @classmethod + def update_settings(cls, main_settings: dict, extra_settings: dict, user_role: list): + extra_settings = copy.deepcopy(extra_settings) + for key, key_info in extra_settings.items(): + if cls.is_sub_settings(key, key_info): + cls.update_settings(main_settings.setdefault(key, {}), key_info, user_role) + elif cls.is_parameters(key, key_info): + for parameter in key_info: + sub_key_info = cls.to_key_info(parameter, user_role) + sub_key_info['parameter_type'] = key + cls.update_key(main_settings, parameter['name'], sub_key_info, user_role) + elif cls.is_parameter_grouping(key, key_info): + main_settings[key] = key_info + else: + cls.update_key(main_settings, key, key_info, user_role) + + def add_settings(self, settings, user_role=None): + """merge a new settings dict to the setting object""" + user_role = self.to_user_role(user_role) + self.update_settings(self._settings, settings, user_role) + + @classmethod + def to_dict(cls, settings): + settings_dict = {} + for key, key_info in settings.items(): + if cls.is_sub_settings(key, key_info): + settings_dict[key] = cls.to_dict(key_info) + else: + if cls.is_parameter_grouping(key, key_info): + continue + settings_dict[key] = key_info['default'] + return settings_dict + + def to_json_schema(self, setting_names): + settings_json_schema_properties = {} + settings_json_schema = {"properties": settings_json_schema_properties} + for setting_name in setting_names: + setting_info = self._settings.get(setting_name) + if setting_info is not None: + settings_properties = {} + settings_object = { + "additionalProperties": False, + "type": "object", + "properties": settings_properties + } + for param_key, param_info in setting_info.items(): + if not self.is_key_info(param_info): + continue + + property_info = {} + schema_key, schema_val = self.parameters_mapping.get(param_info.pop('parameter_type', None), (None, None)) + if schema_key == "type": + property_info[schema_key] = schema_val + elif schema_key == "oneOf": + property_info[schema_key] = param_info[schema_val] + else: + continue + + if "desc" in param_info: + property_info["title"] = param_info["desc"] + if "tooltip" in param_info: + property_info["description"] = param_info["tooltip"] + + if "min" in param_info: + property_info["minimum"] = param_info["min"] + if "max" in param_info: + property_info["maximum"] = param_info["max"] + settings_properties[param_key] = property_info + + if settings_properties: + settings_json_schema_properties[setting_name] = settings_object + + return settings_json_schema + + def get_settings(self): + """return the dict with all the actual values""" + return self.to_dict(self._settings) + + def __getitem__(self, item): + key_info = self._settings[item] + if 'default' in key_info: + return key_info['default'] + else: + return key_info + + +class SettingHandler: + extra_checks = [] + + def __init__(self, schemas=None, compatibility_profiles=None, settings_type='', logger=settings_logger): + if schemas is None: + schemas = [] + if compatibility_profiles is None: + compatibility_profiles = [] + self.__schemas = schemas + self.compatibility_profiles = compatibility_profiles + self.settings_type = settings_type + self.logger = logger + + def add_compatibility_profile(self, compatibility_profile, compatibility_path): + if compatibility_profile is not None: + if isinstance(compatibility_profile, (str, Path)): + with open(compatibility_profile, encoding="UTF-8") as f: + compatibility_profile = jsonref.load(f) + if isinstance(compatibility_path, str): + compatibility_path = [compatibility_path] + compatibility_profile['compatibility_path'] = compatibility_path + self.compatibility_profiles.append(compatibility_profile) + + @property + def info(self): + """ + Returns the path to the loaded JSON file. + + Returns: + str: The path to the loaded JSON file. + + """ + return self.__schemas + + def add_schema(self, schema, name, *, keys_path=None, schema_fp=None, convert_sub_settings=False): + schema_info = { + 'name': name, + 'schema': schema, + } + if schema_fp is not None: + schema_info['schema_fp'] = schema_fp + if keys_path is not None: + schema_info['key_path'] = keys_path + schema_info['convert_sub_settings'] = convert_sub_settings + self.__schemas.append(schema_info) + + def get_schema(self, name): + """ + get the schema from self.__schemas with the name 'name' + return None if not present + """ + for schema_info in self.__schemas: + if schema_info['name'] == name: + return schema_info['schema'] + + def add_schema_from_fp(self, schema_fp, **kwargs): + if schema_fp in os.listdir(DATA_PATH): + settings_logger.info(f'{schema_fp} loaded from default folder {DATA_PATH}') + schema_fp = Path(DATA_PATH, schema_fp) + else: + schema_fp = Path(schema_fp) + + with open(schema_fp, encoding="UTF-8") as f: + schema = jsonref.load(f) + + self.add_schema(schema, schema_fp=schema_fp, **kwargs) + + def update_obsolete_keys(self, settings_data, version=None, sub_path=None, delete_all=False): + """ + Updates the loaded JSON data to account for deprecated keys. + + Args: + settings_data (dict): The loaded JSON data. + version: version to compare to when updating obsolete keys + + Returns: + dict: The updated JSON data. + + """ + if version is None: + def is_obsolete(key_version): return True + else: + def is_obsolete(key_version): + return key_version <= version # key is obsolete only if asked version is after new key has been introduced + + updated_settings_data = settings_data.copy() + # if sub_path is passed settings_data is actually the sub part + if sub_path is None: + compatibility_profiles = self.compatibility_profiles + else: + compatibility_profiles = [compatibility_profile for compatibility_profile in self.compatibility_profiles + if sub_path == compatibility_profile.get("compatibility_path", [])] + + for compatibility_profile in compatibility_profiles: + if sub_path is None: + key_path = compatibility_profile.get("compatibility_path", []) + else: + key_path = [] + for obj in json_dict_walk(updated_settings_data, key_path): + if not isinstance(obj, dict): + self.logger.info(f"In setting {self.settings_type}, " + f"object at path {compatibility_profile.get('compatibility_path', [])} is not a dict or a list of dict") + continue + all_obsolete_keys = set(key for key, info in compatibility_profile.items() + if key != 'compatibility_path' and is_obsolete(info["from_ver"])) + obsolete_keys = set(obj) & all_obsolete_keys + for key in obsolete_keys: + self.logger.info(f" '{key}' loaded as '{compatibility_profile[key]['updated_to']}'") + obj[compatibility_profile[key]['updated_to']] = obj[key] + if delete_all or compatibility_profile[key]['deleted']: + del obj[key] + for key in obj: + if key in Settings.parameters_mapping: + for param in list(obj[key]): + if param['name'] in all_obsolete_keys: + if delete_all or compatibility_profile[param['name']]['deleted']: + param['name'] = compatibility_profile[param['name']]['updated_to'] + else: + param = copy.deepcopy(param) + param['name'] = compatibility_profile[param['name']]['updated_to'] + obj[key].append(param) + + return updated_settings_data + + def load(self, settings_fp, version=None, validate=True, raise_error=True): + """ + Loads the JSON data from a file path. + + Args: + settings_fp (str): The path to the JSON file. + version: version to compare to when updating obsolete keys + validate: if True validate the file after load + raise_error: raise exception on validation failure + Raises: + OdsException: If the JSON file is invalid. + + Returns: + dict: The loaded JSON data. + + """ + try: + filepath = Path(settings_fp) + with filepath.open(encoding="UTF-8") as f: + settings_raw = json.load(f) + except (IOError, TypeError, ValueError): + raise OdsException(f'Invalid {self.settings_type} file or file path: {settings_fp}') + settings_data = self.update_obsolete_keys(settings_raw, version) + + if validate: + self.validate(settings_data, raise_error=raise_error) + return settings_data + + def validate(self, setting_data, raise_error=True): + """ + Validates the loaded JSON data against the schema. + + Args: + setting_data (dict): The loaded JSON data. + raise_error (bool): raise exception on validation failure + + Returns: + tuple: A tuple containing a boolean indicating whether the JSON data is valid + and a dictionary containing any validation errors. + + """ + # special validation + exception_msgs = {} + for check in self.extra_checks: + settings_logger.info(f"Running check_{check}") + for field_name, message in getattr(self, f"check_{check}")(setting_data).items(): + exception_msgs.setdefault(field_name, []).extend(message) + + for schema_info in self.__schemas: + for json_sub_part in json_dict_walk(setting_data, schema_info.get('key_path', [])): + if schema_info['convert_sub_settings']: + _setting = Settings() + _setting.add_settings(json_sub_part) + json_sub_part = _setting.get_settings() + json_sub_part = self.update_obsolete_keys(json_sub_part, sub_path=schema_info.get('key_path', []), delete_all=True) + + for err in jsonschema.Draft202012Validator(schema_info['schema']).iter_errors(json_sub_part): + if err.path: + field = '-'.join([str(e) for e in err.path]) + elif err.schema_path: + field = '-'.join([str(e) for e in err.schema_path]) + else: + field = 'error' + exception_msgs.setdefault(f"{schema_info['name']} {field}", []).append(err.message) + + if exception_msgs and raise_error: + raise OdsException("\nJSON Validation error in '{}.json': {}".format( + self.settings_type, + json.dumps(exception_msgs, indent=4) + )) + + return not bool(exception_msgs), exception_msgs + + def validate_file(self, settings_fp, raise_error=True): + """ + Validates the loaded JSON file against the schema. + + Args: + settings_fp (str): The file path to the settings file. + raise_error (bool): raise execption on validation failuer + + Returns: + tuple: A tuple containing a boolean indicating whether the JSON data is valid + and a dictionary containing any validation errors. + """ + settings_data = self.load(settings_fp) + return self.validate(settings_data, raise_error=raise_error) + + +class AnalysisSettingHandler(SettingHandler): + default_analysis_setting_schema_json = 'analysis_settings_schema.json' + extra_checks = ['unique_summary_ids'] + default_analysis_compatibility_profile = { + "module_supplier_id": { + "from_ver": "1.23.0", + "deleted": True, + "updated_to": "model_supplier_id" + }, + "model_version_id": { + "from_ver": "1.23.0", + "deleted": True, + "updated_to": "model_name_id" + } + } + + @classmethod + def make(cls, + analysis_setting_schema_json=None, model_setting_json=None, computation_settings_json=None, + analysis_compatibility_profile=None, model_compatibility_profile=None, computation_compatibility_profile=None, + **kwargs): + + handler = cls(settings_type='analysis_settings', **kwargs) + if analysis_compatibility_profile is None: + analysis_compatibility_profile = cls.default_analysis_compatibility_profile + handler.add_compatibility_profile(analysis_compatibility_profile, []) + handler.add_compatibility_profile(model_compatibility_profile, 'model_settings') + handler.add_compatibility_profile(computation_compatibility_profile, 'computation_settings') + + if analysis_setting_schema_json is None: + handler.add_schema_from_fp(cls.default_analysis_setting_schema_json, name='analysis_settings_schema') + elif isinstance(analysis_setting_schema_json, (str, Path)): + handler.add_schema_from_fp(analysis_setting_schema_json, name='analysis_settings_schema') + else: + handler.add_schema(analysis_setting_schema_json, name='analysis_settings_schema') + + model_setting_subpart_validation = ['model_settings'] + if computation_settings_json is None: + model_setting_subpart_validation.append('computation_settings') + elif isinstance(computation_settings_json, (str, Path)): + handler.add_schema_from_fp(computation_settings_json, name='computation_settings_schema', keys_path=['computation_settings']) + else: + handler.add_schema(computation_settings_json, name='computation_settings_schema', keys_path=['computation_settings']) + + if model_setting_json is None: + pass + else: + model_setting_handler = ModelSettingHandler.make( + model_setting_schema_json={}, + computation_settings_json=computation_settings_json, + model_compatibility_profile=model_compatibility_profile, + computation_compatibility_profile=computation_compatibility_profile, + **kwargs + ) + + if isinstance(model_setting_json, (str, Path)): + model_setting_dict = model_setting_handler.load(model_setting_json) + else: + model_setting_dict = model_setting_handler.update_obsolete_keys(model_setting_json) + model_setting_induce_schema = Settings() + model_setting_induce_schema.add_settings(model_setting_dict) + handler.add_schema(model_setting_induce_schema.to_json_schema(model_setting_subpart_validation), name='model_setting_induce_schema') + + return handler + + def check_unique_summary_ids(self, setting_data): + """ + Ensures that the JSON data contains unique summary IDs for each + runtype. + + Args: + setting_data (dict): The loaded JSON data. + + Returns: + dict: Exception messages. Will be empty if there are no unique + summary IDs. + + """ + exception_msgs = {} + runtype_summaries = [f'{runtype}_summaries' for runtype in ['gul', 'il', 'ri']] + for runtype_summary in runtype_summaries: + summary_ids = [summary.get('id', []) for summary in setting_data.get(runtype_summary, [])] + duplicate_ids = set(summary_id for summary_id in summary_ids if summary_ids.count(summary_id) > 1) + if duplicate_ids: + error_msgs = [f'id {summary_id} is duplicated' for summary_id in duplicate_ids] + exception_msgs[runtype_summary] = error_msgs + + return exception_msgs + + +class ModelSettingHandler(SettingHandler): + default_model_setting_json = 'model_settings_schema.json' + + @classmethod + def make(cls, + model_setting_schema_json=None, computation_settings_json=None, + model_compatibility_profile=None, computation_compatibility_profile=None, + **kwargs): + handler = cls(settings_type='model_settings', **kwargs) + handler.add_compatibility_profile(model_compatibility_profile, 'model_settings') + handler.add_compatibility_profile(computation_compatibility_profile, 'computation_settings') + + if model_setting_schema_json is None: + handler.add_schema_from_fp(cls.default_model_setting_json, name='model_settings_schema') + elif isinstance(model_setting_schema_json, (str, Path)): + handler.add_schema_from_fp(model_setting_schema_json, name='model_settings_schema') + else: + handler.add_schema(model_setting_schema_json, name='model_settings_schema') + + if computation_settings_json is None: + pass + elif isinstance(computation_settings_json, (str, Path)): + handler.add_schema_from_fp(computation_settings_json, name='computation_settings_schema', + keys_path=['computation_settings'], + convert_sub_settings=True) + else: + handler.add_schema(computation_settings_json, name='computation_settings_schema', + keys_path=['computation_settings'], + convert_sub_settings=True) + + return handler diff --git a/tests/data/analysis_settings.json b/tests/data/analysis_settings.json new file mode 100644 index 0000000..7409230 --- /dev/null +++ b/tests/data/analysis_settings.json @@ -0,0 +1,20 @@ +{ + "module_supplier_id": "test_id", + "model_name_id": "test_id", + "model_settings": {"test_float_old": 0.3}, + "gul_output": true, + "gul_summaries": [{ + "aalcalc": true, + "eltcalc": true, + "id": 1, + "lec_output": true, + "leccalc": { + "full_uncertainty_aep": true, + "full_uncertainty_oep": true, + "return_period_file": true + } + }], + "computation_settings": { + "gulmc": true + } +} diff --git a/tests/data/computation_settings_schema.json b/tests/data/computation_settings_schema.json new file mode 100644 index 0000000..6c938ae --- /dev/null +++ b/tests/data/computation_settings_schema.json @@ -0,0 +1 @@ +{"$schema": "http://oasislmf.org/computation_settings/draft/schema#", "type": "object", "title": "Computation settings.", "description": "Specifies the computation settings and outputs for an analysis.", "additionalProperties": false, "properties": {"oasis_files_dir": {"type": "string", "description": "Path to the directory in which to generate the Oasis files"}, "exposure_pre_analysis_module": {"type": "string", "description": "Exposure Pre-Analysis lookup module path"}, "post_analysis_module": {"type": "string", "description": "Post-Analysis module path"}, "pre_loss_module": {"type": "string", "description": "pre-loss hook module path"}, "post_file_gen_module": {"type": "string", "description": "post-file gen hook module path"}, "check_oed": {"type": "boolean", "description": "if True check input oed files"}, "analysis_settings_json": {"type": "string", "description": "Analysis settings JSON file path"}, "model_storage_json": {"type": "string", "description": "Model data storage settings JSON file path"}, "model_settings_json": {"type": "string", "description": "Model settings JSON file path"}, "user_data_dir": {"type": "string", "description": "Directory containing additional model data files which varies between analysis runs"}, "model_data_dir": {"type": "string", "description": "Model data directory path"}, "copy_model_data": {"type": "boolean", "description": "Copy model data instead of creating symbolic links to it."}, "model_run_dir": {"type": "string", "description": "Model run directory path"}, "model_package_dir": {"type": "string", "description": "Path containing model specific package"}, "ktools_legacy_stream": {"type": "boolean", "description": "Run Ground up losses using the older stream type (Compatibility option)"}, "fmpy": {"type": "boolean", "description": "use fmcalc python version instead of c++ version"}, "ktools_alloc_rule_il": {"type": "number", "description": "Set the fmcalc allocation rule used in direct insured loss"}, "ktools_alloc_rule_ri": {"type": "number", "description": "Set the fmcalc allocation rule used in reinsurance"}, "summarypy": {"type": "boolean", "description": "use summarycalc python version instead of c++ version"}, "check_missing_inputs": {"type": "boolean", "description": "Fail an analysis run if IL/RI is requested without the required generated files."}, "verbose": {"type": "boolean"}, "ktools_num_processes": {"type": "number", "description": "Number of ktools calculation processes to use"}, "ktools_event_shuffle": {"type": "number", "description": "Set rule for event shuffling between eve partions, 0 - No shuffle, 1 - round robin (output elts sorted), 2 - Fisher-Yates shuffle, 3 - std::shuffle (previous default in oasislmf<1.14.0) "}, "ktools_alloc_rule_gul": {"type": "number", "description": "Set the allocation used in gulcalc"}, "ktools_num_gul_per_lb": {"type": "number", "description": "Number of gul per load balancer (0 means no load balancer)"}, "ktools_num_fm_per_lb": {"type": "number", "description": "Number of fm per load balancer (0 means no load balancer)"}, "ktools_disable_guard": {"type": "boolean", "description": "Disables error handling in the ktools run script (abort on non-zero exitcode or output on stderr)"}, "ktools_fifo_relative": {"type": "boolean", "description": "Create ktools fifo queues under the ./fifo dir"}, "modelpy": {"type": "boolean", "description": "use getmodel python version instead of c++ version"}, "gulpy": {"type": "boolean", "description": "use gulcalc python version instead of c++ version"}, "gulpy_random_generator": {"type": "number", "description": "set the random number generator in gulpy (0: Mersenne-Twister, 1: Latin Hypercube. Default: 1)."}, "gulmc": {"type": "boolean", "description": "use full Monte Carlo gulcalc python version"}, "gulmc_random_generator": {"type": "number", "description": "set the random number generator in gulmc (0: Mersenne-Twister, 1: Latin Hypercube. Default: 1)."}, "gulmc_effective_damageability": {"type": "boolean", "description": "use the effective damageability to draw loss samples instead of the full Monte Carlo method. Default: False"}, "gulmc_vuln_cache_size": {"type": "number", "description": "Size in MB of the cache for the vulnerability calculations. Default: 200"}, "fmpy_low_memory": {"type": "boolean", "description": "use memory map instead of RAM to store loss array (may decrease performance but reduce RAM usage drastically)"}, "fmpy_sort_output": {"type": "boolean", "description": "order fmpy output by item_id"}, "model_custom_gulcalc": {"type": "string", "description": "Custom gulcalc binary name to call in the model losses step"}, "model_py_server": {"type": "boolean", "description": "running the data server for modelpy"}, "peril_filter": {"type": "array", "description": "Peril specific run"}, "model_custom_gulcalc_log_start": {"type": "string", "description": "Log message produced when custom gulcalc binary process starts"}, "model_custom_gulcalc_log_finish": {"type": "string", "description": "Log message produced when custom gulcalc binary process ends"}, "base_df_engine": {"type": "string", "description": "The engine to use when loading dataframes"}, "model_df_engine": {"type": "string", "description": "The engine to use when loading model data dataframes (default: --base-df-engine if not set)"}, "exposure_df_engine": {"type": "string", "description": "The engine to use when loading exposure data dataframes (default: --base-df-engine if not set)"}, "dynamic_footprint": {"type": "boolean", "description": "Dynamic Footprint"}, "post_file_gen_class_name": {"type": "string", "description": "Name of the class to use for the pre loss calculation"}, "post_file_gen_setting_json": {"type": "string", "description": "post file generation config JSON file path"}, "oed_schema_info": {"type": "string", "description": "path to custom oed_schema"}, "oed_location_csv": {"type": "string", "description": "Source location CSV file path"}, "oed_accounts_csv": {"type": "string", "description": "Source accounts CSV file path"}, "oed_info_csv": {"type": "string", "description": "Reinsurance info. CSV file path"}, "oed_scope_csv": {"type": "string", "description": "Reinsurance scope CSV file path"}, "location": {"type": "string", "description": "A set of locations to include in the files"}, "portfolio": {"type": "string", "description": "A set of portfolios to include in the files"}, "account": {"type": "string", "description": "A set of locations to include in the files"}, "pre_loss_class_name": {"type": "string", "description": "Name of the class to use for the pre loss calculation"}, "pre_loss_setting_json": {"type": "string", "description": "pre loss calculation config JSON file path"}, "keys_data_csv": {"type": "string", "description": "Pre-generated keys CSV file path"}, "keys_errors_csv": {"type": "string", "description": "Pre-generated keys errors CSV file path"}, "profile_loc_json": {"type": "string", "description": "Source (OED) exposure profile JSON path"}, "profile_acc_json": {"type": "string", "description": "Source (OED) accounts profile JSON path"}, "profile_fm_agg_json": {"type": "string", "description": "FM (OED) aggregation profile path"}, "currency_conversion_json": {"type": "string", "description": "settings to perform currency conversion of oed files"}, "reporting_currency": {"type": "string", "description": "currency to use in the results reported"}, "disable_summarise_exposure": {"type": "boolean", "description": "Disables creation of an exposure summary report"}, "damage_group_id_cols": {"type": "array", "description": "Columns from loc file to set group_id"}, "hazard_group_id_cols": {"type": "array", "description": "Columns from loc file to set hazard_group_id"}, "lookup_multiprocessing": {"type": "boolean", "description": "Flag to enable/disable lookup multiprocessing"}, "do_disaggregation": {"type": "boolean", "description": "if True run the oasis disaggregation."}, "lookup_config": {"type": "string"}, "lookup_complex_config": {"type": "string"}, "write_ri_tree": {"type": "boolean"}, "write_chunksize": {"type": "number"}, "oasis_files_prefixes": {"type": "object"}, "profile_loc": {"type": "object"}, "profile_acc": {"type": "object"}, "profile_fm_agg": {"type": "object"}, "keys_format": {"type": "string", "description": "Keys files output format", "enum": ["oasis", "json"]}, "lookup_config_json": {"type": "string", "description": "Lookup config JSON file path"}, "lookup_data_dir": {"type": "string", "description": "Model lookup/keys data directory path"}, "lookup_module_path": {"type": "string", "description": "Model lookup module path"}, "lookup_complex_config_json": {"type": "string", "description": "Complex lookup config JSON file path"}, "lookup_num_processes": {"type": "number", "description": "Number of workers in multiprocess pools"}, "lookup_num_chunks": {"type": "number", "description": "Number of chunks to split the location file into for multiprocessing"}, "model_version_csv": {"type": "string", "description": "Model version CSV file path"}, "disable_oed_version_update": {"type": "boolean", "description": "Flag to enable/disable conversion to latest compatible OED version. Must be present in model settings."}, "exposure_pre_analysis_class_name": {"type": "string", "description": "Name of the class to use for the exposure_pre_analysis"}, "exposure_pre_analysis_setting_json": {"type": "string", "description": "Exposure Pre-Analysis config JSON file path"}, "post_analysis_class_name": {"type": "string", "description": "Name of the class to use for the post_analysis"}}} \ No newline at end of file diff --git a/tests/data/config_compatibility_profile.json b/tests/data/config_compatibility_profile.json new file mode 100644 index 0000000..6f92979 --- /dev/null +++ b/tests/data/config_compatibility_profile.json @@ -0,0 +1,162 @@ +{ + "complex_lookup_config_file_path":{ + "from_ver": "1.4.1", + "deleted": false, + "updated_to": "lookup_complex_config_json" + }, + "lookup_config_file_path":{ + "from_ver": "1.4.1", + "deleted": false, + "updated_to": "lookup_config_json" + }, + "keys_data_path":{ + "from_ver": "1.4.1", + "deleted": false, + "updated_to": "lookup_data_dir" + }, + "lookup_package_path":{ + "from_ver": "1.4.1", + "deleted": false, + "updated_to": "lookup_module_path" + }, + "analysis_settings_file_path":{ + "from_ver": "1.4.1", + "deleted": false, + "updated_to": "analysis_settings_json" + }, + "source_exposure_file_path":{ + "from_ver": "1.4.1", + "deleted": false, + "updated_to": "oed_location_csv" + }, + "source_accounts_file_path":{ + "from_ver": "1.4.1", + "deleted": false, + "updated_to": "oed_accounts_csv" + }, + "ri_info_file_path":{ + "from_ver": "1.4.1", + "deleted": false, + "updated_to": "oed_info_csv" + }, + "ri_scope_file_path":{ + "from_ver": "1.4.1", + "deleted": false, + "updated_to": "oed_scope_csv" + }, + "source_exposure_profile_path":{ + "from_ver": "1.4.1", + "deleted": false, + "updated_to": "profile_loc_json" + }, + "source_accounts_profile_path":{ + "from_ver": "1.4.1", + "deleted": false, + "updated_to": "profile_acc_json" + }, + "fm_aggregation_profile_path":{ + "from_ver": "1.4.1", + "deleted": false, + "updated_to": "profile_fm_agg_json" + }, + "model_data_path":{ + "from_ver": "1.4.1", + "deleted": false, + "updated_to": "model_data_dir" + }, + "model_package_path":{ + "from_ver": "1.4.1", + "deleted": false, + "updated_to": "model_package_dir" + }, + "model_version_file_path":{ + "from_ver": "1.4.1", + "deleted": false, + "updated_to": "model_version_csv" + }, + "oasis_files_path":{ + "from_ver": "1.4.1", + "deleted": false, + "updated_to": "oasis_files_dir" + }, + "keys_file_path":{ + "from_ver": "1.4.1", + "deleted": false, + "updated_to": "keys_data_csv" + }, + "keys_errors_file_path":{ + "from_ver": "1.4.1", + "deleted": false, + "updated_to": "keys_errors_csv" + }, + "keys_data_path":{ + "from_ver": "1.4.1", + "deleted": false, + "updated_to": "lookup_data_dir" + }, + "bash_conf_filepath":{ + "from_ver": "1.4.1", + "deleted": false, + "updated_to": "bash_conf_file" + }, + "verbose_mode":{ + "from_ver": "1.4.1", + "deleted": false, + "updated_to": "verbose" + }, + "output_directory":{ + "from_ver": "1.4.1", + "deleted": false, + "updated_to": "output_dir" + }, + "ktools_mem_limit":{ + "from_ver": "1.4.2", + "deleted": true, + "updated_to": "" + }, + "summarise_exposure":{ + "from_ver": "1.4.5", + "deleted": true, + "updated_to": "" + }, + "lookup_package_dir": { + "from_ver": "1.7.0", + "deleted": false, + "updated_to": "lookup_module_path" + }, + "api_server_login":{ + "from_ver": "1.9.1", + "deleted": false, + "updated_to": "server_login_json" + }, + "api_server_url":{ + "from_ver": "1.9.1", + "deleted": false, + "updated_to": "server_url" + }, + "alloc_rule_il":{ + "from_ver": "1.9.1", + "deleted": false, + "updated_to": "ktools_alloc_rule_il" + }, + "alloc_rule_ri":{ + "from_ver": "1.9.1", + "deleted": false, + "updated_to": "ktools_alloc_rule_ri" + }, + "test_dir":{ + "from_ver": "1.9.1", + "deleted": false, + "updated_to": "test_case_dir" + }, + "oed_locations_csv":{ + "from_ver": "1.17.0", + "deleted": false, + "updated_to": "oed_location_csv" + }, + "oed_account_csv":{ + "from_ver": "1.17.0", + "deleted": false, + "updated_to": "oed_accounts_csv" + } +} diff --git a/tests/data/model_compatibility_profile.json b/tests/data/model_compatibility_profile.json new file mode 100644 index 0000000..0e71806 --- /dev/null +++ b/tests/data/model_compatibility_profile.json @@ -0,0 +1,7 @@ +{ + "test_float_old":{ + "from_ver": "1.4.1", + "deleted": true, + "updated_to": "test_float_new" + } +} \ No newline at end of file diff --git a/tests/data/model_settings.json b/tests/data/model_settings.json new file mode 100644 index 0000000..4d98a90 --- /dev/null +++ b/tests/data/model_settings.json @@ -0,0 +1,26 @@ +{ + "model_settings": { + "pla": true, + "float_parameters": [ + { + "name": "test_float_old", + "desc": "test for param renaming", + "tooltip": "this should change to test_float_new", + "default": 0.2, + "min": 0.0, + "max": 100000.0 + } + ] + }, + "lookup_settings": {}, + "computation_settings": { + "string_parameters": [ + { + "name": "lookup_package_dir", + "desc": "test for param renaming", + "tooltip": "this should change to test_float_new", + "default": "fake_path" + } + ] + } +} \ No newline at end of file diff --git a/tests/test_ods_package.py b/tests/test_ods_package.py index ca37a57..a383ad6 100644 --- a/tests/test_ods_package.py +++ b/tests/test_ods_package.py @@ -655,7 +655,6 @@ def test_setting_schema_analysis__is_invalid(self): self.assertEqual({'gul_output': ["'True' is not of type 'boolean'"], 'required': ["'gul_summaries' is a required property"], 'ri_summaries': ['id 1 is duplicated']}, errors) - with self.assertRaises(OdsException): ods_analysis_setting.validate(settings_dict) diff --git a/tests/test_settings.py b/tests/test_settings.py new file mode 100644 index 0000000..4450d44 --- /dev/null +++ b/tests/test_settings.py @@ -0,0 +1,162 @@ +import unittest +from pathlib import Path +from ods_tools.oed import Settings, ModelSettingHandler, AnalysisSettingHandler + + +test_data_dir = Path(Path(__file__).parent, "data") + + +class MultiSettingsPriorityCheck(unittest.TestCase): + + def setUp(self): + self.host_settings = { + "fmpy": { + "type": "boolean_parameters", + "default": True, + "restricted": ["admin"]}, + "gulmc": { + "type": "boolean_parameters", + "default": True, + "restricted": ["modeler"], + }, + "num_process": 16, + } + + self.model_settings = { + "fmpy": { + "default": True, + "restricted": ["superuser"] + }, + "gulmc": { + "default": False, + }, + 'gul_threshold': { + "default": 10. + }, + 'model_settings': { + "setting_1": 6, + "setting_2": { + "type": "int", + "default": 5, + }, + "list_parameters": [ + { + "name": "list_param_1", + "desc": "Custom attribute names", + "tooltip": "A list of field names", + "default": [] + }] + } + + } + + self.analysis_settings = { + "fmpy": False, + "gul_threshold": 5, + "num_process": 7, + 'model_settings': { + "setting_1": 2, + "setting_2": 2, + } + } + + def test_admin_user(self): + settings = Settings() + settings.add_settings(self.host_settings, 'host') + settings.add_settings(self.model_settings, 'modeler') + settings.add_settings(self.analysis_settings, 'admin') + dict_settings = settings.get_settings() + assert dict_settings['fmpy'] is False + assert dict_settings['gul_threshold'] == 5 + assert dict_settings['num_process'] == 7 + assert dict_settings['gulmc'] is False + + def test_super_user(self): + settings = Settings() + settings.add_settings(self.host_settings, 'host') + settings.add_settings(self.model_settings, 'modeler') + settings.add_settings(self.analysis_settings, ['super_user']) + dict_settings = settings.get_settings() + assert dict_settings['fmpy'] is True + assert dict_settings['gul_threshold'] == 5 + assert dict_settings['num_process'] == 16 + + def test_user(self): + settings = Settings() + settings.add_settings(self.host_settings, 'host') + settings.add_settings(self.model_settings, 'modeler') + settings.add_settings(self.analysis_settings) + dict_settings = settings.get_settings() + + assert dict_settings['fmpy'] is True + assert dict_settings['gul_threshold'] == 5 + assert dict_settings['num_process'] == 16 + assert dict_settings['model_settings']['setting_1'] == 6 + assert dict_settings['model_settings']['setting_2'] == 2 + + +class OEDSettingsChecks(unittest.TestCase): + def test_model_settings(self): + model_setting_handler = ModelSettingHandler.make( + computation_settings_json=Path(test_data_dir, 'computation_settings_schema.json'), + model_compatibility_profile=Path(test_data_dir, 'model_compatibility_profile.json'), + computation_compatibility_profile=Path(test_data_dir, 'config_compatibility_profile.json') + ) + # correct settings load, no error expected + setting_data = model_setting_handler.load(Path(test_data_dir, "model_settings.json")) + + # check loaded valid data is still valid + model_setting_handler.validate(setting_data) + + # incorrect settings validation fail + setting_data["model_settings"]["bad_float"] = 5.0 + setting_data["computation_settings"]["float_parameters"] = [ + { + "name": "bad_param", + "desc": "test for param renaming", + "tooltip": "raise an error", + "default": 0.2, + "min": 0.0, + "max": 100000.0 + } + ] + with self.assertRaises(Exception) as context: + model_setting_handler.validate(setting_data) + + self.assertTrue("'bad_float' was unexpected" in str(context.exception)) + self.assertTrue("'bad_param' was unexpected" in str(context.exception)) + + def test_analysis_settings(self): + analysis_settings_handler = AnalysisSettingHandler.make( + model_setting_json=Path(test_data_dir, "model_settings.json"), + computation_settings_json=Path(test_data_dir, 'computation_settings_schema.json'), + model_compatibility_profile=Path(test_data_dir, 'model_compatibility_profile.json'), + computation_compatibility_profile=Path(test_data_dir, 'config_compatibility_profile.json'), + ) + + # correct settings load, no error expected + setting_data = analysis_settings_handler.load(Path(test_data_dir, "analysis_settings.json")) + + # check loaded valid data is still valid + analysis_settings_handler.validate(setting_data) + + # incorrect settings validation fail + setting_data["model_settings"]["bad_float"] = 5.0 + setting_data["computation_settings"]["bad_param"] = 0.2 + setting_data["gul_summaries"].append({ + "aalcalc": True, + "eltcalc": True, + "id": 1, + "lec_output": True, + "leccalc": { + "full_uncertainty_aep": True, + "full_uncertainty_oep": True, + "return_period_file": True + } + }) + with self.assertRaises(Exception) as context: + analysis_settings_handler.validate(setting_data) + + self.assertTrue("'bad_float' was unexpected" in str(context.exception)) + self.assertTrue("'bad_param' was unexpected" in str(context.exception)) + self.assertTrue("id 1 is duplicated" in str(context.exception))