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