1#!/usr/bin/env python3
  2
  3"""Widget base class and initialisation functions including widget directory scan."""
  4
  5import sys
  6import imp
  7import inspect
  8import logging
  9import collections
 10from collections import OrderedDict
 11
 12from chart.common.path import Path
 13from chart.common.xml import parsechildstr
 14from chart.common.exceptions import ConfigError
 15from chart.common import traits
 16from chart.common.util import nvl
 17from chart.common.traits import Trait
 18from chart.common.prettyprint import Table
 19from chart.common.decorators import memoized
 20from chart.reports.condition import Condition
 21from chart.common.xml import xml_to_str
 22from chart.browse.make_url import make_url
 23from chart.common.xml import SubElement
 24
 25logger = logging.getLogger()
 26
 27ELEM_ONLY_CHOICES = 'only-choices'
 28
 29
 30def apply_defaults(widget_elem, defaults_elem):
 31    """Apply template widget defaults to a widget.
 32
 33    Scan through the children of `defaults_elem` looking for one with the same tag
 34    as `widget_elem`. When found we look through it's children for any tags not present
 35    in the current widget, and copy them over."""
 36
 37    for default_elem in defaults_elem:
 38        from lxml.etree import iselement
 39        # logging.debug('Examining default ' + str(default_elem.tag) + ' iselement ' +
 40                      # str(iselement(default_elem)) + ' len ' + str(len(default_elem)))
 41        if not isinstance(default_elem.tag, str):
 42            # skip comments
 43            continue
 44
 45        found = False
 46        for widget_setting in widget_elem:
 47            if widget_setting.tag == default_elem.tag:
 48                found = True
 49                break
 50
 51        if not found:
 52            widget_setting = SubElement(widget_elem, default_elem.tag)
 53            widget_setting.text = default_elem.text
 54
 55        apply_defaults(widget_setting, default_elem)
 56            # if len(default_elem) > 0:
 57                # default has children
 58
 59
 60class WidgetOption(Trait):
 61    """Configurable option for a Widget."""
 62
 63    def __init__(self,
 64                 name,
 65                 description=None,
 66                 datatype=None,
 67                 default=Trait.NO_DEFAULT,
 68                 restrict_to_choices=True,
 69                 choices=None,
 70                 optional=False,
 71                 multiple=None,
 72                 unit=None,
 73                 metavar=None):
 74        """Constructor.
 75
 76        Args:
 77        `name` (str): Option name
 78        `description` (str): Long description
 79        `datatype` (DataType or list of WidgetOption): Permitted data types. Options with
 80            suboptions should include a list of WidgetOptions instead
 81        `default` (any): Value to use if none specified. If given, `datatype` is deduced
 82        `restrict_to_choices` (bool): If true and `choices` are given, value must be one of them
 83        `choices` (list of WidgetOptionChoices): Permitted/suggested values
 84        `optional` (bool): Value can be omited. If omited there will be no entry in the
 85            config object.
 86        `multiple` (bool): Allow the option to be set multiple times. Values are collected
 87            into a list in the config structure
 88        `unit` (str): Units of value
 89        `metavar` (str): In generated help strings, meta variable to represent the number
 90        """
 91
 92        if datatype is None and default is Trait.NO_DEFAULT:
 93            raise ConfigError('Config {n} has no datatype'.format(n=name))
 94
 95        super(WidgetOption, self).__init__(datatype=nvl(datatype, type(default)),
 96                                           choices=choices,
 97                                           default=default,
 98                                           unit=unit,
 99                                           description=description)
