Merge lp:~jimbaker/pyjuju/generate-html into lp:pyjuju/ftests

Proposed by Jim Baker
Status: Merged
Approved by: Gustavo Niemeyer
Approved revision: 16
Merged at revision: 2
Proposed branch: lp:~jimbaker/pyjuju/generate-html
Merge into: lp:pyjuju/ftests
Diff against target: 499 lines (+461/-0)
7 files modified
f_tests.mustache (+24/-0)
pystache/LICENSE (+20/-0)
pystache/__init__.py (+8/-0)
pystache/loader.py (+47/-0)
pystache/template.py (+178/-0)
pystache/view.py (+94/-0)
waterfall.py (+90/-0)
To merge this branch: bzr merge lp:~jimbaker/pyjuju/generate-html
Reviewer Review Type Date Requested Status
Gustavo Niemeyer Approve
Review via email: mp+73924@code.launchpad.net

Description of the change

Implements waterfall.py, which writes to stdout an HTML summary of the results, using a waterfall style. This report links the output of failed and successful tests. This follows the review by Gustavo below. Note that the formatting is intentionally bare; it will be addressed in a future branch that adds CSS and expands f_tests.mustache in a minimal fashion.

A future stacked branch will implemented an opinionated driver that will manage working with bzr and driving churn; this driver will be called butler.py, to be in the spirit of that name.

To post a comment you must log in.
Revision history for this message
Gustavo Niemeyer (niemeyer) wrote :

[1]

This tool is doing three different things:

1. Running bzr update
2. Running churn
3. Converting churn output to html

The tool we discussed should do 3 only.

Let it take as input a directory full of churn output directories, and
produce the output based on the directory name. So we run it like:

$ butler.py watefall/ > waterfall.html

Given that within the waterfall dir one might expect:

    /waterfall
        /341
            some-test-name.out
            other-test-name.out.FAILED
        /342
            some-test-name.out
            other-test-name.out

etc. butler will then have the 341, 342, etc in the rows, and the test
names in the columns, with OK/FAILED in the cells, as you've done.

Once this is in place, let's create a tiny but opinionated driver
that knows how to update a branch *revision by revision* and drive
churn and butler from outside. This driver must get as input _just_
the build directory, and will have three things inside it.

So

  $ whatever build/

Generates

  build/
    ensemble/
    waterfall/
    waterfall.html

But once you run

  $ whatever build/

again, it will notice that there's already content inside it,
and will just update revision by revision the branch between
the latest revision in the waterfall and the current known
tip of the trunk.

With that in place, we can put this logic in a machine, and have it
rsyncing this content out into a public web server.

Do not use bzrlib, btw. Use bzr itself. We're not doing anything
fancy, and the command line API won't change.

[2]

Please put a copy of pystache within the tree.

[3]

