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()