100        self.name = name
101        self.optional = optional
102        self.multiple = multiple
103        self.restrict_to_choices = restrict_to_choices
104        self.metavar = metavar
105
106
107    def __str__(self):
108        extra = []
109        if self.default is not Trait.NO_DEFAULT:
110            extra.append(',default={d}'.format(d=self.default))
111
112        else:
113            extra.append(',nodefault')
114
115        if self.optional:
116            extra.append(',optional')
117
118        else:
119            extra.append(',required')
120
121        if self.multiple:
122            extra.append(',multiple')
123
124        return 'WidgetOption("{p}"{e})'.format(
125            p=self.name, e=''.join(extra))
126            # p='{k}:{v}'.format(
127                # k=k, v=getattr(self, k)) for k in ('name', 'optional'))
128
129
130class WidgetOptionChoice:
131    """Enum choice for a Widget option.
132
133    This could potentially be merged with traits.Item, but those are stored as a dict
134    keyed by min_value, here we need a list. Using list everywhere might slow down Item
135    lookups.
136    """
137
138    def __init__(self,
139                 name,
140                 description=None):
141        self.name = name
142        self.description = description
143
144    @staticmethod
145    def from_dict(d):
146        if 'description' in d:
147            return WidgetOptionChoice(name=d['name'], description=d['description'])
148        else:
149            return WidgetOptionChoice(name=d['name'])
150
151class Widget:
152    """Define default functions which may be overriden.
153
154    Define member variables which template and derived widgets require.
155    """
156
157    options = None
158
159    def __str__(self):
160        return self.__class__.__name__
161
162    def __init__(self):
163        self.config = None
164        self.elem = None
165        self.cond = Condition()
166
167        # logging.debug('Init of widget {name}'.format(name=self.__class__.__name__))
168        def as_list_of_widgetoptions(obj):
169            """Convert old-style ordereddict of dicts to a new-style list of WidgetOptions."""
170            if isinstance(obj, OrderedDict):
171                list_options = []
172                for k, v in obj.items():
173                    choices = v.get('choices')
174                    if choices is not None:
175                        choices = [WidgetOptionChoice.from_dict(c) if isinstance(c, dict) else c for c in choices]
176
177                    list_options.append(WidgetOption(
178                        name=k,
179                        description=v.get('description'),
180                        datatype=as_list_of_widgetoptions(v['type']),
181                        default=v.get('default', Trait.NO_DEFAULT),
182                        restrict_to_choices=v.get(ELEM_ONLY_CHOICES),
183                        choices=choices,
184                        optional=v.get('optional'),
185                        multiple=v.get('multiple'),
186                        unit=v.get('unit')))
187
188                return list_options
189
190            else:
191                return obj
192
193        self.__class__.options = as_list_of_widgetoptions(self.__class__.options)
194
195    def pre_html(self, document):
196        """Initial pass. No output is generated but Template variables may be modified."""
197        pass
198
199    def post_html(self, document):
200        """Optional tidy up pass, allow widget to run extra code after all rendering."""
201        pass
202
203    def html(self, document):
204        """Second pass. HTML is generated by derived Widget classes."""
205        pass
206
207    def parse_param(self, options, parent_elem, scid=None):  # (unused arg) pylint: disable=W0613
208        """Convert the content of XML `elem` into a Python value to be placed into
209        our `config` member.
210        """
211
212        config = {}
213        for param_elem in parent_elem.xpath('*'):
214            if param_elem.tag == 'class':
215                # as we are looking for configuration parameters skip over the
216                # <class>Parameter</class> element
217                continue
218
219            if param_elem.tag == 'cond':
220                # a condition for the widget - scid
221                self.cond.append(param_elem)
222                continue
223
224            if param_elem.tag == 'param':
225                # old-style configuration using <param><name>x</name><value>y</value></param>
226                name = parsechildstr(param_elem, 'name')
227
228            else:
229                # new-style configuration <x>y</x>
230                name = param_elem.tag
231
232            if options is None:
233                raise ConfigError(
234                    'Found option "{option}" in widget with no options allowed'.format(
235                        option=name))
236
237            option = None
238            for o in options:
239                if o.name == name:
240                    option = o
241
242            # if name not in options:
243            if option is None:
244                raise ConfigError('Unknown option "{option}". Available options: '
245                                  '{options}'.format(
246                        option=name,
247                        options=', '.join(o.name for o in self.options)))
248
249            # option = options[name]
250
251            if param_elem.tag == 'param':
252                # recursion could be folded into the Trait object itself
253                if isinstance(option.datatype, list):
254                    # recursive option, generate a dictionary of sub-options
255                    new_value = self.parse_param(option.datatype, param_elem.find('value'))
256
257                else:
258                    # generate a single value
259                    new_value = option.from_xml_child(param_elem, 'value')
260
261            else:
262                if isinstance(option.datatype, list):
263                    # recursive option
264                    new_value = self.parse_param(option.datatype, param_elem)
265
266                else:
267                    try:
268                        new_value = option.from_xml(param_elem)
269                    except ValueError as e:
270                        logging.debug(option)
271                        logging.debug(xml_to_str(param_elem))
272                        raise ConfigError(message=str(e), elem=param_elem)
273
274            # if this option has a list of choices defined make the user has picked one of them
275            if option.choices is not None:
276                # this would be better done in the traits module
277                # if option.datatype is Datatype.EVENTCLASS:
278                    # from chart.events.eventclass import EventClass
279                    # choices = [EventClass(c.name']) for c in option['choices']]
280
281                # else:
282                choices = [c.name for c in option.choices]
283
284                if new_value not in choices and option.restrict_to_choices:
285                    raise ConfigError(
286                        'Unknown option {picked} for {param}. Available choices are '
287                        '{avail}'.format(picked=new_value, param=name, avail=', '.join(choices)))
288
289            if option.multiple:
290                # create/append to a list of options with the same name
291                if name not in config:
292                    config[name] = []
293
294                config[name].append(new_value)
295
296            else:
297                # only one option allowed
298                if name in config:
299                    raise ConfigError('Cannot set option {name} multiple times'.format(name=name))
300
301                config[name] = new_value
302
303        # now look for missing required options, and default options
304        if options is not None:
305            missing = []
306            for o in options:
307                # look for available options which have not been set in template
308                if o.name not in config:
309                    if not hasattr(o.default, 'this_is_no_default'):
310                        # the widget option has a default value
311                        config[o.name] = o.default
312
313                    elif not o.optional and not o.multiple:
314                        # the option is neither optional nor multiple, so is missing
315                        missing.append(o.name)
316
317            if len(missing) > 0:
318                raise ConfigError('Missing options: {missing}'.format(missing=', '.join(missing)))
319
320        return config
321
322    def config_xml(self, widget_elem, defaults_elem):
323        """Examine the XML element describing this widget and use it to set our
324        `config` dictionary, based on our available `options`.
325        """
326
327        # apply defaults as simple text substitution
328        if defaults_elem is not None:
329            apply_defaults(widget_elem, defaults_elem)
330
331        # config = {}
332        self.elem = widget_elem
333
334        if not getattr(self, 'raw_options', False):
335            # for a normal widget we pass its child elements are parameters
336            self.config = self.parse_param(self.options, widget_elem)
337
338        else:
339            # the HTML widget is special because its children are normally copied to the report.
340            # The only special case is cond which we parse manually
341            for param_elem in widget_elem.xpath('*'):
342                if param_elem.tag == 'cond':
343                    self.cond.append(param_elem)
344
345        # for param_elem in widget_elem.findall('param'):
346            # config_param = self.parse_param(self.options,
347                                                   # param_elem)
348
349            # if 'multiple' in self.options[name]:
350            #     if name not in config:
351            #         config[name] = []
352
353            #     config[name].append(config_param)
354
355            # else:
356            #     config[name] = config_param
357
358        # if any parameters were defined with multiple=True but the widget does not
359        # include the parameter, create an empty list
360        # for k, v in self.options.iteritems():
361            # if v.get('multiple', False) == True and k not in config:
362                # config[k] = []
363
364        # self.config = config
365
366    def make_html_config(self):
367        """Convert our interpreted config to a list of (`key`, `value`) tuples
368        where both `key` and `value` are HTML strings.
369        """
370
371        t = Table()
372        for k, v in self.config.items():
373            for o in self.options:
374                if o.name == k:
375                    continue
376
377            label = '<span style="font-style:italic" title="{desc}">{name}</span>'.format(
378                desc=o.description, name=k)
379            if isinstance(o.datatype, list):
380                # treat config value as a simple list
381                # t.append((label, '<ul>' + ''.join(
382                            # '<li>' + traits.to_htmlstr(i) + '</li>' for i in v)))
383                t.append((label, traits.to_htmlstr(v)))
384
385            else:
386                t.append((label, traits.to_htmlstr(v)))
387
388        return t.to_html_str()
389
390    def browse_source_url(self):
391        """Return a URL where the user can browse the source to this widget."""
392        filename = sys.modules[type(self).__module__].__file__
393        if filename.endswith('.pyc') or filename.endswith('.pyo'):
394            filename = filename[:-1]
395
396        return make_url(Path(filename))
397
398
399def de_pyc(filename):
400    """Truncate '.pyc' file extension to '.py', if present."""
401
402    if filename.endswith('.pyc'):
403        return filename[:-1]
404
405    else:
406        return filename
407
408
409def add_widgets(filename):
410    """Import `filename` as a module, search it for Widgets and return a dictionary
411    of {name:class} for each one.
412    """
413
414    name = filename.stem
415    mod = imp.load_source(name, str(filename))
416
417    # Look for objects which are:
418    # - classes
419    # - derived from Widget
420    # - defined in the file we are currently scanning
421    #   (i.e. not a class defined elsewhere and imported)
422
423    res = {}
424
425    for cls in vars(mod).values():
426        if not isinstance(cls, type):
427            # object is not a class definition
428            continue
429
430        # print(cls, Widget, str(cls.__bases__))
431        # if not issubclass(cls, Widget):
432            # continue
433        # this is crazy... issubclass() suddenly stopped working one day
434        # but cls.__bases__ is still there#
435        # so we do this awkward thing
436        is_widget = False
437        for a in cls.__bases__:
438            while True:
439                # print('testing ' + str(a) + ' ' + a.__name__)
440                if a.__name__ == 'Widget':
441                    is_widget = True
442
443                if len(a.__bases__) == 0:
444                    break
445
446                a = a.__bases__[0]  # also this could break with a widget derived
447                # from multiple baseclasses which are not all widgets
448
449        if not is_widget:
450            # class definition is not a widget
451            continue
452
453        if de_pyc(inspect.getfile(cls)) != str(filename):
454            # ?
455            continue
456
457        res[cls.name] = cls
458
459    return res
460
461
462@memoized
463def widget_classes(directory):
464    """Inspect the widgets directories for build up a dictionary of Widget.name
465    against Widget class for each one found.
466
467    Returns:
468        List of dictionaries with keys {'directory', 'browse_url', 'widgets'}
469        where `widgets` is a dictionary of widget names against classes.
470    """
471    # logger.debug('widget classes ' + str(directory))
472
473    result = {}
474
475    if directory is None:
476        return result
477
478    # look for Python files
479    for filename in directory.iterdir():
480        # logger.debug('testing ' + str(filename))
481        # exclude any non-Python files or those starting with '.'
482        if filename.suffix != '.py' or str(filename.name).startswith('.'):
483            continue
484
485        # result.update(add_widgets(fullname))
486        for new_name, new_widget in add_widgets(filename).items():
487            if new_name in result:
488                raise ConfigError('Duplicate widget name: {name}'.format(name=new_name))
489
490            else:
491                result[new_name] = new_widget
492
493    return result
494
495
496# def main():
497#     """Command line entry point."""
498#     from itertools import chain
499#     from chart.common.args import ArgumentParser
500#     from chart.project import settings
501#     parser = ArgumentParser(__doc__)
502#     parser.add_argument('--list', '-l',
503#                       action='store_true',
504#                       help='List all widgets')
505#     args = parser.parse_args()
506#     if args.list:
507#         for w in chain(widget_classes(settings.CORE_WIDGET_DIR),
508#                        widget_classes(settings.PROJ_WIDGET_DIR)):
509#                           settings.DIGEST_WIDGET_DIR):
510#             print(w)
511
512#         parser.exit()
513
514#     parser.error('No actions specified')
515
516# if __name__ == '__main__':
517#     main()