1"""Widget Settings and Settings Handlers 2 3Settings are used to declare widget attributes that persist through sessions. 4When widget is removed or saved to a schema file, its settings are packed, 5serialized and stored. When a new widget is created, values of attributes 6marked as settings are read from disk. When schema is loaded, attribute values 7are set to one stored in schema. 8 9Each widget has its own SettingsHandler that takes care of serializing and 10storing of settings and SettingProvider that is incharge of reading and 11writing the setting values. 12 13All widgets extending from OWWidget use SettingsHandler, unless they 14declare otherwise. SettingsHandler ensures that setting attributes 15are replaced with default (last used) setting values when the widget is 16initialized and stored when the widget is removed. 17 18Widgets with settings whose values depend on the widget inputs use 19settings handlers based on ContextHandler. These handlers have two 20additional methods, open_context and close_context. 21 22open_context is called when widgets receives new data. It finds a suitable 23context and sets the widget attributes to the values stored in context. 24If no suitable context exists, a new one is created and values from widget 25are copied to it. 26 27close_context stores values that were last used on the widget to the context 28so they can be used alter. It should be called before widget starts modifying 29(initializing) the value of the setting attributes. 30""" 31 32# Overriden methods in these classes add arguments 33# pylint: disable=arguments-differ,unused-argument,no-value-for-parameter 34 35import copy 36import itertools 37import logging 38import warnings 39 40from orangewidget.settings import ( 41 Setting, SettingProvider, SettingsHandler, ContextSetting, 42 ContextHandler, Context, IncompatibleContext, SettingsPrinter, 43 rename_setting, widget_settings_dir 44) 45from orangewidget.settings import _apply_setting 46 47from Orange.data import Domain, Variable 48from Orange.util import OrangeDeprecationWarning 49from Orange.widgets.utils import vartype 50 51log = logging.getLogger(__name__) 52 53__all__ = [ 54 # re-exported from orangewidget.settings 55 "Setting", "SettingsHandler", "SettingProvider", 56 "ContextSetting", "Context", "ContextHandler", "IncompatibleContext", 57 "rename_setting", "widget_settings_dir", 58 # defined here 59 "DomainContextHandler", "PerfectDomainContextHandler", 60 "ClassValuesContextHandler", "SettingsPrinter", 61 "migrate_str_to_variable", 62] 63 64 65class DomainContextHandler(ContextHandler): 66 """Context handler for widgets with settings that depend on 67 the input dataset. Suitable settings are selected based on the 68 data domain.""" 69 70 MATCH_VALUES_NONE, MATCH_VALUES_CLASS, MATCH_VALUES_ALL = range(3) 71 72 def __init__(self, *, match_values=0, first_match=True, **kwargs): 73 super().__init__() 74 self.match_values = match_values 75 self.first_match = first_match 76 77 for name in kwargs: 78 warnings.warn( 79 "{} is not a valid parameter for DomainContextHandler" 80 .format(name), OrangeDeprecationWarning 81 ) 82 83 def encode_domain(self, domain): 84 """ 85 domain: Orange.data.domain to encode 86 return: dict mapping attribute name to type or list of values 87 (based on the value of self.match_values attribute) 88 """ 89 90 match = self.match_values 91 encode = self.encode_variables 92 if match == self.MATCH_VALUES_CLASS: 93 attributes = encode(domain.attributes, False) 94 attributes.update(encode(domain.class_vars, True)) 95 else: 96 attributes = encode(domain.variables, match == self.MATCH_VALUES_ALL) 97 98 metas = encode(domain.metas, match == self.MATCH_VALUES_ALL) 99 100 return attributes, metas 101 102 @staticmethod 103 def encode_variables(attributes, encode_values): 104 """Encode variables to a list mapping name to variable type 105 or a list of values.""" 106 107 if not encode_values: 108 return {v.name: vartype(v) for v in attributes} 109 110 return {v.name: v.values if v.is_discrete else vartype(v) 111 for v in attributes} 112 113 def new_context(self, domain, attributes, metas): 114 """Create a new context.""" 115 context = super().new_context() 116 context.attributes = attributes 117 context.metas = metas 118 return context 119 120 def open_context(self, widget, domain): 121 if domain is None: 122 return 123 if not isinstance(domain, Domain): 124 domain = domain.domain 125 super().open_context(widget, domain, *self.encode_domain(domain)) 126 127 def filter_value(self, setting, data, domain, attrs, metas): 128 value = data.get(setting.name, None) 129 if isinstance(value, list): 130 new_value = [item for item in value 131 if self.is_valid_item(setting, item, attrs, metas)] 132 data[setting.name] = new_value 133 elif isinstance(value, dict): 134 new_value = {item: val for item, val in value.items() 135 if self.is_valid_item(setting, item, attrs, metas)} 136 data[setting.name] = new_value 137 elif self.is_encoded_var(value) \ 138 and not self._var_exists(setting, value, attrs, metas): 139 del data[setting.name] 140 141 @staticmethod 142 def encode_variable(var): 143 return var.name, 100 + vartype(var) 144 145 @classmethod 146 def encode_setting(cls, context, setting, value): 147 if isinstance(value, list): 148 if all(e is None or isinstance(e, Variable) for e in value) \ 149 and any(e is not None for e in value): 150 return ([None if e is None else cls.encode_variable(e) 151 for e in value], 152 -3) 153 else: 154 return copy.copy(value) 155 156 elif isinstance(value, dict) \ 157 and all(isinstance(e, Variable) for e in value): 158 return ({cls.encode_variable(e): val for e, val in value.items()}, 159 -4) 160 161 if isinstance(value, Variable): 162 if isinstance(setting, ContextSetting): 163 return cls.encode_variable(value) 164 else: 165 raise ValueError("Variables must be stored as ContextSettings; " 166 f"change {setting.name} to ContextSetting.") 167 168 return copy.copy(value), -2 169 170 # backward compatibility, pylint: disable=keyword-arg-before-vararg 171 def decode_setting(self, setting, value, domain=None, *args): 172 def get_var(name): 173 if domain is None: 174 raise ValueError("Cannot decode variable without domain") 175 return domain[name] 176 177 if isinstance(value, tuple): 178 data, dtype = value 179 if dtype == -3: 180 return[None if name_type is None else get_var(name_type[0]) 181 for name_type in data] 182 if dtype == -4: 183 return {get_var(name): val for (name, _), val in data.items()} 184 if dtype >= 100: 185 return get_var(data) 186 return value[0] 187 else: 188 return value 189 190 @classmethod 191 def _var_exists(cls, setting, value, attributes, metas): 192 if not cls.is_encoded_var(value): 193 return False 194 195 attr_name, attr_type = value 196 # attr_type used to be either 1-4 for variables stored as string 197 # settings, and 101-104 for variables stored as variables. The former is 198 # no longer supported, but we play it safe and still handle both here. 199 attr_type %= 100 200 return (not setting.exclude_attributes and 201 attributes.get(attr_name, -1) == attr_type or 202 not setting.exclude_metas and 203 metas.get(attr_name, -1) == attr_type) 204 205 def match(self, context, domain, attrs, metas): 206 if context.attributes == attrs and context.metas == metas: 207 return self.PERFECT_MATCH 208 209 matches = [] 210 try: 211 for setting, data, _ in \ 212 self.provider.traverse_settings(data=context.values): 213 if not isinstance(setting, ContextSetting): 214 continue 215 value = data.get(setting.name, None) 216 217 if isinstance(value, list): 218 matches.append( 219 self.match_list(setting, value, context, attrs, metas)) 220 # type check is a (not foolproof) check in case of a pair that 221 # would, by conincidence, have -3 or -4 as the second element 222 elif isinstance(value, tuple) and len(value) == 2 \ 223 and (value[1] == -3 and isinstance(value[0], list) 224 or (value[1] == -4 and isinstance(value[0], dict))): 225 matches.append(self.match_list(setting, value[0], context, 226 attrs, metas)) 227 elif value is not None: 228 matches.append( 229 self.match_value(setting, value, attrs, metas)) 230 except IncompatibleContext: 231 return self.NO_MATCH 232 233 if self.first_match and matches and sum(m[0] for m in matches): 234 return self.MATCH 235 236 matches.append((0, 0)) 237 matched, available = [sum(m) for m in zip(*matches)] 238 return matched / available if available else 0.1 239 240 def match_list(self, setting, value, context, attrs, metas): 241 """Match a list of values with the given context. 242 returns a tuple containing number of matched and all values. 243 """ 244 matched = 0 245 for item in value: 246 if self.is_valid_item(setting, item, attrs, metas): 247 matched += 1 248 elif setting.required == ContextSetting.REQUIRED: 249 raise IncompatibleContext() 250 return matched, len(value) 251 252 def match_value(self, setting, value, attrs, metas): 253 """Match a single value """ 254 if value[1] < 0: 255 return 0, 0 256 257 if self._var_exists(setting, value, attrs, metas): 258 return 1, 1 259 elif setting.required == setting.OPTIONAL: 260 return 0, 1 261 else: 262 raise IncompatibleContext() 263 264 def is_valid_item(self, setting, item, attrs, metas): 265 """Return True if given item can be used with attrs and metas 266 267 Subclasses can override this method to checks data in alternative 268 representations. 269 """ 270 if not isinstance(item, tuple): 271 return True 272 return self._var_exists(setting, item, attrs, metas) 273 274 @staticmethod 275 def is_encoded_var(value): 276 return isinstance(value, tuple) \ 277 and len(value) == 2 \ 278 and isinstance(value[0], str) and isinstance(value[1], int) \ 279 and value[1] >= 0 280 281class ClassValuesContextHandler(ContextHandler): 282 """Context handler used for widgets that work with 283 a single discrete variable""" 284 285 def open_context(self, widget, classes): 286 if isinstance(classes, Variable): 287 if classes.is_discrete: 288 classes = classes.values 289 else: 290 classes = None 291 292 super().open_context(widget, classes) 293 294 def new_context(self, classes): 295 context = super().new_context() 296 context.classes = classes 297 return context 298 299 def match(self, context, classes): 300 if isinstance(classes, Variable) and classes.is_continuous: 301 return (self.PERFECT_MATCH if context.classes is None 302 else self.NO_MATCH) 303 else: 304 # variable.values used to be a list, and so were context.classes 305 # cast to tuple for compatibility with past contexts 306 if context.classes is not None and tuple(context.classes) == classes: 307 return self.PERFECT_MATCH 308 else: 309 return self.NO_MATCH 310 311 312class PerfectDomainContextHandler(DomainContextHandler): 313 """Context handler that matches a context only when 314 the same domain is available. 315 316 It uses a different encoding than the DomainContextHandler. 317 """ 318 319 def new_context(self, domain, attributes, class_vars, metas): 320 """Same as DomainContextHandler, but also store class_vars""" 321 context = super().new_context(domain, attributes, metas) 322 context.class_vars = class_vars 323 return context 324 325 def clone_context(self, old_context, *args): 326 """Copy of context is always valid, since widgets are using 327 the same domain.""" 328 context = self.new_context(*args) 329 context.values = copy.deepcopy(old_context.values) 330 return context 331 332 def encode_domain(self, domain): 333 """Encode domain into tuples (name, type) 334 A tuple is returned for each of attributes, class_vars and metas. 335 """ 336 337 if self.match_values == self.MATCH_VALUES_ALL: 338 def _encode(attrs): 339 return tuple((v.name, list(v.values) if v.is_discrete else vartype(v)) 340 for v in attrs) 341 else: 342 def _encode(attrs): 343 return tuple((v.name, vartype(v)) for v in attrs) 344 return (_encode(domain.attributes), 345 _encode(domain.class_vars), 346 _encode(domain.metas)) 347 348 def match(self, context, domain, attributes, class_vars, metas): 349 """Context only matches when domains are the same""" 350 351 return (self.PERFECT_MATCH 352 if (context.attributes == attributes and 353 context.class_vars == class_vars and 354 context.metas == metas) 355 else self.NO_MATCH) 356 357 def encode_setting(self, context, setting, value): 358 """Same as is domain context handler, but handles separately stored 359 class_vars.""" 360 361 if isinstance(setting, ContextSetting) and isinstance(value, str): 362 363 def _candidate_variables(): 364 if not setting.exclude_attributes: 365 yield from itertools.chain(context.attributes, 366 context.class_vars) 367 if not setting.exclude_metas: 368 yield from context.metas 369 370 for aname, atype in _candidate_variables(): 371 if aname == value: 372 return value, atype 373 374 return value, -1 375 else: 376 return super().encode_setting(context, setting, value) 377 378 379def migrate_str_to_variable(settings, names=None, none_placeholder=None): 380 """ 381 Change variables stored as `(str, int)` to `(Variable, int)`. 382 383 Args: 384 settings (Context): context that is being migrated 385 names (sequence): names of settings to be migrated. If omitted, 386 all settings with values `(str, int)` are migrated. 387 """ 388 def _fix(name): 389 var, vtype = settings.values[name] 390 if 0 <= vtype <= 100: 391 settings.values[name] = (var, 100 + vtype) 392 elif var == none_placeholder and vtype == -2: 393 settings.values[name] = None 394 395 if names is None: 396 for name, setting in settings.values.items(): 397 if DomainContextHandler.is_encoded_var(setting): 398 _fix(name) 399 elif isinstance(names, str): 400 _fix(names) 401 else: 402 for name in names: 403 _fix(name) 404