96 + <td>{{#failed}}<a href="{{path}}"><strong>FAILED</strong></a>{{/failed}}
97 + {{^failed}}Succeeded{{/failed}}

We have the succeeded output as well. Let's offer it so people can
investigate differences.

It might also be better to have "ok" (lowercased) rather than "Succeeded", to make the
"FAILED" one much more outstanding.

review: Needs Fixing
Revision history for this message
Jim Baker (jimbaker) wrote :

On Fri, Sep 2, 2011 at 6:42 PM, Gustavo Niemeyer <email address hidden>wrote:

> Review: Needs Fixing
> [1]
>
> This tool is doing three different things:
>
> 1. Running bzr update
> 2. Running churn
> 3. Converting churn output to html
>
> The tool we discussed should do 3 only.
>
> Let it take as input a directory full of churn output directories, and
> produce the output based on the directory name. So we run it like:
>
> $ butler.py watefall/ > waterfall.html
>
> Given that within the waterfall dir one might expect:
>
> /waterfall
> /341
> some-test-name.out
> other-test-name.out.FAILED
> /342
> some-test-name.out
> other-test-name.out
>
> etc. butler will then have the 341, 342, etc in the rows, and the test
> names in the columns, with OK/FAILED in the cells, as you've done.
>

Done. I believe the mechanism I have used in working with mustache templates
to generate this table is the best approach.

>
> Once this is in place, let's create a tiny but opinionated driver
> that knows how to update a branch *revision by revision* and drive
> churn and butler from outside. This driver must get as input _just_
> the build directory, and will have three things inside it.
>
> So
>
> $ whatever build/
>
> Generates
>
> build/
> ensemble/
> waterfall/
> waterfall.html
>
> But once you run
>
> $ whatever build/
>
> again, it will notice that there's already content inside it,
> and will just update revision by revision the branch between
> the latest revision in the waterfall and the current known
> tip of the trunk.
>
> With that in place, we can put this logic in a machine, and have it
> rsyncing this content out into a public web server.
>
> Do not use bzrlib, btw. Use bzr itself. We're not doing anything
> fancy, and the command line API won't change.
>

Sounds good. Given that butler is supposed to be managing the results of
everything, I renamed butler.py to waterfall.py, leaving the name butler
available for this opinionated driver. That will be done in a separate
branch.

>
> [2]
>
> Please put a copy of pystache within the tree.
>

Done. This actually involved some debugging effort because pystache, in its
interaction with MarkupSafe, was somehow behaving differently when placed
here, vs normal package install with tools like pip. Modified accordingly.
So pystache as added here just has package code + (renamed) license file.

> [3]
>
> 96 + <td>{{#failed}}<a
> href="{{path}}"><strong>FAILED</strong></a>{{/failed}}
> 97 + {{^failed}}Succeeded{{/failed}}
>
> We have the succeeded output as well. Let's offer it so people can
> investigate differences.
>

Done

>
> It might also be better to have "ok" (lowercased) rather than "Succeeded",
> to make the
> "FAILED" one much more outstanding.
>

Done. The mustache template will need some more work re styling, but it's
functional now. This can be done in a future branch.

Revision history for this message
Gustavo Niemeyer (niemeyer) wrote :

Looks great, thanks Jim. Only a couple of minor comments, and it can go in IMO.

Then, for the driver! I hope we can see a waterfall working this week still!

[4]

=== added file 'pystache-LICENSE'

Please rename this to LICENSE and put it within the pystache dir itself.

[5]

+OUTPUT_STATUS_RE = re.compile(r"(?P<name>.*?)(?P<status>\.out.FAILED|\.out)")

This regex makes things more complex than it helps. E.g.:

FAILED_SUFFIX = ".out.FAILED"
OK_SUFFIX = ".out"
(...)
    if name.endswith(FAILED_SUFFIX):
        test_name = name[:-len(FAILED_SUFFIX)]

etc.

review: Approve
lp:~jimbaker/pyjuju/generate-html updated
16. By Jim Baker

Addressed review comments

Revision history for this message
Jim Baker (jimbaker) wrote :

On Thu, Sep 8, 2011 at 6:10 PM, Gustavo Niemeyer <email address hidden>wrote:

> Review: Approve
>
> Looks great, thanks Jim. Only a couple of minor comments, and it can go in
> IMO.
>
> Then, for the driver! I hope we can see a waterfall working this week
> still!
>

Please see the corresponding butler branch in review

>
> [4]
>
> === added file 'pystache-LICENSE'
>
> Please rename this to LICENSE and put it within the pystache dir itself.
>

Renamed accordingly

>
> [5]
>
> +OUTPUT_STATUS_RE =
> re.compile(r"(?P<name>.*?)(?P<status>\.out.FAILED|\.out)")
>
> This regex makes things more complex than it helps. E.g.:
>
> FAILED_SUFFIX = ".out.FAILED"
> OK_SUFFIX = ".out"
> (...)
> if name.endswith(FAILED_SUFFIX):
> test_name = name[:-len(FAILED_SUFFIX)]
>
> etc.
>
>
Done

Revision history for this message
Gustavo Niemeyer (niemeyer) wrote :

Thanks Jim, please merge it on ftests.

review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== added file 'f_tests.mustache'
2--- f_tests.mustache 1970-01-01 00:00:00 +0000
3+++ f_tests.mustache 2011-09-09 22:36:28 +0000
4@@ -0,0 +1,24 @@
5+<table>
6+ <thead>
7+ <tr>
8+ <th>Run/Test</th>
9+ {{#test_names}}
10+ <th>{{name}}</th>
11+ {{/test_names}}
12+ </tr>
13+ </thead>
14+ <tbody>
15+ {{#results}}
16+ <tr>
17+ <td>{{run_name}}</td>
18+ {{#tests}}
19+ <td>{{#test_name}}
20+ <a href="{{path}}">
21+ {{#failed}}<strong>FAILED</strong>
22+ {{/failed}}{{^failed}}ok{{/failed}}</a>
23+ {{/test_name}}</td>
24+ {{/tests}}
25+ </tr>
26+ {{/results}}
27+ </tbody>
28+</table>
29
30=== added directory 'pystache'
31=== added file 'pystache/LICENSE'
32--- pystache/LICENSE 1970-01-01 00:00:00 +0000
33+++ pystache/LICENSE 2011-09-09 22:36:28 +0000
34@@ -0,0 +1,20 @@
35+Copyright (c) 2009 Chris Wanstrath
36+
37+Permission is hereby granted, free of charge, to any person obtaining
38+a copy of this software and associated documentation files (the
39+"Software"), to deal in the Software without restriction, including
40+without limitation the rights to use, copy, modify, merge, publish,
41+distribute, sublicense, and/or sell copies of the Software, and to
42+permit persons to whom the Software is furnished to do so, subject to
43+the following conditions:
44+
45+The above copyright notice and this permission notice shall be
46+included in all copies or substantial portions of the Software.
47+
48+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
49+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
50+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
51+NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
52+LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
53+OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
54+WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
55
56=== added file 'pystache/__init__.py'
57--- pystache/__init__.py 1970-01-01 00:00:00 +0000
58+++ pystache/__init__.py 2011-09-09 22:36:28 +0000
59@@ -0,0 +1,8 @@
60+from pystache.template import Template
61+from pystache.view import View
62+from pystache.loader import Loader
63+
64+def render(template, context=None, **kwargs):
65+ context = context and context.copy() or {}
66+ context.update(kwargs)
67+ return Template(template, context).render()
68
69=== added file 'pystache/loader.py'
70--- pystache/loader.py 1970-01-01 00:00:00 +0000
71+++ pystache/loader.py 2011-09-09 22:36:28 +0000
72@@ -0,0 +1,47 @@
73+import os
74+
75+class Loader(object):
76+
77+ template_extension = 'mustache'
78+ template_path = '.'
79+ template_encoding = None
80+
81+ def load_template(self, template_name, template_dirs=None, encoding=None, extension=None):
82+ '''Returns the template string from a file or throws IOError if it non existent'''
83+ if None == template_dirs:
84+ template_dirs = self.template_path
85+
86+ if encoding is not None:
87+ self.template_encoding = encoding
88+
89+ if extension is not None:
90+ self.template_extension = extension
91+
92+ file_name = template_name + '.' + self.template_extension
93+
94+ # Given a single directory we'll load from it
95+ if isinstance(template_dirs, basestring):
96+ file_path = os.path.join(template_dirs, file_name)
97+
98+ return self._load_template_file(file_path)
99+
100+ # Given a list of directories we'll check each for our file
101+ for path in template_dirs:
102+ file_path = os.path.join(path, file_name)
103+ if os.path.exists(file_path):
104+ return self._load_template_file(file_path)
105+
106+ raise IOError('"%s" not found in "%s"' % (template_name, ':'.join(template_dirs),))
107+
108+ def _load_template_file(self, file_path):
109+ '''Loads and returns the template file from disk'''
110+ f = open(file_path, 'r')
111+
112+ try:
113+ template = f.read()
114+ if self.template_encoding:
115+ template = unicode(template, self.template_encoding)
116+ finally:
117+ f.close()
118+
119+ return template
120\ No newline at end of file
121
122=== added file 'pystache/template.py'
123--- pystache/template.py 1970-01-01 00:00:00 +0000
124+++ pystache/template.py 2011-09-09 22:36:28 +0000
125@@ -0,0 +1,178 @@
126+import re
127+import cgi
128+import collections
129+import os
130+import copy
131+
132+
133+# Modified from original version (0.3.1) to not use MarkupSafe. Using
134+# MarkupSafe for HTML escaping will cause the expansion of certain
135+# values (lists in particular) to fail in a rather spectacular and
136+# recursive fashion. Rather curiously, this behavior only occurs when
137+# the code is used directly, instead of being part of an installed
138+# package.
139+escape = lambda x: cgi.escape(unicode(x))
140+literal = unicode
141+
142+
143+class Modifiers(dict):
144+ """Dictionary with a decorator for assigning functions to keys."""
145+
146+ def set(self, key):
147+ """
148+ Decorator function to set the given key to the decorated function.
149+
150+ >>> modifiers = {}
151+ >>> @modifiers.set('P')
152+ ... def render_tongue(self, tag_name=None, context=None):
153+ ... return ":P %s" % tag_name
154+ >>> modifiers
155+ {'P': <function render_tongue at 0x...>}
156+ """
157+
158+ def setter(func):
159+ self[key] = func
160+ return func
161+ return setter
162+
163+
164+class Template(object):
165+
166+ tag_re = None
167+
168+ otag = '{{'
169+
170+ ctag = '}}'
171+
172+ modifiers = Modifiers()
173+
174+ def __init__(self, template=None, context=None, **kwargs):
175+ from view import View
176+
177+ self.template = template
178+
179+ if kwargs:
180+ context.update(kwargs)
181+
182+ self.view = context if isinstance(context, View) else View(context=context)
183+ self._compile_regexps()
184+
185+ def _compile_regexps(self):
186+ tags = {
187+ 'otag': re.escape(self.otag),
188+ 'ctag': re.escape(self.ctag)
189+ }
190+
191+ section = r"%(otag)s[\#|^]([^\}]*)%(ctag)s\s*(.+?\s*)%(otag)s/\1%(ctag)s"
192+ self.section_re = re.compile(section % tags, re.M|re.S)
193+
194+ tag = r"%(otag)s(#|=|&|!|>|\{)?(.+?)\1?%(ctag)s+"
195+ self.tag_re = re.compile(tag % tags)
196+
197+ def _render_sections(self, template, view):
198+ while True:
199+ match = self.section_re.search(template)
200+ if match is None:
201+ break
202+
203+ section, section_name, inner = match.group(0, 1, 2)
204+ section_name = section_name.strip()
205+ it = self.view.get(section_name, None)
206+ replacer = ''
207+
208+ # Callable
209+ if it and isinstance(it, collections.Callable):
210+ replacer = it(inner)
211+ # Dictionary
212+ elif it and hasattr(it, 'keys') and hasattr(it, '__getitem__'):
213+ if section[2] != '^':
214+ replacer = self._render_dictionary(inner, it)
215+ # Lists
216+ elif it and hasattr(it, '__iter__'):
217+ if section[2] != '^':
218+ replacer = self._render_list(inner, it)
219+ # Other objects
220+ elif it and isinstance(it, object):
221+ if section[2] != '^':
222+ replacer = self._render_dictionary(inner, it)
223+ # Falsey and Negated or Truthy and Not Negated
224+ elif (not it and section[2] == '^') or (it and section[2] != '^'):
225+ replacer = self._render_dictionary(inner, it)
226+
227+ template = literal(template.replace(section, replacer))
228+
229+ return template
230+
231+ def _render_tags(self, template):
232+ while True:
233+ match = self.tag_re.search(template)
234+ if match is None:
235+ break
236+
237+ tag, tag_type, tag_name = match.group(0, 1, 2)
238+ tag_name = tag_name.strip()
239+ func = self.modifiers[tag_type]
240+ replacement = func(self, tag_name)
241+ template = template.replace(tag, replacement)
242+
243+ return template
244+
245+ def _render_dictionary(self, template, context):
246+ self.view.context_list.insert(0, context)
247+ template = Template(template, self.view)
248+ out = template.render()
249+ self.view.context_list.pop(0)
250+ return out
251+
252+ def _render_list(self, template, listing):
253+ insides = []
254+ for item in listing:
255+ insides.append(self._render_dictionary(template, item))
256+
257+ return ''.join(insides)
258+
259+ @modifiers.set(None)
260+ def _render_tag(self, tag_name):
261+ raw = self.view.get(tag_name, '')
262+
263+ # For methods with no return value
264+ if not raw and raw is not 0:
265+ if tag_name == '.':
266+ raw = self.view.context_list[0]
267+ else:
268+ return ''
269+
270+ return escape(raw)
271+
272+ @modifiers.set('!')
273+ def _render_comment(self, tag_name):
274+ return ''
275+
276+ @modifiers.set('>')
277+ def _render_partial(self, template_name):
278+ from pystache import Loader
279+ markup = Loader().load_template(template_name, self.view.template_path, encoding=self.view.template_encoding)
280+ template = Template(markup, self.view)
281+ return template.render()
282+
283+ @modifiers.set('=')
284+ def _change_delimiter(self, tag_name):
285+ """Changes the Mustache delimiter."""
286+ self.otag, self.ctag = tag_name.split(' ')
287+ self._compile_regexps()
288+ return ''
289+
290+ @modifiers.set('{')
291+ @modifiers.set('&')
292+ def render_unescaped(self, tag_name):
293+ """Render a tag without escaping it."""
294+ return literal(self.view.get(tag_name, ''))
295+
296+ def render(self, encoding=None):
297+ template = self._render_sections(self.template, self.view)
298+ result = self._render_tags(template)
299+
300+ if encoding is not None:
301+ result = result.encode(encoding)
302+
303+ return result
304
305=== added file 'pystache/view.py'
306--- pystache/view.py 1970-01-01 00:00:00 +0000
307+++ pystache/view.py 2011-09-09 22:36:28 +0000
308@@ -0,0 +1,94 @@
309+from pystache import Template
310+import os.path
311+import re
312+from types import *
313+
314+def get_or_attr(context_list, name, default=None):
315+ if not context_list:
316+ return default
317+
318+ for obj in context_list:
319+ try:
320+ return obj[name]
321+ except KeyError:
322+ pass
323+ except:
324+ try:
325+ return getattr(obj, name)
326+ except AttributeError:
327+ pass
328+ return default
329+
330+class View(object):
331+
332+ template_name = None
333+ template_path = None
334+ template = None
335+ template_encoding = None
336+ template_extension = 'mustache'
337+
338+ def __init__(self, template=None, context=None, **kwargs):
339+ self.template = template
340+ context = context or {}
341+ context.update(**kwargs)
342+
343+ self.context_list = [context]
344+
345+ def get(self, attr, default=None):
346+ attr = get_or_attr(self.context_list, attr, getattr(self, attr, default))
347+ if hasattr(attr, '__call__') and type(attr) is UnboundMethodType:
348+ return attr()
349+ else:
350+ return attr
351+
352+ def get_template(self, template_name):
353+ if not self.template:
354+ from pystache import Loader
355+ template_name = self._get_template_name(template_name)
356+ self.template = Loader().load_template(template_name, self.template_path, encoding=self.template_encoding, extension=self.template_extension)
357+
358+ return self.template
359+
360+ def _get_template_name(self, template_name=None):
361+ """TemplatePartial => template_partial
362+ Takes a string but defaults to using the current class' name or
363+ the `template_name` attribute
364+ """
365+ if template_name:
366+ return template_name
367+
368+ template_name = self.__class__.__name__
369+
370+ def repl(match):
371+ return '_' + match.group(0).lower()
372+
373+ return re.sub('[A-Z]', repl, template_name)[1:]
374+
375+ def _get_context(self):
376+ context = {}
377+ for item in self.context_list:
378+ if hasattr(item, 'keys') and hasattr(item, '__getitem__'):
379+ context.update(item)
380+ return context
381+
382+ def render(self, encoding=None):
383+ return Template(self.get_template(self.template_name), self).render(encoding=encoding)
384+
385+ def __contains__(self, needle):
386+ return needle in self.context or hasattr(self, needle)
387+
388+ def __getitem__(self, attr):
389+ val = self.get(attr, None)
390+
391+ if not val and val is not 0:
392+ raise KeyError("Key '%s' does not exist in View" % attr)
393+ return val
394+
395+ def __getattr__(self, attr):
396+ if attr == 'context':
397+ return self._get_context()
398+
399+ raise AttributeError("Attribute '%s' does not exist in View" % attr)
400+
401+ def __str__(self):
402+ return self.render()
403\ No newline at end of file
404
405=== added file 'pystache/waterfall.html'
406=== added file 'waterfall.py'
407--- waterfall.py 1970-01-01 00:00:00 +0000
408+++ waterfall.py 2011-09-09 22:36:28 +0000
409@@ -0,0 +1,90 @@
410+# waterfall - Generate waterfall report from test runs
411+
412+import argparse
413+import os
414+import re
415+
416+from pystache import View
417+
418+
419+FAILED_SUFFIX = ".out.FAILED"
420+OK_SUFFIX = ".out"
421+
422+
423+def main():
424+ """Write waterfall summary of test results to stdout"""
425+ parser = argparse.ArgumentParser(
426+ description="Creates waterfall report from test runs")
427+ parser.add_argument("waterfall",
428+ help="Directory of test runs resulting from churn")
429+ args = parser.parse_args()
430+ waterfall(args.waterfall)
431+
432+
433+class FTests(View):
434+ """Pull in template from f_tests.mustache.
435+
436+ Uses pystache's default class name to filename mapping."""
437+
438+
439+def wrap_names(names):
440+ """Wraps names in a dict for rendering by pystache."""
441+ for name in names:
442+ yield dict(name=name)
443+
444+
445+def whipped(run_names, test_names, results):
446+ """Returns the results as a dense version suitable for rendering."""
447+ for run_name in run_names:
448+ tests = [results.get((test_name, run_name), dict(empty=True))
449+ for test_name in test_names]
450+ yield dict(run_name=run_name, tests=tests)
451+
452+
453+def waterfall(waterfall_path):
454+ """Given a directory of churn runs, writes to stdout a waterfall report.
455+
456+ `waterfall_path` contains a set of directories, one per run. Each
457+ run contains one file per test. NOTE This function assumes there
458+ are no non-run directories in `waterfall_path`. If that is
459+ relaxed, some filtering obviously needs to be done here.
460+
461+ Other than providing an iterator of a listing of test results per
462+ run, via the :func:`whipped`, all display formating is done
463+ through `f_tests.mustache`. In particular, any desired styles
464+ should be setup there.
465+ """
466+ # First, process test results, with an entry per run, test pair.
467+ # The resulting `results` dict is sparse
468+ results = {}
469+ test_names = set()
470+ run_names = sorted(os.listdir(waterfall_path))
471+ for run_name in run_names:
472+ run_path = os.path.join(waterfall_path, run_name)
473+ for name in sorted(os.listdir(run_path)):
474+ test_name = None
475+ if name.endswith(FAILED_SUFFIX):
476+ test_name = name[:-len(FAILED_SUFFIX)]
477+ failed = True
478+ elif name.endswith(OK_SUFFIX):
479+ test_name = name[:-len(OK_SUFFIX)]
480+ failed = False
481+ if test_name:
482+ test_names.add(test_name)
483+ results[test_name, run_name] = {
484+ "run_name": run_name,
485+ "test_name": test_name,
486+ "failed": failed,
487+ "path": os.path.join(run_path, name)}
488+
489+ # Next, transform `results` into an equivalent dense version
490+ # before passing onto pystache for rendering into HTML.
491+ test_names = sorted(test_names)
492+ wrapped_test_names = wrap_names(test_names)
493+ dense_results = whipped(run_names, test_names, results)
494+
495+ print FTests(test_names=wrapped_test_names, results=dense_results).render()
496+
497+
498+if __name__ == "__main__":
499+ main()

Subscribers

People subscribed via source and target branches

to all changes: