1#!/usr/bin/env python3
  2
  3"""Wrapper for XML report templates."""
  4
  5import imp
  6import collections
  7
  8from chart.common.path import Path
  9from chart.project import settings
 10import chart.alg.settings
 11from chart.reports.widget import widget_classes
 12from chart.common.decorators import memoized
 13from chart.common.decorators import memoized2
 14from chart.common.xml import load_xml
 15from chart.common.xml import parsechildstr
 16from chart.reports.document import Document
 17from chart.common.exceptions import ConfigError
 18from chart.common.xml import is_xml_comment
 19from chart.common.xml import parsechildbool
 20# from chart.reports.fix_breaks import fix_page_breaks
 21
 22ELEM_META = 'meta'
 23ELEM_THEME = 'theme'
 24ELEM_PREFIX = 'prefix'
 25ELEM_ENABLE_MATHS = 'enable-mathjax'
 26ELEM_DEFAULT = 'default'
 27
 28# Theme definition file inside a theme directory
 29THEME_FILENAME = 'theme.py'
 30
 31# These cannot be used as widget names.
 32# They may appear as top level element names inside the <template>
 33RESERVED_WORDS = ('name', 'description', 'theme', 'prefix', 'pdf-filename', 'meta',
 34                  'enable-mathjax')
 35
 36
 37class NoSuchTemplate(Exception):
 38    """This exception should be referenced as Template.NoSuchTemplate."""
 39
 40    def __init__(self, name):
 41        super(NoSuchTemplate, self).__init__()
 42        self.name = name
 43
 44    def __str__(self):
 45        return 'No such template: {bad_template}'.format(
 46            bad_template=self.name)
 47            # other_templates=', '.join(
 48                # (f.name for f in sorted(settings.REPORT_TEMPLATE_DIR.listdir())
 49                 # if f.ext == '.xml')))
 50
 51
 52class ReportTemplate:
 53    """Report template wrapper class."""
 54
 55    # we have a cached constructor
 56    _cache = {}
 57
 58    class __metaclass__(type):
 59        def __call__(cls, *args, **kwargs):
 60            return cls.__new__(cls, *args, **kwargs)
 61
 62    def __new__(cls, name, theme=None):
 63        res = ReportTemplate._cache.get((name, theme))
 64        if res is not None:
 65            return res
 66
 67        res = object.__new__(cls)
 68        ReportTemplate._cache[(name, theme)] = res
 69        res.__init__(name)
 70        return res
 71
 72    def __init__(self, name, theme=None):
 73        """Args:
 74        `name` (str): Report template name. This can either be the name of an XML file
 75            (without extension) fromthe project reports directory (settings.REPORT_TEMPLATE_DIR)
 76            or an relative or absolute XML filename'
 77        `theme` (str): Optional override to the report template <theme> value.
 78        """
 79
 80        name = Path(name)
 81
 82        filename = None
 83        # if run manually the user might have passed an actual filename including extension
 84        if name.exists() and not name.is_dir():
 85            filename = name
 86            self.name = name.name
 87
 88        # if run manually the user could also have passed a report template name with extension ...
 89        elif settings.REPORT_TEMPLATE_DIR.child(name).exists():
 90            filename = settings.REPORT_TEMPLATE_DIR.child(name)
 91            self.name = name.name
 92
 93        # ... or without extension
 94        elif settings.REPORT_TEMPLATE_DIR.child(str(name) + '.xml').exists():
 95            newname = str(name) + '.xml'
 96            filename = settings.REPORT_TEMPLATE_DIR.joinpath(newname)
 97            self.name = newname
 98
 99        if filename is None or not filename.exists():
100            raise NoSuchTemplate(name)
101
102        self.filename = filename
103
104        self.elem = load_xml(filename)
105
106        self.meta_elem = self.elem.find(ELEM_META)
107        if self.meta_elem is None:
108            # optionally allow reports to put metadata inside a <meta> element
109            self.meta_elem = self.elem
110
111        self.theme_name = theme
112        self._widgets = None
113
114    @property
115    def description(self):
116        """Return description or None if not present (obsolete)."""
117        return parsechildstr(self.meta_elem, 'description', 'no description')
118
119    @property
120    def prefix(self):
121        """Return the report <prefix> value, used as a filename prefix for all images."""
122        return parsechildstr(self.meta_elem, ELEM_PREFIX, None)
123
124    @property
125    def enable_maths(self):
126        """Test if the template includes <enable-mathjax>true</enable-mathjax>."""
127        return parsechildbool(self.meta_elem, ELEM_ENABLE_MATHS, False)
128
129    @property
130    def pdf_filename(self):
131        """Return the content of the <pdf-filename> element
132        (actually a PDF filename template, which is expanded to a specific PDF filename.
133        """
134        return parsechildstr(self.meta_elem, 'pdf-filename', None)
135
136    def get_widget_defaults(self, classname):
137        """Look for any defaults the template declares for us."""
138        # Warning, we get some weird effects if the user puts a comment in the <meta> element
139        for default_elem in self.meta_elem.findall(ELEM_DEFAULT):
140            if default_elem[0].tag == classname:
141                return default_elem[0]
142
143        return None
144
145    @property
146    def widgets(self):
147        """Return the list of widgets that makes up this template as a list
148        of Widget objects.
149        If not done already, interpret the template XML file and expand to a list
150        of Widget instances.
151        """
152
153        if self._widgets is not None:
154            return self._widgets
155
156        res = []
157
158        # Note there are a couple of things to be careful of:
159        # - During normal rendering report.py need to iterate through all widgets
160        #   early on, before scid, sensing start and stop have been determined.
161        #   This is bad and should be stopped i.e. self.widgets() should refuse to run
162        #   until we have instantiated self.elem properly.
163        # - It is critical that this function is run once, and the output value
164        #   passed to be `pre_html()` and `html()` in `Document`. This is because
165        #   widgets may modify themselfes in pre_html.
166
167        # Assemble all available widget classes into a list
168        all_widgets = {}
169        for widget_dir in settings.WIDGET_DIRS:
170            all_widgets.update(widget_classes(widget_dir['dir']))
171
172        # if self._widgets is None:
173            # self._widgets = []
174        for child_elem in self.elem:  # .findall('widget'):
175            if is_xml_comment(child_elem):
176                continue
177
178            if child_elem.tag == 'widget':
179                widget_name = parsechildstr(child_elem, 'class')
180                for a in all_widgets:
181                    if widget_name.lower().replace('-', '') == a.lower().replace('-', ''):
182                        widget_name = a
183
184            else:
185                widget_name = child_elem.tag
186
187            if widget_name in RESERVED_WORDS:
188                continue
189
190            if widget_name not in all_widgets:
191                raise ConfigError('Widget "{name}" not found'.format(
192                        name=widget_name))
193
194            widget = all_widgets[widget_name]()
195            defaults = self.get_widget_defaults(widget_name)
196            try:
197                widget.config_xml(child_elem, defaults)
198            except ConfigError as e:
199                e.elem = child_elem
200                raise e
201
202            # self._widgets.append(widget)
203            res.append(widget)
204
205                # if hasattr(widget, 'report_title'):
206                    # self.title = widget.report_title
207
208            # for widget_elem in self.elem.findall('widget'):
209            #     widget_name = parsechildstr(widget_elem, 'class')
210            #     if widget_name not in all_widgets:
211            #         raise ConfigError('Widget "{name}" not found'.format(
212            #                 name=widget_name))
213
214            #     widget = all_widgets[widget_name]()
215            #     try:
216            #         widget.config_xml(widget_elem)
217            #     except ConfigError as e:
218            #         e.elem = widget_elem
219            #         raise e
220
221            #     self._widgets.append(widget)
222
223        # return self._widgets
224
225        self._widgets = res
226        return res
227
228    @property
229    @memoized2
230    def theme(self):
231        """Return the theme module to use for this report.
232        A default value is specified as DEFAULT_THEME in the project `settings.py`.
233        This can be overridden by the <theme> element of the report template.
234        Both can e overridden with the `--theme ...` flag on the command line.
235        """
236
237        theme_name = parsechildstr(self.meta_elem,
238                                   ELEM_THEME,
239                                   settings.DEFAULT_THEME)
240
241        if self.theme_name is not None:
242            theme_name = self.theme_name
243
244        # look for theme directory
245        theme_dir = settings.THEME_DIR.child(theme_name)
246        # for dirname in (settings.CHART_THEME_DIR,
247                  # settings.THEME_DIR):
248            # test = os.path.join(dirname, theme_name)
249            # if os.path.isdir(test):
250                # theme_dir = test
251
252        # if theme_dir is None:
253        if not theme_dir.is_dir():
254            raise ConfigError('Cannot locate theme {theme} in {proj}'.format(
255                    theme=theme_name,
256                    # sys=settings.CHART_THEME_DIR,
257                    proj=settings.THEME_DIR))
258
259        theme_filename = theme_dir.joinpath(THEME_FILENAME)
260        if not theme_filename.exists():
261            raise ConfigError('Cannot find {file} inside theme directory {dir}'.format(
262                    file=THEME_FILENAME,
263                    dir=theme_dir))
264
265        return imp.load_source(theme_name, str(theme_filename))
266
267    def document_options(self):
268        """Compute a consolidated list of document options for all widgets in `template`."""
269        options = collections.OrderedDict()
270        for widget in self.widgets:
271            # note a widget may have no options
272            if hasattr(widget.__class__, 'document_options'):
273                for k, v in widget.__class__.document_options.items():
274                    if k not in options:
275                        options[k] = v
276
277        return options
278
279    def render(self, config, debug=False, dump_values=False):
280        """Create a report based on ourselves, with specific document configuration in
281        `config` (scid, sensing-start, sensing-stop)
282        Note, each of our widgets has its own widget.config structure also.
283        """
284
285        doc = Document()
286        doc.config = config
287        doc.report_filename = chart.alg.settings.REPORT_FILENAME
288        # report_filename()
289        # insert XML glyphs for unicode chars
290        # handle = doc.report_filename.open('wb',
291                                          # handle.write(h.getvalue().encode('latin-1', 'xmlcharrefreplace')))
292        import codecs
293        handle = codecs.open(str(doc.report_filename),
294                             mode='wb',
295                             encoding='utf-8')
296
297        doc.render(self, debug, dump_values, doc.config['sid'])
298        doc.write(handle)
299        # doc.report_filename.copy(doc.report_filename + '.bak')
300        # fix_page_breaks(doc.report_filename)
301        return doc
302
303
304@staticmethod
305def all_templates():
306    """Yield a ReportTemplate object for each template in the system templates directory."""
307    for filename in sorted(settings.REPORT_TEMPLATE_DIR.glob('*.xml')):
308        if filename.name == 'schemas.xml':
309            continue
310
311        yield ReportTemplate(filename)
312
313
314ReportTemplate.NoSuchWidget = NoSuchTemplate  # (unused variable) pylint: disable=W0612
315ReportTemplate.all_templates = all_templates  # (unused variable) pylint: disable=W0612
316ReportTemplate.all = all_templates