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