diff --git a/Orange/widgets/settings.py b/Orange/widgets/settings.py index 3bbe31eb2c0..1e8bcd25ffd 100644 --- a/Orange/widgets/settings.py +++ b/Orange/widgets/settings.py @@ -38,10 +38,11 @@ import warnings from orangewidget.settings import ( - Setting, SettingProvider, SettingsHandler, ContextSetting, + Setting, SettingProvider, SettingsHandler, ContextHandler, Context, IncompatibleContext, SettingsPrinter, rename_setting, widget_settings_dir ) +from orangewidget.settings import ContextSetting as WBContextSetting from orangewidget.settings import _apply_setting from Orange.data import Domain, Variable @@ -62,92 +63,40 @@ ] -class DomainContextHandler(ContextHandler): - """Context handler for widgets with settings that depend on - the input dataset. Suitable settings are selected based on the - data domain.""" - - MATCH_VALUES_NONE, MATCH_VALUES_CLASS, MATCH_VALUES_ALL = range(3) - - def __init__(self, *, match_values=0, first_match=True, **kwargs): - super().__init__() - self.match_values = match_values - self.first_match = first_match - - for name in kwargs: - warnings.warn( - "{} is not a valid parameter for DomainContextHandler" - .format(name), OrangeDeprecationWarning - ) - - def encode_domain(self, domain): - """ - domain: Orange.data.domain to encode - return: dict mapping attribute name to type or list of values - (based on the value of self.match_values attribute) - """ - - match = self.match_values - encode = self.encode_variables - if match == self.MATCH_VALUES_CLASS: - attributes = encode(domain.attributes, False) - attributes.update(encode(domain.class_vars, True)) - else: - attributes = encode(domain.variables, match == self.MATCH_VALUES_ALL) +class ContextSetting(WBContextSetting): + """Description of a context dependent setting""" - metas = encode(domain.metas, match == self.MATCH_VALUES_ALL) + def __init__(self, default, *, required=2, + exclude_attributes=False, exclude_class_vars=False, exclude_metas=False, + **data): + super().__init__(default, required=required, **data) + self.exclude_attributes = exclude_attributes + self.exclude_class_vars = exclude_class_vars + self.exclude_metas = exclude_metas + self.required = required - return attributes, metas - - @staticmethod - def encode_variables(attributes, encode_values): - """Encode variables to a list mapping name to variable type - or a list of values.""" - if not encode_values: - return {v.name: vartype(v) for v in attributes} - - return {v.name: v.values if v.is_discrete else vartype(v) - for v in attributes} - - def new_context(self, domain, attributes, metas): - """Create a new context.""" - context = super().new_context() - context.attributes = attributes - context.metas = metas - return context +class StructuredVariableSettingMixin: - def open_context(self, widget, domain): - if domain is None: - return - if not isinstance(domain, Domain): - domain = domain.domain - super().open_context(widget, domain, *self.encode_domain(domain)) - - def filter_value(self, setting, data, domain, attrs, metas): + def filter_value(self, setting, data, *args): value = data.get(setting.name, None) if isinstance(value, list): new_value = [item for item in value - if self.is_valid_item(setting, item, attrs, metas)] + if self.is_valid_item(setting, item, *args)] data[setting.name] = new_value elif isinstance(value, dict): new_value = {item: val for item, val in value.items() - if self.is_valid_item(setting, item, attrs, metas)} + if self.is_valid_item(setting, item, *args)} data[setting.name] = new_value elif self.is_encoded_var(value) \ - and not self._var_exists(setting, value, attrs, metas): + and not self.match_variable(setting, value, *args): del data[setting.name] - @staticmethod - def encode_variable(var): - return var.name, 100 + vartype(var) - - @classmethod - def encode_setting(cls, context, setting, value): + def encode_setting(self, context, setting, value): if isinstance(value, list): if all(e is None or isinstance(e, Variable) for e in value) \ and any(e is not None for e in value): - return ([None if e is None else cls.encode_variable(e) + return ([None if e is None else self.encode_variable(e) for e in value], -3) else: @@ -155,12 +104,12 @@ def encode_setting(cls, context, setting, value): elif isinstance(value, dict) \ and all(isinstance(e, Variable) for e in value): - return ({cls.encode_variable(e): val for e, val in value.items()}, + return ({self.encode_variable(e): val for e, val in value.items()}, -4) if isinstance(value, Variable): - if isinstance(setting, ContextSetting): - return cls.encode_variable(value) + if isinstance(setting, WBContextSetting): + return self.encode_variable(value) else: raise ValueError("Variables must be stored as ContextSettings; " f"change {setting.name} to ContextSetting.") @@ -187,46 +136,28 @@ def get_var(name): else: return value - @classmethod - def _var_exists(cls, setting, value, attributes, metas): - if not cls.is_encoded_var(value): - return False - - attr_name, attr_type = value - # attr_type used to be either 1-4 for variables stored as string - # settings, and 101-104 for variables stored as variables. The former is - # no longer supported, but we play it safe and still handle both here. - attr_type %= 100 - return (not setting.exclude_attributes and - attributes.get(attr_name, -1) == attr_type or - not setting.exclude_metas and - metas.get(attr_name, -1) == attr_type) - - def match(self, context, domain, attrs, metas): - if context.attributes == attrs and context.metas == metas: - return self.PERFECT_MATCH - + def match(self, context, *args): matches = [] try: for setting, data, _ in \ self.provider.traverse_settings(data=context.values): - if not isinstance(setting, ContextSetting): + if not isinstance(setting, WBContextSetting): continue value = data.get(setting.name, None) if isinstance(value, list): matches.append( - self.match_list(setting, value, context, attrs, metas)) + self.match_list(setting, value, context, *args)) # type check is a (not foolproof) check in case of a pair that # would, by conincidence, have -3 or -4 as the second element elif isinstance(value, tuple) and len(value) == 2 \ and (value[1] == -3 and isinstance(value[0], list) or (value[1] == -4 and isinstance(value[0], dict))): matches.append(self.match_list(setting, value[0], context, - attrs, metas)) + *args)) elif value is not None: matches.append( - self.match_value(setting, value, attrs, metas)) + self.match_value(setting, value, *args)) except IncompatibleContext: return self.NO_MATCH @@ -237,31 +168,31 @@ def match(self, context, domain, attrs, metas): matched, available = [sum(m) for m in zip(*matches)] return matched / available if available else 0.1 - def match_list(self, setting, value, context, attrs, metas): + def match_list(self, setting, value, context, *args): """Match a list of values with the given context. returns a tuple containing number of matched and all values. """ matched = 0 for item in value: - if self.is_valid_item(setting, item, attrs, metas): + if self.is_valid_item(setting, item, *args): matched += 1 - elif setting.required == ContextSetting.REQUIRED: + elif setting.required == WBContextSetting.REQUIRED: raise IncompatibleContext() return matched, len(value) - def match_value(self, setting, value, attrs, metas): + def match_value(self, setting, value, *args): """Match a single value """ if value[1] < 0: return 0, 0 - if self._var_exists(setting, value, attrs, metas): + if self.match_variable(setting, value, *args): return 1, 1 elif setting.required == setting.OPTIONAL: return 0, 1 else: raise IncompatibleContext() - def is_valid_item(self, setting, item, attrs, metas): + def is_valid_item(self, setting, item, *args): """Return True if given item can be used with attrs and metas Subclasses can override this method to checks data in alternative @@ -269,7 +200,19 @@ def is_valid_item(self, setting, item, attrs, metas): """ if not isinstance(item, tuple): return True - return self._var_exists(setting, item, attrs, metas) + return self.match_variable(setting, item, *args) + + @classmethod + def match_variable(cls, setting, value, *args): + """ Return if variable described with value can be matched to *args. """ + raise NotImplementedError + + +class VariableEncoderMixin: + + @staticmethod + def encode_variable(var): + return var.name, 100 + vartype(var) @staticmethod def is_encoded_var(value): @@ -278,6 +221,153 @@ def is_encoded_var(value): and isinstance(value[0], str) and isinstance(value[1], int) \ and value[1] >= 0 + +class DomainContextHandler(ContextHandler, StructuredVariableSettingMixin, + VariableEncoderMixin): + """Context handler for widgets with settings that depend on + the input dataset. Suitable settings are selected based on the + data domain.""" + + MATCH_VALUES_NONE, MATCH_VALUES_CLASS, MATCH_VALUES_ALL = range(3) + + def __init__(self, *, match_values=0, first_match=True, **kwargs): + super().__init__() + self.match_values = match_values + self.first_match = first_match + + for name in kwargs: + warnings.warn( + "{} is not a valid parameter for DomainContextHandler" + .format(name), OrangeDeprecationWarning + ) + + def encode_domain(self, domain): + """ + domain: Orange.data.domain to encode + return: dict mapping attribute name to type or list of values + (based on the value of self.match_values attribute) + """ + + match = self.match_values + encode = self.encode_variables + if match == self.MATCH_VALUES_CLASS: + attributes = encode(domain.attributes, False) + attributes.update(encode(domain.class_vars, True)) + else: + attributes = encode(domain.variables, match == self.MATCH_VALUES_ALL) + + metas = encode(domain.metas, match == self.MATCH_VALUES_ALL) + + return attributes, metas + + @staticmethod + def encode_variables(attributes, encode_values): + """Encode variables to a list mapping name to variable type + or a list of values.""" + + if not encode_values: + return {v.name: vartype(v) for v in attributes} + + return {v.name: v.values if v.is_discrete else vartype(v) + for v in attributes} + + def new_context(self, domain, attributes, metas): + """Create a new context.""" + context = super().new_context() + context.attributes = attributes + context.metas = metas + return context + + def open_context(self, widget, domain): + if domain is None: + return + if not isinstance(domain, Domain): + domain = domain.domain + super().open_context(widget, domain, *self.encode_domain(domain)) + + def filter_value(self, setting, data, domain, *args): + StructuredVariableSettingMixin.filter_value(self, setting, data, *args) + + def encode_setting(self, context, setting, value): + return StructuredVariableSettingMixin.encode_setting( + self, context, setting, value) + + # backward compatibility, pylint: disable=keyword-arg-before-vararg + def decode_setting(self, setting, value, domain=None, *args): + return StructuredVariableSettingMixin.decode_setting( + self, setting, value, domain, *args) + + def match_variable(self, setting, value, attributes, metas): + if not self.is_encoded_var(value): + return False + + attr_name, attr_type = value + # attr_type used to be either 1-4 for variables stored as string + # settings, and 101-104 for variables stored as variables. The former is + # no longer supported, but we play it safe and still handle both here. + attr_type %= 100 + return (not setting.exclude_attributes and + attributes.get(attr_name, -1) == attr_type or + not setting.exclude_metas and + metas.get(attr_name, -1) == attr_type) + + def match(self, context, domain, attrs, metas): + if context.attributes == attrs and context.metas == metas: + return self.PERFECT_MATCH + return StructuredVariableSettingMixin.match(self, context, attrs, metas) + + +class SimpleDomainContextHandler(ContextHandler, StructuredVariableSettingMixin, + VariableEncoderMixin): + def __init__(self): + super().__init__() + self.first_match = True + + def open_context(self, widget, domain): + if domain is None: + return + if not isinstance(domain, Domain): + domain = domain.domain + super().open_context(widget, domain) + + def filter_value(self, setting, data, domain, *args): + StructuredVariableSettingMixin.filter_value(self, setting, data, *args) + + def encode_setting(self, context, setting, value): + return StructuredVariableSettingMixin.encode_setting( + self, context, setting, value) + + def decode_setting(self, setting, value, domain, *args): + return StructuredVariableSettingMixin.decode_setting( + self, setting, value, domain, *args) + + def match_variable(self, setting, value, domain): + assert isinstance(setting, ContextSetting) + + if not self.is_encoded_var(value): + return False + + attr_name, attr_type = value + + if attr_name not in domain: + return False + + candidate = domain[attr_name] + idx = domain.index(candidate) + if (0 <= idx < len(domain.attributes) and setting.exclude_attributes + or idx >= len(domain.attributes) and setting.exclude_class_vars + or idx < 0 and setting.exclude_metas): + return False + + return self.encode_variable(candidate)[1] == attr_type + + def filter_value(self, setting, data, domain): + StructuredVariableSettingMixin.filter_value(self, setting, data, domain) + + def match(self, context, domain): + return StructuredVariableSettingMixin.match(self, context, domain) + + class ClassValuesContextHandler(ContextHandler): """Context handler used for widgets that work with a single discrete variable""" @@ -358,7 +448,7 @@ def encode_setting(self, context, setting, value): """Same as is domain context handler, but handles separately stored class_vars.""" - if isinstance(setting, ContextSetting) and isinstance(value, str): + if isinstance(setting, WBContextSetting) and isinstance(value, str): def _candidate_variables(): if not setting.exclude_attributes: diff --git a/Orange/widgets/visualize/owheatmap.py b/Orange/widgets/visualize/owheatmap.py index 53d1845331d..e2d7f3986fd 100644 --- a/Orange/widgets/visualize/owheatmap.py +++ b/Orange/widgets/visualize/owheatmap.py @@ -146,9 +146,9 @@ class Outputs: selected_data = Output("Selected Data", Table, default=True) annotated_data = Output(ANNOTATED_DATA_SIGNAL_NAME, Table) - settings_version = 3 + settings_version = 4 - settingsHandler = settings.DomainContextHandler() + settingsHandler = settings.SimpleDomainContextHandler() # Disable clustering for inputs bigger than this MaxClustering = 25000 @@ -170,7 +170,7 @@ class Outputs: #: text row annotation (row names) annotation_var = settings.ContextSetting(None) #: color row annotation - annotation_color_var = settings.ContextSetting(None) + annotation_color_var = settings.ContextSetting(None, exclude_attributes=True) column_annotation_color_key: Optional[Tuple[str, str]] = settings.ContextSetting(None) # Discrete variable used to split that data/heatmaps (vertically) diff --git a/Orange/widgets/visualize/tests/test_owheatmap.py b/Orange/widgets/visualize/tests/test_owheatmap.py index d6f42f29154..7649a3299ef 100644 --- a/Orange/widgets/visualize/tests/test_owheatmap.py +++ b/Orange/widgets/visualize/tests/test_owheatmap.py @@ -365,6 +365,20 @@ def test_row_color_annotations_with_na(self): widget.set_annotation_color_var(None) self.assertFalse(widget.scene.widget.right_side_colors[0].isVisible()) + def test_row_color_annotations_invalid_context(self): + widget = self.widget + data = self.brown_selected[::5] + self.send_signal(widget.Inputs.data, data, widget=widget) + widget.set_annotation_color_var(data.domain["function"]) + self.assertTrue(widget.scene.widget.right_side_colors[0].isVisible()) + # attributes are ignored in for annotation_color_var, so the following + # data should not match any context + data_attributes = self.brown_selected.transform( + Domain(data.domain.attributes + data.domain.class_vars)) + self.send_signal(widget.Inputs.data, data_attributes, widget=widget) + self.assertEqual(widget.row_side_color_cb.currentText(), "(None)") + self.assertFalse(widget.scene.widget.right_side_colors[0].isVisible()) + def test_col_color_annotations(self): widget = self.widget data = self._brown_selected_10()