Merge lp:~zyga/checkbox/readonly-results into lp:checkbox

Proposed by Zygmunt Krynicki
Status: Work in progress
Proposed branch: lp:~zyga/checkbox/readonly-results
Merge into: lp:checkbox
Diff against target: 4085 lines (+1820/-796)
28 files modified
checkbox-ng/checkbox_ng/commands/newcli.py (+10/-4)
checkbox-ng/checkbox_ng/service.py (+13/-7)
plainbox/plainbox/data/report/checkbox.css (+0/-261)
plainbox/plainbox/data/report/checkbox.html (+260/-139)
plainbox/plainbox/data/report/checkbox.js (+0/-16)
plainbox/plainbox/data/report/hexr.xml (+149/-0)
plainbox/plainbox/impl/commands/inv_check_config.py (+6/-3)
plainbox/plainbox/impl/commands/inv_run.py (+49/-42)
plainbox/plainbox/impl/exporter/hexr.py (+138/-0)
plainbox/plainbox/impl/exporter/html.py (+0/-2)
plainbox/plainbox/impl/exporter/jinja2.py (+9/-0)
plainbox/plainbox/impl/exporter/test_hexr.py (+535/-0)
plainbox/plainbox/impl/exporter/text.py (+17/-3)
plainbox/plainbox/impl/pod.py (+131/-8)
plainbox/plainbox/impl/providers/special.py (+2/-2)
plainbox/plainbox/impl/providers/stubbox/units/jobs/representative.pxu (+98/-0)
plainbox/plainbox/impl/resource.py (+4/-0)
plainbox/plainbox/impl/result.py (+104/-61)
plainbox/plainbox/impl/runner.py (+90/-85)
plainbox/plainbox/impl/session/jobs.py (+59/-13)
plainbox/plainbox/impl/session/resume.py (+4/-7)
plainbox/plainbox/impl/session/suspend.py (+4/-5)
plainbox/plainbox/impl/session/test_jobs.py (+11/-0)
plainbox/plainbox/impl/test_pod.py (+76/-112)
plainbox/plainbox/impl/test_result.py (+44/-0)
plainbox/setup.py (+4/-0)
providers/plainbox-provider-checkbox/whitelists/smoke.whitelist (+0/-26)
support/install-deb-dependencies (+3/-0)
To merge this branch: bzr merge lp:~zyga/checkbox/readonly-results
Reviewer Review Type Date Requested Status
Checkbox Developers Pending
Review via email: mp+259093@code.launchpad.net

Description of the change

This is a work in progress.

I want it out so that I can see the the diff and history and iterate on it,.

To post a comment you must log in.
lp:~zyga/checkbox/readonly-results updated
3777. By Zygmunt Krynicki

support: run dist-upgrade after installing deps

Signed-off-by: Zygmunt Krynicki <email address hidden>

3778. By Zygmunt Krynicki

plainbox:commands:check_config: produce more useful output

Signed-off-by: Zygmunt Krynicki <email address hidden>

3779. By Zygmunt Krynicki

plainbox:pod: fix several PEP257 issues

Signed-off-by: Zygmunt Krynicki <email address hidden>

3780. By Zygmunt Krynicki

plainbox:pod: add unset variants for type assignment filters

Signed-off-by: Zygmunt Krynicki <email address hidden>

3781. By Zygmunt Krynicki

plainbox:pod: filter-out UNSET from as_dict()

Signed-off-by: Zygmunt Krynicki <email address hidden>

3782. By Zygmunt Krynicki

plainbox:pod: fix PEP257 issues

Some docstrings were rewritten entirely.

Signed-off-by: Zygmunt Krynicki <email address hidden>

3783. By Zygmunt Krynicki

plainbox:pod: add Field.change_notifier

This patch adds a new decorator that can be used similarly to
@foo.setter (when foo is a @property-decorated method).

This can simplify all fields that need to have a custom notification
method. All such methods can be implemented _after_ all of the field
definitions now.

Signed-off-by: Zygmunt Krynicki <email address hidden>

3784. By Zygmunt Krynicki

plainbox:result: fix many PEP257 and some PEP8 issues

Signed-off-by: Zygmunt Krynicki <email address hidden>

3785. By Zygmunt Krynicki

plainbox:result: warn about modification of existing results

Signed-off-by: Zygmunt Krynicki <email address hidden>

3786. By Zygmunt Krynicki

plainbox:result: add JobResultBuilder

This patch adds a new class that assists in building appropriate
JobResult objects.

Signed-off-by: Zygmunt Krynicki <email address hidden>

3787. By Zygmunt Krynicki

plainbox:result: add _JobReturnBase.unseal()

Signed-off-by: Zygmunt Krynicki <email address hidden>

3788. By Zygmunt Krynicki

plainbox:runner: fix many PEP257 issues

Signed-off-by: Zygmunt Krynicki <email address hidden>

3789. By Zygmunt Krynicki

plainbox:runner: use JobResultBuilder everywhere

Signed-off-by: Zygmunt Krynicki <email address hidden>

3790. By Zygmunt Krynicki

plainbox:commands:run: use JobResultBuilder to handle mutable results

Signed-off-by: Zygmunt Krynicki <email address hidden>

3791. By Zygmunt Krynicki

checkbox-ng:service: use JobResultBuilder

Signed-off-by: Zygmunt Krynicki <email address hidden>

3792. By Zygmunt Krynicki

plainbox:resource: make it possible to iterate over resources

Iterating over resources behaves just like iterating over dictionaries
(keys are returned). This is useful to access resource elements in Jinja
templates. In the past we just accessed ._data directly but this is a
hack and we should get rid of it.

Signed-off-by: Zygmunt Krynicki <email address hidden>

3793. By Zygmunt Krynicki

plainbox:stubbox: add set of representative jobs

This patch adds a number of jobs that validate and don't do anything.
Each job has an identifier that looks like
"representative/plugin/{plugin}".

The goal of this collection is to simplify unit tests that traditionally
re-created everything using python APIs (which is both fragile and more
verbose).

Signed-off-by: Zygmunt Krynicki <email address hidden>

3794. By Zygmunt Krynicki

plainbox:providers:special: allow passing keyword args to get_stubbox()

The provider constructor seems to call the old validate API. Since this
is pretty annoying (all the incorrect things the old validation says) I
wanted to have a way to disable that by passing validate=False,
check=True. This is a quick stop-gap measure. In reality we want to
purge all the old validation code.

Signed-off-by: Zygmunt Krynicki <email address hidden>

3795. By Zygmunt Krynicki

plainbox:session:jobs: fix PEP257 issues

Signed-off-by: Zygmunt Krynicki <email address hidden>

3796. By Zygmunt Krynicki

plainbox:session:jobs: keep track of result history

Signed-off-by: Zygmunt Krynicki <email address hidden>

3797. By Zygmunt Krynicki

plainbox:session:suspend: fix PEP257 issue

Signed-off-by: Zygmunt Krynicki <email address hidden>

3798. By Zygmunt Krynicki

plainbox:session:suspend: store full result history

Signed-off-by: Zygmunt Krynicki <email address hidden>

3799. By Zygmunt Krynicki

plainbox:session:result: replay the full result history

Signed-off-by: Zygmunt Krynicki <email address hidden>

3800. By Zygmunt Krynicki

plainbox:commands:run: print session identifier on startup

Signed-off-by: Zygmunt Krynicki <email address hidden>

3801. By Zygmunt Krynicki

checkbox-ng:commands:newcli: allow re-run to be done indefinitely

Signed-off-by: Zygmunt Krynicki <email address hidden>

3802. By Zygmunt Krynicki

checkbox-ng:commands:newcli: don't reset last outcome when re-running jobs

Signed-off-by: Zygmunt Krynicki <email address hidden>

3803. By Zygmunt Krynicki

remove shit from smoke whitelist

3804. By Zygmunt Krynicki

plainbox:exporter:jinja: add support for customizing Environment

The environment is how you control all of jinja's behavior. Currently
the environment is hidden in the initializer of the base jinja exporter
class. By having a method where we can customize the environment
_before_ a template is loaded we can add anything (additional tests and
filters). After a template is initialized, those changes don't affect
the template.

Signed-off-by: Zygmunt Krynicki <email address hidden>

3805. By Zygmunt Krynicki

plainbox:exporter:hexr: add HEXR exporter

This patch adds a new exporter (HEXR) that is intended to replace our
current "xml" exporter. The schema is specific to the Canonical HEXR
application so the name reflects that.

Note that due to the syntax used, this exporter requires Jinja2 >= 2.7.
The dependency wasn't there before so I've added it to install_requires.

Signed-off-by: Zygmunt Krynicki <email address hidden>

3806. By Zygmunt Krynicki

plainbox:exporter:text: fix PEP257 issue

Signed-off-by: Zygmunt Krynicki <email address hidden>

3807. By Zygmunt Krynicki

plainbox:exporter:text: display result history

Signed-off-by: Zygmunt Krynicki <email address hidden>

3808. By Zygmunt Krynicki

plainbox:exporter:html: fix broken substitution on tr_outcome

Signed-off-by: Zygmunt Krynicki <email address hidden>

3809. By Zygmunt Krynicki

plainbox:exporter:html: remove useless OOM object

Signed-off-by: Zygmunt Krynicki <email address hidden>

3810. By Zygmunt Krynicki

plainbox:exporter:html: remove useless newlines

Signed-off-by: Zygmunt Krynicki <email address hidden>

3811. By Zygmunt Krynicki

plainbox:exporter:html: don't spell the long namespace over and over

Signed-off-by: Zygmunt Krynicki <email address hidden>

3812. By Zygmunt Krynicki

plainbox:exporter:html: use a variable for resource_map

Signed-off-by: Zygmunt Krynicki <email address hidden>

3813. By Zygmunt Krynicki

plainbox:exporter:html: use a variable for job_state_map

Signed-off-by: Zygmunt Krynicki <email address hidden>

3814. By Zygmunt Krynicki

plainbox:exporter:html: inline checkbox.css

Signed-off-by: Zygmunt Krynicki <email address hidden>

3815. By Zygmunt Krynicki

plainbox:exporter:html: inline checkbox.js

Signed-off-by: Zygmunt Krynicki <email address hidden>

3816. By Zygmunt Krynicki

plainbox:exporter:html: remove unused css rules

Signed-off-by: Zygmunt Krynicki <email address hidden>

3817. By Zygmunt Krynicki

plainbox:exporter:html: fix some inconsistent css syntax

Signed-off-by: Zygmunt Krynicki <email address hidden>

3818. By Zygmunt Krynicki

plainbox:exporter:html: don't use deprecated -vendor properties

Signed-off-by: Zygmunt Krynicki <email address hidden>

3819. By Zygmunt Krynicki

plainbox:exporter:html: remove commented-out rule

Signed-off-by: Zygmunt Krynicki <email address hidden>

3820. By Zygmunt Krynicki

plainbox:report:html: simplify html structure + result history [WIP]

Signed-off-by: Zygmunt Krynicki <email address hidden>

Unmerged revisions

3820. By Zygmunt Krynicki

plainbox:report:html: simplify html structure + result history [WIP]

Signed-off-by: Zygmunt Krynicki <email address hidden>

3819. By Zygmunt Krynicki

plainbox:exporter:html: remove commented-out rule

Signed-off-by: Zygmunt Krynicki <email address hidden>

3818. By Zygmunt Krynicki

plainbox:exporter:html: don't use deprecated -vendor properties

Signed-off-by: Zygmunt Krynicki <email address hidden>

3817. By Zygmunt Krynicki

plainbox:exporter:html: fix some inconsistent css syntax

Signed-off-by: Zygmunt Krynicki <email address hidden>

3816. By Zygmunt Krynicki

plainbox:exporter:html: remove unused css rules

Signed-off-by: Zygmunt Krynicki <email address hidden>

3815. By Zygmunt Krynicki

plainbox:exporter:html: inline checkbox.js

Signed-off-by: Zygmunt Krynicki <email address hidden>

3814. By Zygmunt Krynicki

plainbox:exporter:html: inline checkbox.css

Signed-off-by: Zygmunt Krynicki <email address hidden>

3813. By Zygmunt Krynicki

plainbox:exporter:html: use a variable for job_state_map

Signed-off-by: Zygmunt Krynicki <email address hidden>

3812. By Zygmunt Krynicki

plainbox:exporter:html: use a variable for resource_map

Signed-off-by: Zygmunt Krynicki <email address hidden>

3811. By Zygmunt Krynicki

plainbox:exporter:html: don't spell the long namespace over and over

Signed-off-by: Zygmunt Krynicki <email address hidden>

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'checkbox-ng/checkbox_ng/commands/newcli.py'
2--- checkbox-ng/checkbox_ng/commands/newcli.py 2015-04-17 10:43:20 +0000
3+++ checkbox-ng/checkbox_ng/commands/newcli.py 2015-05-14 11:57:31 +0000
4@@ -207,7 +207,11 @@
5 self.print_estimated_duration()
6 self.run_all_selected_jobs()
7 if self.is_interactive:
8- self.maybe_rerun_jobs()
9+ while True:
10+ if self.maybe_rerun_jobs():
11+ continue
12+ else:
13+ break
14 self.export_and_send_results()
15 if SessionMetaData.FLAG_INCOMPLETE in self.metadata.flags:
16 print(self.C.header("Session Complete!", "GREEN"))
17@@ -517,7 +521,7 @@
18 rerun_candidates.append(job)
19 # bail-out early if no job qualifies for rerunning
20 if not rerun_candidates:
21- return
22+ return False
23 tree = SelectableJobTreeNode.create_tree(
24 self.manager.state, rerun_candidates)
25 # deselect all by default
26@@ -526,10 +530,12 @@
27 wanted_set = frozenset(tree.selection)
28 if not wanted_set:
29 # nothing selected - nothing to run
30- return
31+ return False
32 rerun_job_list = [job for job in self.manager.state.run_list
33 if job in wanted_set]
34 # reset outcome of jobs that are selected for re-running
35 for job in wanted_set:
36- self.manager.state.job_state_map[job.id].result.outcome = None
37+ from plainbox.impl.result import MemoryJobResult
38+ self.manager.state.job_state_map[job.id].result = MemoryJobResult({})
39 self.run_all_selected_jobs()
40+ return True
41
42=== modified file 'checkbox-ng/checkbox_ng/service.py'
43--- checkbox-ng/checkbox_ng/service.py 2015-05-06 10:18:36 +0000
44+++ checkbox-ng/checkbox_ng/service.py 2015-05-14 11:57:31 +0000
45@@ -37,7 +37,7 @@
46 raise SystemExit(_("DBus parts require 'funcsigs' from pypi."))
47 from plainbox.abc import IJobResult
48 from plainbox.impl.job import JobDefinition
49-from plainbox.impl.result import MemoryJobResult
50+from plainbox.impl.result import JobResultBuilder
51 from plainbox.impl.secure.qualifiers import select_jobs
52 from plainbox.impl.session import JobState
53 from plainbox.vendor import extcmd
54@@ -1372,6 +1372,7 @@
55 # A result object we got from running the command OR the result this
56 # job used to have before. It should be always published on the bus.
57 self._result = None
58+ self._result_builder = None
59 # A future for the result each time we're waiting for the command to
60 # finish. Gets reset to None after the command is done executing.
61 self._result_future = None
62@@ -1447,16 +1448,20 @@
63 _("But the job is not manual, it is %s"),
64 self.native.job.plugin)
65 # Create a new result object
66- self._result = MemoryJobResult({
67- 'outcome': outcome,
68- 'comments': comments
69- })
70+ self._result_builder = JobResultBuilder()
71+ self._result_builder.outcome = outcome
72+ self._result_builder.comments = comments
73+ self._result = self._result_builder.seal()
74 # Add the new result object to the bus
75 self._session_wrapper.add_result(self._result)
76 else:
77+ assert self._result_builder is not None
78 # Set the values as requested
79- self._result.outcome = outcome
80- self._result.comments = comments
81+ self._result_builder.outcome = outcome
82+ self._result_builder.comments = comments
83+ self._result = self._result_builder.seal()
84+ # Add the new result object to the bus
85+ self._session_wrapper.add_result(self._result)
86 # Notify the application that the result is ready. This has to be
87 # done unconditionally each time this method called.
88 self.JobResultAvailable(
89@@ -1548,6 +1553,7 @@
90 self._session_wrapper.remove_result(self._result)
91 # Unpack the result from the future
92 self._result = result_future.result()
93+ self._result_builder = self._result.unseal()
94 # Add the new result object to the session wrapper (and to the bus)
95 self._session_wrapper.add_result(self._result)
96 # Reset the future so that RunCommand() can run the job again
97
98=== removed file 'plainbox/plainbox/data/report/checkbox.css'
99--- plainbox/plainbox/data/report/checkbox.css 2015-04-09 16:57:49 +0000
100+++ plainbox/plainbox/data/report/checkbox.css 1970-01-01 00:00:00 +0000
101@@ -1,261 +0,0 @@
102-body {
103- font-family: "Ubuntu Beta", "Bitstream Vera Sans", DejaVu Sans, Tahoma, sans-serif;
104- color: #333;
105- background: white;
106- font-size: 12px;
107- line-height: 14px;
108- margin: 0px;
109- padding: 0px;
110-}
111-#container {
112- background: #f7f6f5;
113- margin: 0px auto 20px;
114- padding: 0px;
115- width: 976px;
116-}
117-#container-inner {
118- background-color: #dfdcd9;
119-}
120-#header, #container-inner {
121- -moz-border-radius: 0px 0px 5px 5px;
122- -webkit-border-bottom-left-radius: 5px;
123- -webkit-border-bottom-right-radius: 5px;
124- -moz-box-shadow: #bbb 0px 0px 5px;
125- -webkit-box-shadow: #bbb 0px 0px 5px;
126-}
127-#header {
128- background: #dd4814 top left repeat-x;
129- height: 64px;
130- margin: 0px;
131- padding: 0px;
132- position: relative;
133-}
134-
135-#menu-search {
136- height: 40px;
137- margin: 0 16px;
138-}
139-
140-#title {
141- padding: 28px 24px;
142-}
143-
144-#content {
145- /*padding: 32px 80px 32px 80px;*/
146- padding: 32px 240px 32px 60px;
147- margin: 0 16px 16px;
148- width: 644px;
149- background-color: #fff;
150- -moz-border-radius: 4px;
151- -webkit-border-radius: 4px;
152-}
153-#end-content {
154- clear: both;
155-}
156-
157-#content-panel {
158- width: 446px;
159- margin: 0px 0px 0px 0px;
160- padding: 8px 8px 32px 8px;
161- background-color: #fff;
162- -moz-border-radius: 4px;
163- -webkit-border-radius: 4px;
164-}
165-
166-#copyright {
167- background-position: 803px 40px;
168- background-repeat: no-repeat;
169- text-align: center;
170- margin: 0 16px;
171- padding: 40px 0 0 0;
172- height: 32px;
173-}
174-#copyright p {
175- color: #aea79f;
176- font-size: 10px;
177- line-height: 14px;
178- margin: 2px 0;
179-}
180-
181-#footer {
182- padding-top: 16px;
183-}
184-#footer * {
185- font-size: 10px;
186- line-height: 14px;
187-}
188-#footer p {
189- margin: 0;
190- padding-bottom: 3px;
191- border-bottom: 1px dotted #aea79f;
192-}
193-#footer p.footer-title {
194- font-weight: bold;
195-}
196-#footer .footer-div {
197- width: 144px;
198- float: left;
199- margin-left: 16px;
200-}
201-#footer .last-div {
202- margin-right: 16px;
203-}
204-#footer ul {
205- list-style: none;
206- margin: 0;
207- padding: 0;
208-}
209-#footer li {
210- margin: 0;
211- padding: 3px 0;
212- border-bottom: 1px dotted #aea79f;
213-}
214-
215-h1, h2, h3, h4, h5 {
216- padding: 0;
217- margin: 0;
218- font-weight: normal;
219-}
220-h1 {
221- font-size: 36px;
222- line-height: 40px;
223- color: #dd4814;
224-}
225-h2 {
226- font-size: 24px;
227- line-height: 28px;
228- margin-bottom: 8px;
229-}
230-h3 {
231- font-size: 16px;
232- line-height: 20px;
233- margin-bottom: 8px;
234-}
235-h3.link-other {
236- color: #333;
237-}
238-h3.link-services {
239- color: #fff;
240-}
241-h4 {
242- font-size: 12px;
243- line-height: 14px;
244-}
245-h4.partners {
246- color: #333;
247- font-size: 16px;
248- line-height: 20px;
249-}
250-h5 {
251- color: #333;
252- font-size: 10px;
253- line-height: 14px;
254-}
255-h1 span.grey, h2 span.grey, h1 span, h2 span{
256- color: #aea79f;
257-}
258-p {
259- font-size: 12px;
260- line-height: 14px;
261- margin-bottom: 8px;
262-}
263-strong {
264- font-weight: bold;
265-}
266-
267-a {
268- color: #333;
269- text-decoration: none;
270-}
271-a:hover {
272- color: #dd4814;
273- text-decoration: underline;
274-}
275-div.footer-div:hover a, div#content:hover a {
276- color: #dd4814;
277- text-decoration: none;
278-}
279-div.footer-div:hover a:hover, div#content:hover a:hover {
280- color: #dd4814;
281- text-decoration: underline;
282-}
283-
284-ul {
285- margin-bottom: 16px;
286-}
287-ul li {
288- margin-bottom: 8px;
289- line-height: 14px;
290-}
291-ul li:last-child {
292- margin-bottom: 0px;
293-}
294-
295-p.call-to-action {
296- color: #333;
297-}
298-p.case-study {
299- color: #333;
300-}
301-p.highlight {
302- font-size: 16px;
303- line-height: 20px;
304-}
305-p.introduction {
306- color: #333;
307- font-size: 16px;
308- line-height: 20px;
309-}
310-p.services {
311- color: #fff;
312-}
313-p.small-text {
314- color: #333;
315- font-size: 10px;
316-}
317-
318-/* Clearing floats without extra markup
319-Based on How To Clear Floats Without Structural Markup by PiE
320-[http://www.positioniseverything.net/easyclearing.html] */
321-.clearfix:after {
322- content: ".";
323- display: block;
324- height: 0;
325- clear: both;
326- visibility: hidden;
327-}
328-.clearfix {
329- -moz-border-radius: 5px 5px 5px 5px;
330- -webkit-border-bottom-top-radius: 5px;
331- -webkit-border-bottom-left-radius: 5px;
332- -webkit-border-bottom-bottom-radius: 5px;
333- -webkit-border-bottom-right-radius: 5px;
334- -moz-box-shadow: #bbb 0px 0px 5px;
335- -webkit-box-shadow: #bbb 0px 0px 5px;
336- display: inline-block;
337-} /* for IE/Mac */
338-th
339-{
340- text-align: left;
341-}
342-td
343-{
344- margin: 0;
345- padding-bottom: 3px;
346- border-bottom: 1px dotted #aea79f;
347- font-size: 10px;
348- line-height: 14px;
349-}
350-.resultimg
351-{
352- height: 12px;
353-}
354-.disclosureimg
355-{
356- height: .75em;
357- vertical-align: middle;
358-}
359-.data
360-{
361- display: none;
362-}
363
364=== modified file 'plainbox/plainbox/data/report/checkbox.html'
365--- plainbox/plainbox/data/report/checkbox.html 2015-05-04 08:08:53 +0000
366+++ plainbox/plainbox/data/report/checkbox.html 2015-05-14 11:57:31 +0000
367@@ -1,153 +1,274 @@
368+{%- set ns = '2013.com.canonical.certification::' -%}
369+{%- set state = manager.default_device_context.state -%}
370+{%- set resource_map = state.resource_map -%}
371+{%- set job_state_map = state.job_state_map -%}
372 <!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01//EN" "http://www.w3.org/TR/html4/strict.dtd">
373 <html>
374 <head>
375 <title>System Testing Report</title>
376 <style type="text/css">
377- {% include "checkbox.css" %}
378+ html {
379+ font-family: "Ubuntu Beta", "Bitstream Vera Sans", DejaVu Sans, Tahoma, sans-serif;
380+ color: #333;
381+ background: white;
382+ font-size: 12px;
383+ line-height: 14px;
384+ margin: 0px;
385+ padding: 0px;
386+ }
387+ body {
388+ background: #f7f6f5;
389+ margin: 0px auto 20px;
390+ padding: 0px;
391+ width: 976px;
392+ background-color: #dfdcd9;
393+ border-radius: 0px 0px 5px 5px;
394+ box-shadow: #bbb 0px 0px 5px;
395+ }
396+ #title {
397+ padding: 28px 24px;
398+ }
399+ #content {
400+ padding: 32px 240px 32px 60px;
401+ margin: 0 16px 16px;
402+ width: 644px;
403+ background-color: #fff;
404+ border-radius: 4px;
405+ }
406+ h1, h2, h3, h4, h5 {
407+ padding: 0;
408+ margin: 0;
409+ font-weight: normal;
410+ }
411+ h1 {
412+ font-size: 36px;
413+ line-height: 40px;
414+ color: #dd4814;
415+ }
416+ h2 {
417+ font-size: 24px;
418+ line-height: 28px;
419+ margin-bottom: 8px;
420+ }
421+ h3 {
422+ font-size: 16px;
423+ line-height: 20px;
424+ margin-bottom: 8px;
425+ }
426+ h4 {
427+ font-size: 12px;
428+ line-height: 14px;
429+ }
430+ h5 {
431+ color: #333;
432+ font-size: 10px;
433+ line-height: 14px;
434+ }
435+ h1 span.grey, h2 span.grey, h1 span, h2 span{
436+ color: #aea79f;
437+ }
438+ p {
439+ font-size: 12px;
440+ line-height: 14px;
441+ margin-bottom: 8px;
442+ }
443+ pre {
444+ color: gray;
445+ }
446+ a {
447+ color: #333;
448+ text-decoration: none;
449+ }
450+ a:hover {
451+ color: #dd4814;
452+ text-decoration: underline;
453+ }
454+ div#content:hover a {
455+ color: #dd4814;
456+ text-decoration: none;
457+ }
458+ div#content:hover a:hover {
459+ color: #dd4814;
460+ text-decoration: underline;
461+ }
462+ ul {
463+ margin-bottom: 16px;
464+ }
465+ ul li {
466+ margin-bottom: 8px;
467+ line-height: 14px;
468+ }
469+ ul li:last-child {
470+ margin-bottom: 0px;
471+ }
472+ /* Clearing floats without extra markup
473+ Based on How To Clear Floats Without Structural Markup by PiE
474+ [http://www.positioniseverything.net/easyclearing.html] */
475+ .clearfix:after {
476+ content: ".";
477+ display: block;
478+ height: 0;
479+ clear: both;
480+ visibility: hidden;
481+ }
482+ .clearfix {
483+ border-radius: 5px 5px 5px 5px;
484+ box-shadow: #bbb 0px 0px 5px;
485+ display: inline-block;
486+ } /* for IE/Mac */
487+ th {
488+ text-align: left;
489+ }
490+ td {
491+ margin: 0;
492+ padding-bottom: 3px;
493+ border-bottom: 1px dotted #aea79f;
494+ font-size: 10px;
495+ line-height: 14px;
496+ vertical-align: top;
497+ }
498+ .data {
499+ display: none;
500+ }
501 </style>
502 <script type="text/javascript">
503- {% include "checkbox.js" %}
504+ function showHide(what) {
505+ var heading = document.getElementById(what);
506+ var contents = document.getElementById(what + "-contents");
507+ var headingcontents = heading.innerHTML;
508+ var newcontents;
509+
510+ if (contents.style.display != "block") {
511+ newcontents = headingcontents.replace(/[^\x00-\x80]/g, "&#9660;");
512+ contents.style.display = "block";
513+ } else {
514+ newcontents = headingcontents.replace(/[^\x00-\x80]/g, "&#9654;");
515+ contents.style.display = "none";
516+ }
517+
518+ heading.innerHTML = newcontents;
519+ }
520 </script>
521 </head>
522 <body>
523- <div id="container">
524- <div id="container-inner">
525-
526- <div id="title">
527- <h1>System Testing<span class="grey"> Report</span></h1>
528- </div>
529- <div id="content" class="clearfix">
530- This report was created using {{ client_name }} {{ client_version }} on {{ timestamp }}
531- </div>
532- {% if "2013.com.canonical.certification::lsb" in manager.state.resource_map %}
533- <div id="content" class="clearfix">
534- <h2>Software Information</h2>
535- <p>{{ manager.state.resource_map["2013.com.canonical.certification::lsb"][0]["description"] }}</p>
536- </div>
537- {% endif %}
538-
539- {% if manager.state.get_certification_status_map() %}
540- <div id="content" class="clearfix">
541- <h2 id="questions">Certification Status - Blockers</h2>
542- <table style="width: 700px">
543- <thead>
544- <tr>
545- <th>Test ID</th>
546- <th>Result</th>
547- </tr>
548- </thead>
549- <tbody>
550- {% for job_id, job_state in manager.state.get_certification_status_map()|dictsort %}
551- <tr>
552- <td>{{ job_state.job.tr_summary() }}</td>
553- <td style='font-weight: bold; color: {{ OMM[job_state.result.outcome].color_hex }}'>{{ OMM[job_state.result.outcome].tr_outcome|replace("job ", "") }}</td>
554- </tr>
555- {% endfor %}
556- </tbody>
557- </table>
558- </div>
559- {% endif %}
560-
561- {% if manager.state.get_certification_status_map(certification_status_filter=('non-blocker',)) %}
562- <div id="content" class="clearfix">
563- <h2 id="questions">Certification Status - Non Blockers</h2>
564- <table style="width: 700px">
565- <thead>
566- <tr>
567- <th>Test ID</th>
568- <th>Result</th>
569- </tr>
570- </thead>
571- <tbody>
572- {% for job_id, job_state in manager.state.get_certification_status_map(certification_status_filter=('non-blocker',))|dictsort %}
573- <tr>
574- <td>{{ job_state.job.tr_summary() }}</td>
575- <td style='font-weight: bold; color: {{ OMM[job_state.result.outcome].color_hex }}'>{{ OMM[job_state.result.outcome].tr_outcome|replace("job ", "") }}</td>
576- </tr>
577- {% endfor %}
578- </tbody>
579- </table>
580- </div>
581- {% endif %}
582-
583- <div id="content" class="clearfix">
584- <h2 id="questions">Tests Performed</h2>
585- <table style="width: 824px">
586- <thead>
587- <tr>
588- <th>Test ID</th>
589- <th>Result</th>
590- <th>Certification status</th>
591- <th>Comment</th>
592- </tr>
593- </thead>
594- <tbody>
595- {% for job_id, job_state in manager.state.job_state_map|dictsort if job_state.result.outcome != None and job_state.job.plugin not in ("resource", "local", "attachment") %}
596- <tr>
597- <td>{{ job_state.job.tr_summary() }}</td>
598- <td style='font-weight: bold; color: {{ OMM[job_state.result.outcome].color_hex }}'>{{ OMM[job_state.result.outcome].tr_outcome|replace("job ", "") }}</td>
599- <td>{{ job_state.effective_certification_status }}</td>
600- {% if job_state.result.comments != None %}
601- <td>{{ job_state.result.comments }}</td>
602- {% else %}
603- {% if job_state.result.io_log_as_flat_text != "" %}
604- <td><div style="vertical-align: middle; width: 420px; overflow: auto;">
605- <pre>{{ job_state.result.io_log_as_flat_text }}</pre>
606- </div>
607- </td>
608- {% else %}
609- <td>&nbsp;</td>
610- {% endif %}
611- {% endif %}
612- </tr>
613- {% endfor %}
614- </tbody>
615- </table>
616- </div>
617- {% if "2013.com.canonical.certification::requirements" in manager.state.resource_map %}
618- <div id="content" class="clearfix">
619- <h2>Requirements documents</h2>
620- <ul>
621- {% for doc in manager.state.resource_map["2013.com.canonical.certification::requirements"] %}
622- <li><a href="{{ doc["link"] }}">{{ doc["name"] }}</a></li>
623- {% endfor %}
624- </ul>
625- </div>
626- {% endif %}
627- <div id="content" class="clearfix">
628- <h2>Log Files and Environment Information</h2>
629- <div id="packages-contents">
630- {% if "2013.com.canonical.certification::package" in manager.state.resource_map %}
631- <span onClick="showHide('package');"><h3><span id="package" style="color: #dd4814;">&#9654;</span>
632- packages installed</h3></span>
633- <div class="data" id="package-contents" style="overflow: auto;">
634- <table>
635- <thead>
636- <tr>
637- <th>Name</th>
638- <th>Version</th>
639- </tr>
640- </thead>
641- <tbody>
642- {% for package in manager.state.resource_map["2013.com.canonical.certification::package"] %}
643- <tr>
644- <td>{{ package["name"] }}</td>
645- <td>{{ package["version"] }}</td>
646- </tr>
647- {% endfor %}
648- </tbody>
649- </table>
650- </div>
651- {% endif %}
652- {% for job_id, job_state in manager.state.job_state_map|dictsort if job_state.result.outcome != None and job_state.job.plugin == "attachment" %}
653- <span onClick="showHide('att{{ loop.index }}');"><h3><span id="att{{ loop.index }}" style="color: #dd4814;">&#9654;</span>
654- {{ job_state.job.partial_id|replace("_attachment", "") }}</h3></span>
655- <div class="data" id="att{{ loop.index }}-contents" style="overflow: auto;">
656- <pre>{{ job_state.result.io_log_as_text_attachment }}</pre>
657- </div>
658+ <div id="title">
659+ <h1>System Testing<span class="grey"> Report</span></h1>
660+ </div>
661+ <div id="content" class="clearfix">
662+ This report was created using {{ client_name }} {{ client_version }} on {{ timestamp }}
663+ </div>
664+ {% if ns ~ "lsb" in resource_map %}
665+ <div id="content" class="clearfix">
666+ <h2>Software Information</h2>
667+ <p>{{ resource_map[ns ~ "lsb"][0]["description"] }}</p>
668+ </div>
669+ {% endif %}
670+ {% if manager.state.failed_blockers_map %}
671+ <div id="content" class="clearfix">
672+ <h2 id="questions">Certification Status - Blockers</h2>
673+ <table style="width: 700px">
674+ <thead>
675+ <tr>
676+ <th>Test ID</th>
677+ <th>Result</th>
678+ </tr>
679+ </thead>
680+ <tbody>
681+ {% for job_id, job_state in manager.state.get_certification_status_map()|dictsort %}
682+ <tr>
683+ <td>{{ job_state.job.tr_summary() }}</td>
684+ <td style='font-weight: bold; color: {{ job_state.result.outcome_meta().color_hex }}'>{{ job_state.result.outcome_meta().tr_label }}</td>
685+ </tr>
686+ {% endfor %}
687+ </tbody>
688+ </table>
689+ </div>
690+ {% endif %}
691+ {% if manager.state.failed_non_blockers_map %}
692+ <div id="content" class="clearfix">
693+ <h2 id="questions">Certification Status - Non Blockers</h2>
694+ <table style="width: 700px">
695+ <thead>
696+ <tr>
697+ <th>Test ID</th>
698+ <th>Result</th>
699+ </tr>
700+ </thead>
701+ <tbody>
702+ {% for job_id, job_state in manager.state.get_certification_status_map(certification_status_filter=('non-blocker',))|dictsort %}
703+ <tr>
704+ <td>{{ job_state.job.tr_summary() }}</td>
705+ <td style='font-weight: bold; color: {{ job_state.result.outcome_meta().color_hex }}'>{{ job_state.result.outcome_meta().tr_label }}</td>
706+ </tr>
707+ {% endfor %}
708+ </tbody>
709+ </table>
710+ </div>
711+ {% endif %}
712+ <div id="content" class="clearfix">
713+ <h2 id="questions">Tests Performed</h2>
714+ <table style="width: 824px">
715+ <thead>
716+ <tr>
717+ <th>Test ID</th>
718+ <th>Result</th>
719+ <th>Certification status</th>
720+ <th>Comments</th>
721+ </tr>
722+ </thead>
723+ <tbody>
724+ {%- for job_id, job_state in job_state_map|dictsort if job_state.result.outcome != None and job_state.job.plugin not in ("resource", "local", "attachment") %}
725+ {%- for result in job_state.result_history %}
726+ <tr>
727+ <td>{{ job_state.job.tr_summary() }}{% if loop.index > 1 %} (run {{ loop.index }}){% endif %}</td>
728+ <td style='font-weight: bold; color: {{ result.outcome_meta().color_hex }}'>{{ result.outcome_meta().tr_label }}</td>
729+ {%- if loop.index0 == 0 %}
730+ <td rowspan="{{ 2 * job_state.result_history|length }}">{{ job_state.effective_certification_status }}</td>
731+ {%- endif %}
732+ <td>{{ result.comments|default("", true) }}</td>
733+ </tr>
734+ <tr>
735+ <td colspan="4">
736+ <pre style="overflow: auto; width: 80em;">{{ result.io_log_as_flat_text }}</pre>
737+ </td>
738+ </tr>
739+ {%- endfor %}
740+ {%- endfor %}
741+ </tbody>
742+ </table>
743+ </div>
744+ <div id="content" class="clearfix">
745+ <h2>Log Files and Environment Information</h2>
746+ <div id="packages-contents">
747+ {% if ns ~ "package" in resource_map %}
748+ <span onClick="showHide('package');"><h3><span id="package" style="color: #dd4814;">&#9654;</span>
749+ packages installed</h3></span>
750+ <div class="data" id="package-contents" style="overflow: auto;">
751+ <table>
752+ <thead>
753+ <tr>
754+ <th>Name</th>
755+ <th>Version</th>
756+ </tr>
757+ </thead>
758+ <tbody>
759+ {% for package in resource_map[ns ~ "package"] %}
760+ <tr>
761+ <td>{{ package["name"] }}</td>
762+ <td>{{ package["version"] }}</td>
763+ </tr>
764 {% endfor %}
765- </div>
766- </div>
767+ </tbody>
768+ </table>
769+ </div>
770+ {% endif %}
771+ {% for job_id, job_state in job_state_map|dictsort if job_state.result.outcome != None and job_state.job.plugin == "attachment" %}
772+ <span onClick="showHide('att{{ loop.index }}');"><h3><span id="att{{ loop.index }}" style="color: #dd4814;">&#9654;</span>
773+ {{ job_state.job.partial_id|replace("_attachment", "") }}</h3></span>
774+ <div class="data" id="att{{ loop.index }}-contents" style="overflow: auto;">
775+ <pre>{{ job_state.result.io_log_as_text_attachment }}</pre>
776+ </div>
777+ {% endfor %}
778 </div>
779 </div>
780 </body>
781
782=== removed file 'plainbox/plainbox/data/report/checkbox.js'
783--- plainbox/plainbox/data/report/checkbox.js 2015-04-09 16:57:49 +0000
784+++ plainbox/plainbox/data/report/checkbox.js 1970-01-01 00:00:00 +0000
785@@ -1,16 +0,0 @@
786-function showHide(what) {
787- var heading = document.getElementById(what);
788- var contents = document.getElementById(what + "-contents");
789- var headingcontents = heading.innerHTML;
790- var newcontents;
791-
792- if (contents.style.display != "block") {
793- newcontents = headingcontents.replace(/[^\x00-\x80]/g, "&#9660;");
794- contents.style.display = "block";
795- } else {
796- newcontents = headingcontents.replace(/[^\x00-\x80]/g, "&#9654;");
797- contents.style.display = "none";
798- }
799-
800- heading.innerHTML = newcontents;
801-}
802
803=== added file 'plainbox/plainbox/data/report/hexr.xml'
804--- plainbox/plainbox/data/report/hexr.xml 1970-01-01 00:00:00 +0000
805+++ plainbox/plainbox/data/report/hexr.xml 2015-05-14 11:57:31 +0000
806@@ -0,0 +1,149 @@
807+{%- set ns = '2013.com.canonical.certification::' -%}
808+{%- set state = manager.default_device_context.state -%}
809+{%- set job_state_map = state.job_state_map -%}
810+{%- set hexr_outcome_list = OUTCOME_METADATA_MAP.values()|selectattr('hexr_xml_allowed')|sort(attribute='hexr_xml_order') -%}
811+<?xml version="1.0"?>
812+<system version="1.0">
813+ <context>
814+ {%- for job_id in job_state_map|sort %}
815+ {%- set job_state = job_state_map[job_id] %}
816+ {%- if job_state.job.id|strip_ns not in ("dmi_attachment", "sysfs_attachment", "udev_attachment") and job_state.result.outcome %}
817+ <info command="{{ job_state.job.id|strip_ns }}">{{ job_state.result.io_log_as_text_attachment }}</info>
818+ {%- endif %}
819+ {%- endfor %}
820+ </context>
821+ <hardware>
822+ {%- if ns ~ 'dmi_attachment' in state.job_state_map %}
823+ {%- set dmi_attachment = state.job_state_map[ns ~ 'dmi_attachment'].result.io_log_as_text_attachment %}
824+ <dmi>{{ dmi_attachment }}</dmi>
825+ {%- else %}
826+ <!-- the dmi_attachment job is not available, not producing the <dmi> section -->
827+ {%- endif %}
828+ {%- if ns ~ 'sysfs_attachment' in state.job_state_map %}
829+ {%- set sysfs_attachment = state.job_state_map[ns ~ 'sysfs_attachment'].result.io_log_as_text_attachment %}
830+ <sysfs-attributes>{{ sysfs_attachment }}</sysfs-attributes>
831+ {%- else %}
832+ <!-- the sysfs_attachment job is not available, not producing the <sysfs-attributes> tag -->
833+ {%- endif %}
834+ {%- if ns ~ 'udev_attachment' in state.job_state_map %}
835+ {%- set udev_attachment = state.job_state_map[ns ~ 'udev_attachment'].result.io_log_as_text_attachment %}
836+ <udev>{{ udev_attachment }}</udev>
837+ {%- else %}
838+ <!-- the udev_attachment job is not available, not producing the <udev> tag -->
839+ {%- endif %}
840+ {%- if ns ~ 'cpuinfo' in state.resource_map %}
841+ {%- set processor_resource = state.resource_map[ns ~ 'cpuinfo'][0] %}
842+ {#- FIXME: The <processors> section is quite broken by design. #}
843+ {#- Yes, it does copy the data for the 0th CPU $count times. #}
844+ <processors>
845+ {%- for dummy_index in range(processor_resource.count|int) %}
846+ <processor id="{{ loop.index0 }}" name="{{ loop.index0 }}">
847+ {%- for key in processor_resource|sort %}
848+ <property name="{{ key }}" type="str">{{ processor_resource[key] }}</property>
849+ {%- endfor %}
850+ </processor>
851+ {%- endfor %}
852+ </processors>
853+ {%- else %}
854+ <!-- cpuinfo resource is not available, not producing the <processors> section -->
855+ {%- endif %}
856+ </hardware>
857+ <questions>
858+ {%- for job_id in job_state_map|sort %}
859+ {%- set job_state = job_state_map[job_id] %}
860+ {%- set job = job_state.job %}
861+ {%- set result = job_state.result %}
862+ {%- if job.plugin not in ("resource", "local", "attachment") and job_state.result.outcome %}
863+ <question{{ {
864+ 'name': job.id|strip_ns,
865+ 'certification_status': job_state.certification_status,
866+ }|sorted_xmlattr }}>
867+ <answer type="multiple_choice">{{ result.outcome_meta().hexr_xml_mapping }}</answer>
868+ {#- FIXME: Yes the <answer_choices> section is useless #}
869+ <answer_choices>
870+ {%- for outcome_info in hexr_outcome_list %}
871+ <value type="str">{{ outcome_info.hexr_xml_mapping }}</value>
872+ {%- endfor %}
873+ </answer_choices>
874+ {#- FIXME: yes this does munges comments and I/O log #}
875+ <comment>
876+ {%- if result.comments -%}
877+ {{ result.comments }}
878+ {%- else -%}
879+ {{ result.io_log_as_flat_text }}
880+ {%- endif -%}
881+ </comment>
882+ </question>
883+ {%- endif %}
884+ {%- endfor %}
885+ </questions>
886+ <software>
887+ {%- if ns ~ 'lsb' in state.resource_map %}
888+ {%- set lsb_resource = state.resource_map[ns ~ 'lsb'][0] %}
889+ <lsbrelease>
890+ {%- for key in lsb_resource|sort %}
891+ <property name="{{ key }}" type="str">{{ lsb_resource[key] }}</property>
892+ {%- endfor %}
893+ </lsbrelease>
894+ {%- else %}
895+ <!-- lsb resource is not available, not producing the <lsbrelease> tag -->
896+ {%- endif %}
897+ {%- if ns ~ 'package' in state.resource_map %}
898+ {%- set package_resource_list = state.resource_map[ns ~ 'package'] %}
899+ <packages>
900+ {%- for package_resource in package_resource_list %}
901+ <package id="{{ loop.index0 }}" name="{{ package_resource.name }}">
902+ {%- for key in package_resource|reject('is_name')|sort %}
903+ <property name="{{ key }}" type="str">{{ package_resource[key] }}</property>
904+ {%- endfor %}
905+ </package>
906+ {%- endfor %}
907+ </packages>
908+ {%- else %}
909+ <!-- package resource is not available, not producing the <packages> tag -->
910+ {%- endif %}
911+ {%- if ns ~ 'requirements' in state.resource_map %}
912+ {%- set requirement_resource_list = state.resource_map[ns ~ 'requirements'] %}
913+ <requirements>
914+ {%- for requirement_resource in requirement_resource_list %}
915+ <requirement id=" {{ loop.index0 }}" name="{{ requirement_resource.name }}">
916+ {%- for key in requirement_resource|reject('is_name')|sort %}
917+ <property name="{{ key }}" type="str">{{ requirement_resource[key] }}</property>
918+ {%- endfor %}
919+ </requirement>
920+ {%- endfor %}
921+ </requirements>
922+ {%- else %}
923+ <!-- requirements resource is not available, not producing the <requirements> tag -->
924+ {%- endif %}
925+ </software>
926+ <summary>
927+ {#- FIXME: with all the <property> tags those should not be custom #}
928+ <client name="{{ client_name }}" version="{{ client_version }}"/>
929+ <date_created value="{{ timestamp }}"/>
930+ {%- if ns ~ 'dpkg' in state.resource_map %}
931+ {%- set dpkg_resource = state.resource_map[ns ~ 'dpkg'][0] %}
932+ <architecture value="{{ dpkg_resource.architecture }}"/>
933+ {%- else %}
934+ <!-- dpkg resource is not available, not producing the <architecture> tag -->
935+ {%- endif %}
936+ {%- if ns ~ 'lsb' in state.resource_map %}
937+ {#- NOTE: lsb_resource is computed earlier, if the condition above holds #}
938+ <distribution value="{{ lsb_resource.distributor_id }}"/>
939+ <distroseries value="{{ lsb_resource.release }}"/>
940+ {%- else %}
941+ <!-- lsb resource is not available, not producing <distribution> and <distroseries> tags -->
942+ {%- endif %}
943+ {%- if ns ~ 'uname' in state.resource_map %}
944+ {%- set uname_resource = state.resource_map[ns ~ 'uname'][0] %}
945+ <kernel-release value="{{ uname_resource.release }}"/>
946+ {%- else %}
947+ <!-- uname resource is not available, not producing the <kernel-release> tag -->
948+ {%- endif %}
949+ {#- FIXME: yes, this is just hard-coded non-sense #}
950+ <private value="False"/>
951+ <contactable value="False"/>
952+ <live_cd value="False"/>
953+ <system_id value="{{ system_id }}"/>
954+ </summary>
955+</system>
956
957=== modified file 'plainbox/plainbox/impl/commands/inv_check_config.py'
958--- plainbox/plainbox/impl/commands/inv_check_config.py 2014-11-18 17:20:58 +0000
959+++ plainbox/plainbox/impl/commands/inv_check_config.py 2015-05-14 11:57:31 +0000
960@@ -43,14 +43,17 @@
961 print(_(" - {0} (not present)").format(filename))
962 print(_("Variables:"))
963 for variable in self.config.Meta.variable_list:
964+ print(" [{0}]".format(variable.section))
965 print(" {0}={1}".format(
966 variable.name,
967 variable.__get__(self.config, self.config.__class__)))
968 print(_("Sections:"))
969 for section in self.config.Meta.section_list:
970- print(" {0}={1}".format(
971- section.name,
972- section.__get__(self.config, self.config.__class__)))
973+ print(" [{0}]".format(section.name))
974+ section_value = section.__get__(self.config, self.config.__class__)
975+ if section_value:
976+ for key, value in sorted(section_value.items()):
977+ print(" {0}={1}".format(key, value))
978 if self.config.problem_list:
979 print(_("Problems:"))
980 for problem in self.config.problem_list:
981
982=== modified file 'plainbox/plainbox/impl/commands/inv_run.py'
983--- plainbox/plainbox/impl/commands/inv_run.py 2015-05-12 16:09:40 +0000
984+++ plainbox/plainbox/impl/commands/inv_run.py 2015-05-14 11:57:31 +0000
985@@ -44,6 +44,7 @@
986 from plainbox.impl.depmgr import DependencyDuplicateError
987 from plainbox.impl.exporter import ByteStringStreamTranslator
988 from plainbox.impl.exporter import get_all_exporters
989+from plainbox.impl.result import JobResultBuilder
990 from plainbox.impl.result import MemoryJobResult
991 from plainbox.impl.result import tr_outcome
992 from plainbox.impl.runner import JobRunner
993@@ -626,6 +627,11 @@
994 # Connect the on_job_added signal. We use it to mark the test loop
995 # for re-execution and to update the list of desired jobs.
996 self.state.on_job_added.connect(self.on_job_added)
997+ finally:
998+ print(_("Session ID: {0}").format(
999+ self.C.YELLOW(self._manager.storage.id)
1000+ ))
1001+
1002
1003 def create_runner(self):
1004 """
1005@@ -741,10 +747,7 @@
1006 estimated_time -= job.estimated_duration
1007
1008 def run_single_job(self, job):
1009- job_start_time = time.time()
1010 self.run_single_job_with_ui(job, self.get_ui_for_job(job))
1011- job_state = self.state.job_state_map[job.id]
1012- job_state.result.execution_duration = time.time() - job_start_time
1013
1014 def get_ui_for_job(self, job):
1015 if self.ns.dont_suppress_output is False and job.plugin in (
1016@@ -754,6 +757,7 @@
1017 return NormalUI(self.C.c, show_cmd_output=True)
1018
1019 def run_single_job_with_ui(self, job, ui):
1020+ job_start_time = time.time()
1021 job_state = self.state.job_state_map[job.id]
1022 ui.considering_job(job, job_state)
1023 if job_state.can_start():
1024@@ -761,18 +765,21 @@
1025 self.metadata.running_job_name = job.id
1026 self.manager.checkpoint()
1027 ui.started_running(job, job_state)
1028- job_result = self._run_single_job_with_ui_loop(job, job_state, ui)
1029+ result_builder = self._run_single_job_with_ui_loop(job, job_state, ui)
1030+ assert result_builder is not None
1031+ result_builder.execution_duration = time.time() - job_start_time
1032+ job_result = result_builder.seal()
1033 self.metadata.running_job_name = None
1034 self.manager.checkpoint()
1035 ui.finished_running(job, job_state, job_result)
1036 else:
1037- job_result = MemoryJobResult({
1038- 'outcome': IJobResult.OUTCOME_NOT_SUPPORTED,
1039- 'comments': job_state.get_readiness_description()
1040- })
1041+ result_builder = JobResultBuilder(
1042+ outcome=IJobResult.OUTCOME_NOT_SUPPORTED,
1043+ comments=job_state.get_readiness_description(),
1044+ execution_duration = time.time() - job_start_time)
1045+ job_result = result_builder.seal()
1046 ui.job_cannot_start(job, job_state, job_result)
1047- if job_result is not None:
1048- self.state.update_job_result(job, job_result)
1049+ self.state.update_job_result(job, job_result)
1050 ui.finished(job, job_state, job_result)
1051
1052 def _run_single_job_with_ui_loop(self, job, job_state, ui):
1053@@ -788,8 +795,9 @@
1054 cmd = ui.wait_for_interaction_prompt(job)
1055 if cmd == 'run' or cmd is None:
1056 ui.notify_about_steps(job)
1057- job_result = self.runner.run_job(
1058- job, job_state, self.config, ui)
1059+ result_builder = self.runner.run_job(
1060+ job, job_state, self.config, ui
1061+ ).unseal()
1062 elif cmd == 'comment':
1063 new_comment = input(self.C.BLUE(
1064 _('Please enter your comments:') + '\n'))
1065@@ -797,36 +805,37 @@
1066 comments += new_comment + '\n'
1067 continue
1068 elif cmd == 'skip':
1069- job_result = MemoryJobResult({
1070- 'outcome': IJobResult.OUTCOME_SKIP,
1071- 'comments': _("Explicitly skipped before"
1072- " execution")
1073- })
1074+ result_builder = JobResultBuilder(
1075+ outcome=IJobResult.OUTCOME_SKIP,
1076+ comments=_("Explicitly skipped before"
1077+ " execution"))
1078 if comments != "":
1079- job_result.comments = comments
1080+ result_builder.comments = comments
1081 break
1082 elif cmd == 'quit':
1083 raise SystemExit()
1084 else:
1085- job_result = self.runner.run_job(
1086- job, job_state, self.config, ui)
1087+ result_builder = self.runner.run_job(
1088+ job, job_state, self.config, ui
1089+ ).unseal()
1090 else:
1091 if 'noreturn' in job.get_flag_set():
1092 ui.noreturn_job()
1093- job_result = self.runner.run_job(
1094- job, job_state, self.config, ui)
1095+ result_builder = self.runner.run_job(
1096+ job, job_state, self.config, ui
1097+ ).unseal()
1098 if (self.is_interactive and
1099- job_result.outcome == IJobResult.OUTCOME_UNDECIDED):
1100+ result_builder.outcome == IJobResult.OUTCOME_UNDECIDED):
1101 try:
1102 if comments != "":
1103- job_result.comments = comments
1104+ result_builder.comments = comments
1105 ui.notify_about_verification(job)
1106- job_result = self._interaction_callback(
1107- self.runner, job, job_result, self.config)
1108+ self._interaction_callback(
1109+ self.runner, job, result_builder, self.config)
1110 except ReRunJob:
1111 continue
1112 break
1113- return job_result
1114+ return result_builder
1115
1116 def export_and_send_results(self):
1117 # Get a stream with exported session data.
1118@@ -861,7 +870,7 @@
1119 def _pick_action_cmd(self, action_list, prompt=None):
1120 return ActionUI(action_list, prompt, self._color).run()
1121
1122- def _interaction_callback(self, runner, job, result, config,
1123+ def _interaction_callback(self, runner, job, result_builder, config,
1124 prompt=None, allowed_outcome=None):
1125 if prompt is None:
1126 prompt = _("Select an outcome or an action: ")
1127@@ -890,42 +899,40 @@
1128 if job.command is not None:
1129 allowed_actions.append(
1130 Action('r', _('re-run this job'), 're-run'))
1131- if result.return_code is not None:
1132- if result.return_code == 0:
1133+ if result_builder.return_code is not None:
1134+ if result_builder.return_code == 0:
1135 suggested_outcome = IJobResult.OUTCOME_PASS
1136 else:
1137 suggested_outcome = IJobResult.OUTCOME_FAIL
1138 allowed_actions.append(
1139 Action('', _('set suggested outcome [{0}]').format(
1140 tr_outcome(suggested_outcome)), 'set-suggested'))
1141- while result.outcome not in allowed_outcome:
1142+ while result_builder.outcome not in allowed_outcome:
1143 print(_("Please decide what to do next:"))
1144- print(" " + _("outcome") + ": {0}".format(self.C.result(result)))
1145- if result.comments is None:
1146+ print(" " + _("outcome") + ": {0}".format(
1147+ self.C.result(result_builder.seal())))
1148+ if result_builder.comments is None:
1149 print(" " + _("comments") + ": {0}".format(
1150 C_("none comment", "none")))
1151 else:
1152 print(" " + _("comments") + ": {0}".format(
1153- self.C.CYAN(result.comments, bright=False)))
1154+ self.C.CYAN(result_builder.comments, bright=False)))
1155 cmd = self._pick_action_cmd(allowed_actions)
1156 if cmd == 'set-pass':
1157- result.outcome = IJobResult.OUTCOME_PASS
1158+ result_builder.outcome = IJobResult.OUTCOME_PASS
1159 elif cmd == 'set-fail':
1160- result.outcome = IJobResult.OUTCOME_FAIL
1161+ result_builder.outcome = IJobResult.OUTCOME_FAIL
1162 elif cmd == 'set-skip' or cmd is None:
1163- result.outcome = IJobResult.OUTCOME_SKIP
1164+ result_builder.outcome = IJobResult.OUTCOME_SKIP
1165 elif cmd == 'set-suggested':
1166- result.outcome = suggested_outcome
1167+ result_builder.outcome = suggested_outcome
1168 elif cmd == 'set-comments':
1169- if result.comments is None:
1170- result.comments = ""
1171 new_comment = input(self.C.BLUE(
1172 _('Please enter your comments:') + '\n'))
1173 if new_comment:
1174- result.comments += new_comment + '\n'
1175+ result_builder.add_comment(new_comment)
1176 elif cmd == 're-run':
1177 raise ReRunJob
1178- return result
1179
1180 def _update_desired_job_list(self, desired_job_list):
1181 problem_list = self.state.update_desired_job_list(desired_job_list)
1182
1183=== added file 'plainbox/plainbox/impl/exporter/hexr.py'
1184--- plainbox/plainbox/impl/exporter/hexr.py 1970-01-01 00:00:00 +0000
1185+++ plainbox/plainbox/impl/exporter/hexr.py 2015-05-14 11:57:31 +0000
1186@@ -0,0 +1,138 @@
1187+# This file is part of Checkbox.
1188+#
1189+# Copyright 2015 Canonical Ltd.
1190+# Written by:
1191+# Zygmunt Krynicki <zygmunt.krynicki@canonical.com>
1192+#
1193+# Checkbox is free software: you can redistribute it and/or modify
1194+# it under the terms of the GNU General Public License version 3,
1195+# as published by the Free Software Foundation.
1196+#
1197+# Checkbox is distributed in the hope that it will be useful,
1198+# but WITHOUT ANY WARRANTY; without even the implied warranty of
1199+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
1200+# GNU General Public License for more details.
1201+#
1202+# You should have received a copy of the GNU General Public License
1203+# along with Checkbox. If not, see <http://www.gnu.org/licenses/>.
1204+
1205+"""Exporter for creating the XML structure expected by HEXR."""
1206+
1207+from datetime import datetime
1208+
1209+from jinja2 import Markup
1210+from jinja2 import Undefined
1211+from jinja2 import environmentfilter
1212+from jinja2 import escape
1213+
1214+from plainbox import __version__ as version
1215+from plainbox.impl.exporter.jinja2 import Jinja2SessionStateExporter
1216+from plainbox.impl.result import OUTCOME_METADATA_MAP
1217+
1218+
1219+#: Name-space prefix for Canonical Certification
1220+CERTIFICATION_NS = '2013.com.canonical.certification::'
1221+
1222+
1223+@environmentfilter
1224+def do_sorted_xmlattr(_environment, d, autospace=True):
1225+ """A version of xmlattr filter that sorts attributes."""
1226+ rv = ' '.join(
1227+ '%s="%s"' % (escape(key), escape(value))
1228+ for key, value in sorted(d.items())
1229+ if value is not None and not isinstance(value, Undefined)
1230+ )
1231+ if autospace and rv:
1232+ rv = ' ' + rv
1233+ if _environment.autoescape:
1234+ rv = Markup(rv)
1235+ return rv
1236+
1237+
1238+@environmentfilter
1239+def do_strip_ns(_environment, unit_id, ns=CERTIFICATION_NS):
1240+ """Remove the namespace part of the identifier."""
1241+ if unit_id.startswith(ns):
1242+ rv = unit_id[len(ns):]
1243+ else:
1244+ rv = unit_id
1245+ rv = escape(rv)
1246+ if _environment.autoescape:
1247+ rv = Markup(rv)
1248+ return rv
1249+
1250+
1251+def do_is_name(text):
1252+ """A filter for checking if something is equal to "name"."""
1253+ return text == 'name'
1254+
1255+
1256+class HEXRExporter(Jinja2SessionStateExporter):
1257+
1258+ """
1259+ Exporter for creating HEXR XML documents.
1260+
1261+ This exporter takes the whole session and creates a XML document
1262+ containing a subset of the data. It is applicable for submission to HEXR
1263+ and ``C3`` (the certification website). It's also really bad.
1264+
1265+ The format is hardwired to require certain jobs. They are:
1266+
1267+ - 2013.com.canonical.certification::package (Optional)
1268+ - 2013.com.canonical.certification::uname (Optional)
1269+ - 2013.com.canonical.certification::lsb (Mandatory)
1270+ - 2013.com.canonical.certification::cpuinfo (Mandatory)
1271+ - 2013.com.canonical.certification::dpkg (Mandatory)
1272+ - 2013.com.canonical.certification::dmi_attachment (Mandatory)
1273+ - 2013.com.canonical.certification::sysfs_attachment (Mandatory)
1274+ - 2013.com.canonical.certification::udev_attachment (Mandatory)
1275+
1276+ .. note::
1277+ The exporter won't misbehave if those are not available but the server
1278+ side component will most likely crash or reject the resulting document.
1279+ """
1280+
1281+ def __init__(self, option_list=None, system_id="", timestamp=None,
1282+ client_version=None, client_name='plainbox'):
1283+ """Initialize the exporter with stuff that exporters need."""
1284+ super().__init__("hexr.xml", option_list)
1285+ self._system_id = system_id
1286+ # Generate a time-stamp if needed
1287+ if timestamp is None:
1288+ timestamp = datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%S")
1289+ self._timestamp = timestamp
1290+ # Use current version unless told otherwise
1291+ if client_version is None:
1292+ client_version = "{}.{}.{}".format(*version[:3])
1293+ self._client_version = client_version
1294+ # Remember client name
1295+ self._client_name = client_name
1296+
1297+ def customize_environment(self, env):
1298+ """Register filters and tests custom to the HEXR exporter."""
1299+ env.autoescape = True
1300+ env.filters['sorted_xmlattr'] = do_sorted_xmlattr
1301+ env.filters['strip_ns'] = do_strip_ns
1302+ env.tests['is_name'] = do_is_name
1303+
1304+ def dump_from_session_manager(self, manager, stream):
1305+ """
1306+ Extract data from session_manager and dump it into the stream.
1307+
1308+ :param session_manager:
1309+ SessionManager instance that manages session to be exported by
1310+ this exporter
1311+ :param stream:
1312+ Byte stream to write to.
1313+
1314+ """
1315+ data = {
1316+ 'OUTCOME_METADATA_MAP': OUTCOME_METADATA_MAP,
1317+ 'client_name': self._client_name,
1318+ 'client_version': self._client_version,
1319+ 'manager': manager,
1320+ 'options': self.option_list,
1321+ 'system_id': self._system_id,
1322+ 'timestamp': self._timestamp,
1323+ }
1324+ self.dump(data, stream)
1325
1326=== modified file 'plainbox/plainbox/impl/exporter/html.py'
1327--- plainbox/plainbox/impl/exporter/html.py 2015-05-10 18:38:53 +0000
1328+++ plainbox/plainbox/impl/exporter/html.py 2015-05-14 11:57:31 +0000
1329@@ -33,7 +33,6 @@
1330
1331 from plainbox import __version__ as version
1332 from plainbox.impl.exporter.jinja2 import Jinja2SessionStateExporter
1333-from plainbox.impl.result import OUTCOME_METADATA_MAP as OMM
1334
1335
1336 class HTMLSessionStateExporter(Jinja2SessionStateExporter):
1337@@ -75,7 +74,6 @@
1338 data = {
1339 'manager': session_manager,
1340 'options': self.option_list,
1341- 'OMM': OMM,
1342 'timestamp': self._timestamp,
1343 'client_version': self._client_version,
1344 'client_name': self._client_name
1345
1346=== modified file 'plainbox/plainbox/impl/exporter/jinja2.py'
1347--- plainbox/plainbox/impl/exporter/jinja2.py 2015-04-09 16:57:49 +0000
1348+++ plainbox/plainbox/impl/exporter/jinja2.py 2015-05-14 11:57:31 +0000
1349@@ -55,6 +55,7 @@
1350 paths.extend(extra_paths)
1351 loader = FileSystemLoader(paths)
1352 env = Environment(loader=loader)
1353+ self.customize_environment(env)
1354
1355 def include_file(name):
1356 # This helper function insert static files literally into Jinja
1357@@ -64,6 +65,14 @@
1358 self.template = env.get_template(jinja2_template)
1359 env.globals['include_file'] = include_file
1360
1361+ def customize_environment(self, env):
1362+ """
1363+ Customize the jinja2 Environment object.
1364+
1365+ By default this method does nothing. Override it in your subclass to
1366+ define any extra filters, tests or other things that you may require.
1367+ """
1368+
1369 def dump(self, data, stream):
1370 """
1371 Render report using jinja2 and dump it to stream.
1372
1373=== added file 'plainbox/plainbox/impl/exporter/test_hexr.py'
1374--- plainbox/plainbox/impl/exporter/test_hexr.py 1970-01-01 00:00:00 +0000
1375+++ plainbox/plainbox/impl/exporter/test_hexr.py 2015-05-14 11:57:31 +0000
1376@@ -0,0 +1,535 @@
1377+# This file is part of Checkbox.
1378+#
1379+# Copyright 2015 Canonical Ltd.
1380+# Written by:
1381+# Zygmunt Krynicki <zygmunt.krynicki@canonical.com>
1382+#
1383+# Checkbox is free software: you can redistribute it and/or modify
1384+# it under the terms of the GNU General Public License version 3,
1385+# as published by the Free Software Foundation.
1386+#
1387+# Checkbox is distributed in the hope that it will be useful,
1388+# but WITHOUT ANY WARRANTY; without even the implied warranty of
1389+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
1390+# GNU General Public License for more details.
1391+#
1392+# You should have received a copy of the GNU General Public License
1393+# along with Checkbox. If not, see <http://www.gnu.org/licenses/>.
1394+
1395+"""Tests for the hexr exporter."""
1396+
1397+from io import BytesIO
1398+from unittest import TestCase
1399+from xml.etree import ElementTree
1400+
1401+from plainbox.impl.exporter.hexr import CERTIFICATION_NS
1402+from plainbox.impl.exporter.hexr import HEXRExporter
1403+from plainbox.impl.providers.special import get_stubbox
1404+from plainbox.impl.resource import Resource
1405+from plainbox.impl.result import MemoryJobResult
1406+from plainbox.impl.session import SessionManager
1407+from plainbox.impl.unit.job import JobDefinition
1408+
1409+
1410+class HexrExporterTests(TestCase):
1411+
1412+ """Tests for HEXRExporter."""
1413+
1414+ maxDiff = None
1415+
1416+ def setUp(self):
1417+ """Common initialization."""
1418+ self.exporter = HEXRExporter(
1419+ system_id='SYSTEM_ID', timestamp='TIMESTAMP',
1420+ client_version='CLIENT_VERSION', client_name='CLIENT_NAME')
1421+ self.manager = SessionManager.create()
1422+ self.manager.add_local_device_context()
1423+
1424+ def _populate_session(self):
1425+ self._make_representative_jobs()
1426+ self._make_cert_resources()
1427+ self._make_cert_attachments()
1428+
1429+ def _make_representative_jobs(self):
1430+ # Add all of the jobs from representative.pxu so that we don't have to
1431+ # create verbose fakes. Each job gets a simple passing result.
1432+ state = self.manager.default_device_context.state
1433+ stubbox = get_stubbox(validate=False, check=True)
1434+ for job in stubbox.job_list:
1435+ if not job.partial_id.startswith('representative/plugin/'):
1436+ continue
1437+ state.add_unit(job)
1438+ result = self._make_result_for(job)
1439+ state.update_job_result(job, result)
1440+ # Add a comment to one job (the last one)
1441+ result.comments = 'COMMENTS'
1442+
1443+ def _make_result_for(self, job):
1444+ data = {
1445+ 'outcome': 'pass',
1446+ 'comments': None
1447+ }
1448+ if job.plugin == 'local':
1449+ pass
1450+ elif job.plugin == 'resource':
1451+ pass
1452+ else:
1453+ data['io_log'] = (
1454+ (0, 'stdout', b'IO-LOG-STDOUT\n'),
1455+ (1, 'stderr', b'IO-LOG-STDERR\n')
1456+ )
1457+ return MemoryJobResult(data)
1458+
1459+ def _make_cert_resources(self):
1460+ # Create some specific resources that this exporter relies on. The
1461+ # corresponding jobs are _not_ loaded but this is irrelevant.
1462+ state = self.manager.default_device_context.state
1463+ ns = CERTIFICATION_NS
1464+ state.set_resource_list(ns + 'cpuinfo', [Resource({
1465+ 'PROP-1': 'VALUE-1',
1466+ 'PROP-2': 'VALUE-2',
1467+ 'count': '2', # NOTE: this has to be a number :/
1468+ })])
1469+ state.set_resource_list(ns + 'dpkg', [Resource({
1470+ 'architecture': 'dpkg.ARCHITECTURE',
1471+ })])
1472+ state.set_resource_list(ns + 'lsb', [Resource({
1473+ 'codename': 'lsb.CODENAME',
1474+ 'description': 'lsb.DESCRIPTION',
1475+ 'release': 'lsb.RELEASE',
1476+ 'distributor_id': 'lsb.DISTRIBUTOR_ID',
1477+ })])
1478+ state.set_resource_list(ns + 'uname', [Resource({
1479+ 'release': 'uname.RELEASE',
1480+ })])
1481+ state.set_resource_list(ns + 'package', [Resource({
1482+ 'name': 'package.0.NAME',
1483+ 'version': 'package.0.VERSION',
1484+ }), Resource({
1485+ 'name': 'package.1.NAME',
1486+ 'version': 'package.1.VERSION',
1487+ })])
1488+ state.set_resource_list(ns + 'requirements', [Resource({
1489+ 'name': 'requirement.0.NAME',
1490+ 'link': 'requirement.0.LINK',
1491+ }), Resource({
1492+ 'name': 'requirement.1.NAME',
1493+ 'link': 'requirement.1.LINK',
1494+ })])
1495+
1496+ def _make_cert_attachments(self):
1497+ state = self.manager.default_device_context.state
1498+ partial_id_list = ['dmi_attachment', 'sysfs_attachment',
1499+ 'udev_attachment']
1500+ for partial_id in partial_id_list:
1501+ job = JobDefinition({
1502+ 'id': CERTIFICATION_NS + partial_id,
1503+ 'plugin': 'attachment'
1504+ })
1505+ result = MemoryJobResult({
1506+ 'io_log': (
1507+ (0, 'stdout', 'STDOUT-{}\n'.format(
1508+ partial_id).encode('utf-8')),
1509+ (1, 'stderr', 'STDERR-{}\n'.format(
1510+ partial_id).encode('utf-8'))),
1511+ })
1512+ state.add_unit(job)
1513+ state.update_job_result(job, result)
1514+
1515+ def _inject_evil_input(self):
1516+ evil = '"\'<&>'
1517+ self.exporter._system_id = evil
1518+ self.exporter._timestamp = evil
1519+ self.exporter._client_name = evil
1520+ self.exporter._client_version = evil
1521+ state = self.manager.default_device_context.state
1522+ for resource_id in state.resource_map:
1523+ resource_list = state.resource_map[resource_id]
1524+ for resource in resource_list:
1525+ for key in resource:
1526+ if resource_id.endswith('cpuinfo') and key == 'count':
1527+ # don't change resources for the <hardware> section
1528+ continue
1529+ resource[key] = evil
1530+ new_job_state_map = {}
1531+ for index, job_id in enumerate(sorted(state.job_state_map)):
1532+ job_state = state.job_state_map[job_id]
1533+ if (job_state.job.partial_id.endswith('_attachment')
1534+ or job_state.job.partial_id == 'cpuinfo'):
1535+ # don't change attachments for the <hardware> section
1536+ evil_id = job_id
1537+ else:
1538+ evil_id = '{}-{}-{}'.format(evil, index, job_state.job.plugin)
1539+ # NOTE: using private API
1540+ job_state.job._data['id'] = evil_id
1541+ job_state.result.comments = evil
1542+ # NOTE: using private API
1543+ job_state.result._data['_io_log'] = (
1544+ (0, 'stdout', evil.encode("UTF-8")),
1545+ )
1546+ new_job_state_map[evil_id] = job_state
1547+ # NOTE: using private API
1548+ state._job_state_map = new_job_state_map
1549+
1550+ def tearDown(self):
1551+ """Common teardown."""
1552+ self.manager.destroy()
1553+
1554+ def test_smoke(self):
1555+ """The XML document has the right data in the right spot."""
1556+ self._populate_session()
1557+ stream = BytesIO()
1558+ self.exporter.dump_from_session_manager(self.manager, stream)
1559+ smoke_actual = stream.getvalue().decode("utf-8")
1560+ self.assertMultiLineEqual(_smoke_expected, smoke_actual)
1561+
1562+ def test_without_any_data(self):
1563+ """The XML document can be produced without any data in the session."""
1564+ stream = BytesIO()
1565+ self.exporter.dump_from_session_manager(self.manager, stream)
1566+ empty_actual = stream.getvalue().decode("utf-8")
1567+ self.assertMultiLineEqual(_empty_expected, empty_actual)
1568+
1569+ def test_escaping(self):
1570+ """Evil input doesn't break the correctness of the XML document."""
1571+ self._populate_session()
1572+ self._inject_evil_input()
1573+ stream = BytesIO()
1574+ self.exporter.dump_from_session_manager(self.manager, stream)
1575+ evil_actual = stream.getvalue().decode("utf-8")
1576+ self.assertMultiLineEqual(_evil_expected, evil_actual)
1577+
1578+ def test_xml_parsability(self):
1579+ """Each produced output can be parsed with an XML parser."""
1580+ stream1 = BytesIO(_smoke_expected.encode("utf-8"))
1581+ ElementTree.parse(stream1)
1582+ stream2 = BytesIO(_empty_expected.encode("utf-8"))
1583+ ElementTree.parse(stream2)
1584+ stream3 = BytesIO(_evil_expected.encode("utf-8"))
1585+ ElementTree.parse(stream3)
1586+
1587+
1588+_smoke_expected = """\
1589+<?xml version="1.0"?>
1590+<system version="1.0">
1591+ <context>
1592+ <info command="2013.com.canonical.plainbox::representative/plugin/attachment">IO-LOG-STDOUT
1593+</info>
1594+ <info command="2013.com.canonical.plainbox::representative/plugin/local"></info>
1595+ <info command="2013.com.canonical.plainbox::representative/plugin/manual">IO-LOG-STDOUT
1596+</info>
1597+ <info command="2013.com.canonical.plainbox::representative/plugin/qml">IO-LOG-STDOUT
1598+</info>
1599+ <info command="2013.com.canonical.plainbox::representative/plugin/resource"></info>
1600+ <info command="2013.com.canonical.plainbox::representative/plugin/shell">IO-LOG-STDOUT
1601+</info>
1602+ <info command="2013.com.canonical.plainbox::representative/plugin/user-interact">IO-LOG-STDOUT
1603+</info>
1604+ <info command="2013.com.canonical.plainbox::representative/plugin/user-interact-verify">IO-LOG-STDOUT
1605+</info>
1606+ <info command="2013.com.canonical.plainbox::representative/plugin/user-verify">IO-LOG-STDOUT
1607+</info>
1608+ </context>
1609+ <hardware>
1610+ <dmi>STDOUT-dmi_attachment
1611+</dmi>
1612+ <sysfs-attributes>STDOUT-sysfs_attachment
1613+</sysfs-attributes>
1614+ <udev>STDOUT-udev_attachment
1615+</udev>
1616+ <processors>
1617+ <processor id="0" name="0">
1618+ <property name="count" type="str">2</property>
1619+ <property name="PROP-1" type="str">VALUE-1</property>
1620+ <property name="PROP-2" type="str">VALUE-2</property>
1621+ </processor>
1622+ <processor id="1" name="1">
1623+ <property name="count" type="str">2</property>
1624+ <property name="PROP-1" type="str">VALUE-1</property>
1625+ <property name="PROP-2" type="str">VALUE-2</property>
1626+ </processor>
1627+ </processors>
1628+ </hardware>
1629+ <questions>
1630+ <question name="2013.com.canonical.plainbox::representative/plugin/manual">
1631+ <answer type="multiple_choice">pass</answer>
1632+ <answer_choices>
1633+ <value type="str">none</value>
1634+ <value type="str">pass</value>
1635+ <value type="str">fail</value>
1636+ <value type="str">skip</value>
1637+ </answer_choices>
1638+ <comment>IO-LOG-STDOUT
1639+IO-LOG-STDERR
1640+</comment>
1641+ </question>
1642+ <question name="2013.com.canonical.plainbox::representative/plugin/qml">
1643+ <answer type="multiple_choice">pass</answer>
1644+ <answer_choices>
1645+ <value type="str">none</value>
1646+ <value type="str">pass</value>
1647+ <value type="str">fail</value>
1648+ <value type="str">skip</value>
1649+ </answer_choices>
1650+ <comment>IO-LOG-STDOUT
1651+IO-LOG-STDERR
1652+</comment>
1653+ </question>
1654+ <question name="2013.com.canonical.plainbox::representative/plugin/shell">
1655+ <answer type="multiple_choice">pass</answer>
1656+ <answer_choices>
1657+ <value type="str">none</value>
1658+ <value type="str">pass</value>
1659+ <value type="str">fail</value>
1660+ <value type="str">skip</value>
1661+ </answer_choices>
1662+ <comment>IO-LOG-STDOUT
1663+IO-LOG-STDERR
1664+</comment>
1665+ </question>
1666+ <question name="2013.com.canonical.plainbox::representative/plugin/user-interact">
1667+ <answer type="multiple_choice">pass</answer>
1668+ <answer_choices>
1669+ <value type="str">none</value>
1670+ <value type="str">pass</value>
1671+ <value type="str">fail</value>
1672+ <value type="str">skip</value>
1673+ </answer_choices>
1674+ <comment>IO-LOG-STDOUT
1675+IO-LOG-STDERR
1676+</comment>
1677+ </question>
1678+ <question name="2013.com.canonical.plainbox::representative/plugin/user-interact-verify">
1679+ <answer type="multiple_choice">pass</answer>
1680+ <answer_choices>
1681+ <value type="str">none</value>
1682+ <value type="str">pass</value>
1683+ <value type="str">fail</value>
1684+ <value type="str">skip</value>
1685+ </answer_choices>
1686+ <comment>IO-LOG-STDOUT
1687+IO-LOG-STDERR
1688+</comment>
1689+ </question>
1690+ <question name="2013.com.canonical.plainbox::representative/plugin/user-verify">
1691+ <answer type="multiple_choice">pass</answer>
1692+ <answer_choices>
1693+ <value type="str">none</value>
1694+ <value type="str">pass</value>
1695+ <value type="str">fail</value>
1696+ <value type="str">skip</value>
1697+ </answer_choices>
1698+ <comment>COMMENTS</comment>
1699+ </question>
1700+ </questions>
1701+ <software>
1702+ <lsbrelease>
1703+ <property name="codename" type="str">lsb.CODENAME</property>
1704+ <property name="description" type="str">lsb.DESCRIPTION</property>
1705+ <property name="distributor_id" type="str">lsb.DISTRIBUTOR_ID</property>
1706+ <property name="release" type="str">lsb.RELEASE</property>
1707+ </lsbrelease>
1708+ <packages>
1709+ <package id="0" name="package.0.NAME">
1710+ <property name="version" type="str">package.0.VERSION</property>
1711+ </package>
1712+ <package id="1" name="package.1.NAME">
1713+ <property name="version" type="str">package.1.VERSION</property>
1714+ </package>
1715+ </packages>
1716+ <requirements>
1717+ <requirement id=" 0" name="requirement.0.NAME">
1718+ <property name="link" type="str">requirement.0.LINK</property>
1719+ </requirement>
1720+ <requirement id=" 1" name="requirement.1.NAME">
1721+ <property name="link" type="str">requirement.1.LINK</property>
1722+ </requirement>
1723+ </requirements>
1724+ </software>
1725+ <summary>
1726+ <client name="CLIENT_NAME" version="CLIENT_VERSION"/>
1727+ <date_created value="TIMESTAMP"/>
1728+ <architecture value="dpkg.ARCHITECTURE"/>
1729+ <distribution value="lsb.DISTRIBUTOR_ID"/>
1730+ <distroseries value="lsb.RELEASE"/>
1731+ <kernel-release value="uname.RELEASE"/>
1732+ <private value="False"/>
1733+ <contactable value="False"/>
1734+ <live_cd value="False"/>
1735+ <system_id value="SYSTEM_ID"/>
1736+ </summary>
1737+</system>"""
1738+
1739+
1740+_empty_expected = """\
1741+<?xml version="1.0"?>
1742+<system version="1.0">
1743+ <context>
1744+ </context>
1745+ <hardware>
1746+ <!-- the dmi_attachment job is not available, not producing the <dmi> section -->
1747+ <!-- the sysfs_attachment job is not available, not producing the <sysfs-attributes> tag -->
1748+ <!-- the udev_attachment job is not available, not producing the <udev> tag -->
1749+ <!-- cpuinfo resource is not available, not producing the <processors> section -->
1750+ </hardware>
1751+ <questions>
1752+ </questions>
1753+ <software>
1754+ <!-- lsb resource is not available, not producing the <lsbrelease> tag -->
1755+ <!-- package resource is not available, not producing the <packages> tag -->
1756+ <!-- requirements resource is not available, not producing the <requirements> tag -->
1757+ </software>
1758+ <summary>
1759+ <client name="CLIENT_NAME" version="CLIENT_VERSION"/>
1760+ <date_created value="TIMESTAMP"/>
1761+ <!-- dpkg resource is not available, not producing the <architecture> tag -->
1762+ <!-- lsb resource is not available, not producing <distribution> and <distroseries> tags -->
1763+ <!-- uname resource is not available, not producing the <kernel-release> tag -->
1764+ <private value="False"/>
1765+ <contactable value="False"/>
1766+ <live_cd value="False"/>
1767+ <system_id value="SYSTEM_ID"/>
1768+ </summary>
1769+</system>"""
1770+
1771+_escaped_evil_text = '&#34;&#39;&lt;&amp;&gt;'
1772+_evil_expected = """\
1773+<?xml version="1.0"?>
1774+<system version="1.0">
1775+ <context>
1776+ <info command="2013.com.canonical.plainbox::&#34;&#39;&lt;&amp;&gt;-10-user-interact-verify">IO-LOG-STDOUT
1777+</info>
1778+ <info command="2013.com.canonical.plainbox::&#34;&#39;&lt;&amp;&gt;-11-user-verify">IO-LOG-STDOUT
1779+</info>
1780+ <info command="2013.com.canonical.plainbox::&#34;&#39;&lt;&amp;&gt;-3-attachment">IO-LOG-STDOUT
1781+</info>
1782+ <info command="2013.com.canonical.plainbox::&#34;&#39;&lt;&amp;&gt;-4-local"></info>
1783+ <info command="2013.com.canonical.plainbox::&#34;&#39;&lt;&amp;&gt;-5-manual">IO-LOG-STDOUT
1784+</info>
1785+ <info command="2013.com.canonical.plainbox::&#34;&#39;&lt;&amp;&gt;-6-qml">IO-LOG-STDOUT
1786+</info>
1787+ <info command="2013.com.canonical.plainbox::&#34;&#39;&lt;&amp;&gt;-7-resource"></info>
1788+ <info command="2013.com.canonical.plainbox::&#34;&#39;&lt;&amp;&gt;-8-shell">IO-LOG-STDOUT
1789+</info>
1790+ <info command="2013.com.canonical.plainbox::&#34;&#39;&lt;&amp;&gt;-9-user-interact">IO-LOG-STDOUT
1791+</info>
1792+ </context>
1793+ <hardware>
1794+ <dmi>STDOUT-dmi_attachment
1795+</dmi>
1796+ <sysfs-attributes>STDOUT-sysfs_attachment
1797+</sysfs-attributes>
1798+ <udev>STDOUT-udev_attachment
1799+</udev>
1800+ <processors>
1801+ <processor id="0" name="0">
1802+ <property name="count" type="str">2</property>
1803+ <property name="PROP-1" type="str">{evil}</property>
1804+ <property name="PROP-2" type="str">{evil}</property>
1805+ </processor>
1806+ <processor id="1" name="1">
1807+ <property name="count" type="str">2</property>
1808+ <property name="PROP-1" type="str">{evil}</property>
1809+ <property name="PROP-2" type="str">{evil}</property>
1810+ </processor>
1811+ </processors>
1812+ </hardware>
1813+ <questions>
1814+ <question name="2013.com.canonical.plainbox::&#34;&#39;&lt;&amp;&gt;-10-user-interact-verify">
1815+ <answer type="multiple_choice">pass</answer>
1816+ <answer_choices>
1817+ <value type="str">none</value>
1818+ <value type="str">pass</value>
1819+ <value type="str">fail</value>
1820+ <value type="str">skip</value>
1821+ </answer_choices>
1822+ <comment>&#34;&#39;&lt;&amp;&gt;</comment>
1823+ </question>
1824+ <question name="2013.com.canonical.plainbox::&#34;&#39;&lt;&amp;&gt;-11-user-verify">
1825+ <answer type="multiple_choice">pass</answer>
1826+ <answer_choices>
1827+ <value type="str">none</value>
1828+ <value type="str">pass</value>
1829+ <value type="str">fail</value>
1830+ <value type="str">skip</value>
1831+ </answer_choices>
1832+ <comment>&#34;&#39;&lt;&amp;&gt;</comment>
1833+ </question>
1834+ <question name="2013.com.canonical.plainbox::&#34;&#39;&lt;&amp;&gt;-5-manual">
1835+ <answer type="multiple_choice">pass</answer>
1836+ <answer_choices>
1837+ <value type="str">none</value>
1838+ <value type="str">pass</value>
1839+ <value type="str">fail</value>
1840+ <value type="str">skip</value>
1841+ </answer_choices>
1842+ <comment>&#34;&#39;&lt;&amp;&gt;</comment>
1843+ </question>
1844+ <question name="2013.com.canonical.plainbox::&#34;&#39;&lt;&amp;&gt;-6-qml">
1845+ <answer type="multiple_choice">pass</answer>
1846+ <answer_choices>
1847+ <value type="str">none</value>
1848+ <value type="str">pass</value>
1849+ <value type="str">fail</value>
1850+ <value type="str">skip</value>
1851+ </answer_choices>
1852+ <comment>&#34;&#39;&lt;&amp;&gt;</comment>
1853+ </question>
1854+ <question name="2013.com.canonical.plainbox::&#34;&#39;&lt;&amp;&gt;-8-shell">
1855+ <answer type="multiple_choice">pass</answer>
1856+ <answer_choices>
1857+ <value type="str">none</value>
1858+ <value type="str">pass</value>
1859+ <value type="str">fail</value>
1860+ <value type="str">skip</value>
1861+ </answer_choices>
1862+ <comment>&#34;&#39;&lt;&amp;&gt;</comment>
1863+ </question>
1864+ <question name="2013.com.canonical.plainbox::&#34;&#39;&lt;&amp;&gt;-9-user-interact">
1865+ <answer type="multiple_choice">pass</answer>
1866+ <answer_choices>
1867+ <value type="str">none</value>
1868+ <value type="str">pass</value>
1869+ <value type="str">fail</value>
1870+ <value type="str">skip</value>
1871+ </answer_choices>
1872+ <comment>&#34;&#39;&lt;&amp;&gt;</comment>
1873+ </question>
1874+ </questions>
1875+ <software>
1876+ <lsbrelease>
1877+ <property name="codename" type="str">{evil}</property>
1878+ <property name="description" type="str">{evil}</property>
1879+ <property name="distributor_id" type="str">{evil}</property>
1880+ <property name="release" type="str">{evil}</property>
1881+ </lsbrelease>
1882+ <packages>
1883+ <package id="0" name="{evil}">
1884+ <property name="version" type="str">{evil}</property>
1885+ </package>
1886+ <package id="1" name="{evil}">
1887+ <property name="version" type="str">{evil}</property>
1888+ </package>
1889+ </packages>
1890+ <requirements>
1891+ <requirement id=" 0" name="{evil}">
1892+ <property name="link" type="str">{evil}</property>
1893+ </requirement>
1894+ <requirement id=" 1" name="{evil}">
1895+ <property name="link" type="str">{evil}</property>
1896+ </requirement>
1897+ </requirements>
1898+ </software>
1899+ <summary>
1900+ <client name="{evil}" version="{evil}"/>
1901+ <date_created value="{evil}"/>
1902+ <architecture value="{evil}"/>
1903+ <distribution value="{evil}"/>
1904+ <distroseries value="{evil}"/>
1905+ <kernel-release value="{evil}"/>
1906+ <private value="False"/>
1907+ <contactable value="False"/>
1908+ <live_cd value="False"/>
1909+ <system_id value="{evil}"/>
1910+ </summary>
1911+</system>""".format(evil=_escaped_evil_text)
1912
1913=== modified file 'plainbox/plainbox/impl/exporter/text.py'
1914--- plainbox/plainbox/impl/exporter/text.py 2015-03-31 08:38:35 +0000
1915+++ plainbox/plainbox/impl/exporter/text.py 2015-05-14 11:57:31 +0000
1916@@ -25,15 +25,15 @@
1917
1918 THIS MODULE DOES NOT HAVE STABLE PUBLIC API
1919 """
1920+from plainbox.i18n import gettext as _
1921 from plainbox.impl.commands.inv_run import Colorizer
1922 from plainbox.impl.exporter import SessionStateExporterBase
1923 from plainbox.impl.result import outcome_meta
1924
1925
1926 class TextSessionStateExporter(SessionStateExporterBase):
1927- """
1928- Human-readable session state exporter.
1929- """
1930+
1931+ """Human-readable session state exporter."""
1932
1933 def __init__(self, option_list=None, color=None):
1934 super().__init__(option_list)
1935@@ -55,9 +55,23 @@
1936 outcome_meta(state.result.outcome).color_ansi
1937 ), state.job.tr_summary(),
1938 ).encode("UTF-8"))
1939+ if len(state.result_history) > 1:
1940+ stream.write(_(" history: {0}\n").format(
1941+ ', '.join(
1942+ self.C.custom(
1943+ result.outcome_meta().tr_outcome,
1944+ result.outcome_meta().color_ansi)
1945+ for result in state.result_history)
1946+ ).encode("UTF-8"))
1947 else:
1948 stream.write(
1949 "{:^15}: {}\n".format(
1950 state.result.tr_outcome(),
1951 state.job.tr_summary(),
1952 ).encode("UTF-8"))
1953+ if state.result_history:
1954+ print(_("History:"), ', '.join(
1955+ self.C.custom(
1956+ result.outcome_meta().unicode_sigil,
1957+ result.outcome_meta().color_ansi)
1958+ for result in state.result_history))
1959
1960=== modified file 'plainbox/plainbox/impl/pod.py'
1961--- plainbox/plainbox/impl/pod.py 2015-04-16 20:49:41 +0000
1962+++ plainbox/plainbox/impl/pod.py 2015-05-14 11:57:31 +0000
1963@@ -1,3 +1,4 @@
1964+# encoding: utf-8
1965 # This file is part of Checkbox.
1966 #
1967 # Copyright 2012-2015 Canonical Ltd.
1968@@ -75,7 +76,7 @@
1969
1970 class _Singleton:
1971
1972- """ A simple object()-like singleton that has a more useful repr(). """
1973+ """A simple object()-like singleton that has a more useful repr()."""
1974
1975 def __repr__(self):
1976 return self.__class__.__name__
1977@@ -202,7 +203,7 @@
1978
1979 def __init__(self, doc=None, type=None, initial=None, initial_fn=None,
1980 notify=False, notify_fn=None, assign_filter_list=None):
1981- """ Initialize (define) a new POD field. """
1982+ """Initialize (define) a new POD field."""
1983 self.__doc__ = dedent(doc) if doc is not None else None
1984 self.type = type
1985 self.initial = initial
1986@@ -224,13 +225,47 @@
1987 self.counter = self.__class__._counter
1988 self.__class__._counter += 1
1989
1990+ @property
1991+ def change_notifier(self):
1992+ """
1993+ Decorator for changing the change notification function.
1994+
1995+ This decorator can be used to define all the fields in one block and
1996+ all the notification function in another block. It helps to make the
1997+ code easier to read.
1998+
1999+ Example::
2000+
2001+ >>> class Person(POD):
2002+ ... name = Field()
2003+ ...
2004+ ... @name.change_notifier
2005+ ... def _name_changed(self, old, new):
2006+ ... print("changed from {!r} to {!r}".format(old, new))
2007+ >>> person = Person()
2008+ changed from UNSET to None
2009+ >>> person.name = "bob"
2010+ changed from None to 'bob'
2011+
2012+ .. note::
2013+ Keep in mind that the decorated function is converted to a signal
2014+ automatically. The name of the function is also irrelevant, the POD
2015+ core automatically creates signals that have consistent names of
2016+ ``on_{field}_changed()``.
2017+ """
2018+ def decorator(fn):
2019+ self.notify = True
2020+ self.notify_fn = fn
2021+ return fn
2022+ return decorator
2023+
2024 def __repr__(self):
2025- """ Get a debugging representation of a field. """
2026+ """Get a debugging representation of a field."""
2027 return "<{} name:{!r}>".format(self.__class__.__name__, self.name)
2028
2029 @property
2030 def is_mandatory(self) -> bool:
2031- """ Flag indicating if the field needs a mandatory initializer. """
2032+ """Flag indicating if the field needs a mandatory initializer."""
2033 return self.initial is MANDATORY
2034
2035 def gain_name(self, name: str) -> None:
2036@@ -328,7 +363,7 @@
2037 @total_ordering
2038 class PODBase:
2039
2040- """ Base class for POD-like classes. """
2041+ """Base class for POD-like classes."""
2042
2043 field_list = []
2044 namedtuple_cls = namedtuple('PODBase', '')
2045@@ -386,7 +421,7 @@
2046 setattr(self, field.name, field_value)
2047
2048 def __repr__(self):
2049- """ Get a debugging representation of a POD object. """
2050+ """Get a debugging representation of a POD object."""
2051 return "{}({})".format(
2052 self.__class__.__name__,
2053 ', '.join([
2054@@ -428,10 +463,16 @@
2055 ])
2056
2057 def as_dict(self) -> dict:
2058- """ Return the data in this POD as a dictionary. """
2059+ """
2060+ Return the data in this POD as a dictionary.
2061+
2062+ .. note::
2063+ UNSET values are not added to the dictionary.
2064+ """
2065 return {
2066 field.name: getattr(self, field.name)
2067 for field in self.__class__.field_list
2068+ if getattr(self, field.name) is not UNSET
2069 }
2070
2071
2072@@ -457,7 +498,7 @@
2073 self.field_origin_map = {} # field name -> defining class name
2074
2075 def inspect_cls_for_decorator(self, cls: type) -> None:
2076- """ Analyze a bare POD class. """
2077+ """Analyze a bare POD class."""
2078 self.inspect_base_classes(cls.__bases__)
2079 self.inspect_namespace(cls.__dict__, cls.__name__)
2080
2081@@ -727,6 +768,39 @@
2082 typed = type_check_assign_filter
2083
2084
2085+@modify_field_docstring(
2086+ "unset or type-checked (value must be of type {field.type.__name__})")
2087+def unset_or_type_check_assign_filter(
2088+ instance: POD, field: Field, old: "Any", new: "Any") -> "Any":
2089+ """
2090+ An assign filter that type-checks the value according to the field type.
2091+
2092+ .. note::
2093+ This filter allows (passes through) the special ``UNSET`` value as-is.
2094+
2095+ The field must have a valid python type object stored in the .type field.
2096+
2097+ :param instance:
2098+ A subclass of :class:`POD` that contains ``field``
2099+ :param field:
2100+ The :class:`Field` being assigned to
2101+ :param old:
2102+ The current value of the field
2103+ :param new:
2104+ The proposed value of the field
2105+ :returns:
2106+ ``new``, as-is
2107+ :raises TypeError:
2108+ if ``new`` is not an instance of ``field.type``
2109+ """
2110+ if new is UNSET:
2111+ return new
2112+ return type_check_assign_filter(instance, field, old, new)
2113+
2114+
2115+unset_or_typed = unset_or_type_check_assign_filter
2116+
2117+
2118 class sequence_type_check_assign_filter:
2119
2120 """
2121@@ -781,6 +855,55 @@
2122 typed.sequence = sequence_type_check_assign_filter
2123
2124
2125+class unset_or_sequence_type_check_assign_filter(typed.sequence):
2126+
2127+ """
2128+ Assign filter for typed sequences.
2129+
2130+ .. note::
2131+ This filter allows (passes through) the special ``UNSET`` value as-is.
2132+
2133+ An assign filter for typed sequences (lists or tuples) that must contain an
2134+ object of the given type.
2135+ """
2136+
2137+ @property
2138+ def field_docstring_ext(self) -> str:
2139+ return (
2140+ "unset or type-checked sequence (items must be of type {})"
2141+ ).format(self.item_type.__name__)
2142+
2143+ def __call__(
2144+ self, instance: POD, field: Field, old: "Any", new: "Any"
2145+ ) -> "Any":
2146+ """
2147+ An assign filter that type-checks the value of all sequence elements.
2148+
2149+ .. note::
2150+ This filter allows (passes through) the special ``UNSET`` value
2151+ as-is.
2152+
2153+ :param instance:
2154+ A subclass of :class:`POD` that contains ``field``
2155+ :param field:
2156+ The :class:`Field` being assigned to
2157+ :param old:
2158+ The current value of the field
2159+ :param new:
2160+ The proposed value of the field
2161+ :returns:
2162+ ``new``, as-is
2163+ :raises TypeError:
2164+ if ``new`` is not an instance of ``field.type``
2165+ """
2166+ if new is UNSET:
2167+ return new
2168+ return super().__call__(instance, field, old, new)
2169+
2170+
2171+unset_or_typed.sequence = unset_or_sequence_type_check_assign_filter
2172+
2173+
2174 @modify_field_docstring("unique elements (sequence elements cannot repeat)")
2175 def unique_elements_assign_filter(
2176 instance: POD, field: Field, old: "Any", new: "Any") -> "Any":
2177
2178=== modified file 'plainbox/plainbox/impl/providers/special.py'
2179--- plainbox/plainbox/impl/providers/special.py 2015-03-30 17:36:35 +0000
2180+++ plainbox/plainbox/impl/providers/special.py 2015-05-14 11:57:31 +0000
2181@@ -52,8 +52,8 @@
2182 return stubbox_def
2183
2184
2185-def get_stubbox():
2186- return Provider1.from_definition(get_stubbox_def(), secure=False)
2187+def get_stubbox(**kwargs):
2188+ return Provider1.from_definition(get_stubbox_def(), secure=False, **kwargs)
2189
2190
2191 def get_categories_def():
2192
2193=== added file 'plainbox/plainbox/impl/providers/stubbox/units/jobs/representative.pxu'
2194--- plainbox/plainbox/impl/providers/stubbox/units/jobs/representative.pxu 1970-01-01 00:00:00 +0000
2195+++ plainbox/plainbox/impl/providers/stubbox/units/jobs/representative.pxu 2015-05-14 11:57:31 +0000
2196@@ -0,0 +1,98 @@
2197+# Definitions of jobs that are useful for testing. Whenever you need a scenario
2198+# for testing that involves realistic jobs and you don't want to painstakingly
2199+# define them manually just load stubbox and get all jobs matching the pattern
2200+# 'representative/plugin/.*'
2201+
2202+# NOTE:: all of the jobs below can be simplified to a template once static
2203+# resources are available.
2204+
2205+id: representative/plugin/shell
2206+_summary: Job with plugin=shell
2207+_description: Job with plugin=shell
2208+plugin: shell
2209+flags: preserve-locale
2210+command: true;
2211+estimated_duration: 0.1
2212+category_id: plugin-representative
2213+
2214+id: representative/plugin/resource
2215+_summary: Job with plugin=resource
2216+_description: Job with plugin=resource
2217+plugin: resource
2218+flags: preserve-locale
2219+command:
2220+ echo "key_a: value-a-1"
2221+ echo "key_b: value-b-1"
2222+ echo
2223+ echo "key_a: value-a-2"
2224+ echo "key_b: value-b-2"
2225+estimated_duration: 0.1
2226+category_id: plugin-representative
2227+
2228+id: representative/plugin/local
2229+_summary: Job with plugin=local
2230+_description: Job with plugin=local
2231+plugin: local
2232+flags: preserve-locale
2233+command: :
2234+estimated_duration: 0.1
2235+category_id: plugin-representative
2236+
2237+id: representative/plugin/attachment
2238+_summary: Job with plugin=attachment
2239+_description: Job with plugin=attachment
2240+plugin: attachment
2241+flags: preserve-locale
2242+command:
2243+ echo "Line 1"
2244+ echo "Line 2"
2245+ echo "Line 3 (last)"
2246+estimated_duration: 0.1
2247+category_id: plugin-representative
2248+
2249+id: representative/plugin/user-interact
2250+_summary: Job with plugin=user-interact
2251+_description: Job with plugin=user-interact
2252+plugin: user-interact
2253+flags: preserve-locale
2254+command:
2255+ echo "(interaction)"
2256+estimated_duration: 30
2257+category_id: plugin-representative
2258+
2259+id: representative/plugin/user-verify
2260+_summary: Job with plugin=user-verify
2261+_description: Job with plugin=user-verify
2262+plugin: user-verify
2263+flags: preserve-locale
2264+command:
2265+ echo "(verification)"
2266+estimated_duration: 30
2267+category_id: plugin-representative
2268+
2269+id: representative/plugin/user-interact-verify
2270+_summary: Job with plugin=user-interact-verify
2271+_description: Job with plugin=user-interact-verify
2272+plugin: user-interact-verify
2273+flags: preserve-locale
2274+command:
2275+ echo "(interaction)"
2276+ echo "(verification)"
2277+estimated_duration: 30
2278+category_id: plugin-representative
2279+
2280+id: representative/plugin/manual
2281+_summary: Job with plugin=manual
2282+_description: Job with plugin=manual
2283+plugin: manual
2284+estimated_duration: 1
2285+category_id: plugin-representative
2286+
2287+id: representative/plugin/qml
2288+_summary: Job with plugin=qml
2289+_description: Job with plugin=qml
2290+plugin: qml
2291+qml_file: qml-simple.qml
2292+flags: preserve-locale
2293+estimated_duration: 10
2294+category_id: plugin-representative
2295
2296=== modified file 'plainbox/plainbox/impl/resource.py'
2297--- plainbox/plainbox/impl/resource.py 2014-09-13 11:37:17 +0000
2298+++ plainbox/plainbox/impl/resource.py 2015-05-14 11:57:31 +0000
2299@@ -88,6 +88,10 @@
2300 data = {}
2301 object.__setattr__(self, '_data', data)
2302
2303+ def __iter__(self):
2304+ data = object.__getattribute__(self, '_data')
2305+ return iter(data)
2306+
2307 def __setattr__(self, attr, value):
2308 if attr.startswith("_"):
2309 raise AttributeError(attr)
2310
2311=== modified file 'plainbox/plainbox/impl/result.py'
2312--- plainbox/plainbox/impl/result.py 2015-04-16 20:49:41 +0000
2313+++ plainbox/plainbox/impl/result.py 2015-05-14 11:57:31 +0000
2314@@ -1,13 +1,13 @@
2315+# encoding: utf-8
2316 # This file is part of Checkbox.
2317 #
2318-# Copyright 2012 Canonical Ltd.
2319+# Copyright 2012-2015 Canonical Ltd.
2320 # Written by:
2321 # Zygmunt Krynicki <zygmunt.krynicki@canonical.com>
2322 #
2323 # Checkbox is free software: you can redistribute it and/or modify
2324 # it under the terms of the GNU General Public License version 3,
2325 # as published by the Free Software Foundation.
2326-
2327 #
2328 # Checkbox is distributed in the hope that it will be useful,
2329 # but WITHOUT ANY WARRANTY; without even the implied warranty of
2330@@ -18,6 +18,8 @@
2331 # along with Checkbox. If not, see <http://www.gnu.org/licenses/>.
2332
2333 """
2334+Implementation of job result (test result) classes.
2335+
2336 :mod:`plainbox.impl.result` -- job result
2337 =========================================
2338
2339@@ -25,19 +27,21 @@
2340 :class:`MemoryJobResult` and :class:`DiskJobResult`.
2341 """
2342
2343-from collections import namedtuple
2344 import base64
2345 import codecs
2346 import gzip
2347+import inspect
2348 import io
2349 import json
2350 import logging
2351-import inspect
2352 import re
2353+from collections import namedtuple
2354
2355 from plainbox.abc import IJobResult
2356 from plainbox.i18n import gettext as _
2357 from plainbox.i18n import pgettext as C_
2358+from plainbox.impl import pod
2359+from plainbox.impl.decorators import raises
2360 from plainbox.vendor import morris
2361
2362 logger = logging.getLogger("plainbox.result")
2363@@ -209,34 +213,82 @@
2364
2365
2366 def tr_outcome(outcome):
2367- """
2368- Get the translated value of ``OUTCOME_`` constant
2369- """
2370+ """Get the translated value of ``OUTCOME_`` constant."""
2371 return OUTCOME_METADATA_MAP[outcome].tr_outcome
2372
2373
2374 def outcome_color_hex(outcome):
2375- """
2376- Get the hexadecimal "#RRGGBB" color that represents this outcome
2377- """
2378+ """Get the hexadecimal "#RRGGBB" color that represents this outcome."""
2379 return OUTCOME_METADATA_MAP[outcome].color_hex
2380
2381
2382 def outcome_color_ansi(outcome):
2383- """
2384- Get an ANSI escape sequence that represents this outcome
2385- """
2386+ """Get an ANSI escape sequence that represents this outcome."""
2387 return OUTCOME_METADATA_MAP[outcome].color_ansi
2388
2389
2390 def outcome_meta(outcome):
2391- """
2392- Get the OutcomeMetadata object associated with this outcome.
2393- """
2394+ """Get the OutcomeMetadata object associated with this outcome."""
2395 return OUTCOME_METADATA_MAP[outcome]
2396
2397
2398+class JobResultBuilder(pod.POD):
2399+
2400+ """A builder for job result objects."""
2401+
2402+ outcome = pod.Field(
2403+ 'outcome of a test',
2404+ str, pod.UNSET, assign_filter_list=[pod.unset_or_typed])
2405+ execution_duration = pod.Field(
2406+ 'time of test execution',
2407+ float, pod.UNSET, assign_filter_list=[pod.unset_or_typed])
2408+ comments = pod.Field(
2409+ 'comments from the test operator',
2410+ str, pod.UNSET, assign_filter_list=[pod.unset_or_typed])
2411+ return_code = pod.Field(
2412+ 'return code from the (optional) test process',
2413+ int, pod.UNSET, assign_filter_list=[pod.unset_or_typed])
2414+ io_log = pod.Field(
2415+ 'history of the I/O log of the (optional) test process',
2416+ list, pod.UNSET, assign_filter_list=[
2417+ pod.unset_or_typed, pod.unset_or_typed.sequence(tuple)])
2418+ io_log_filename = pod.Field(
2419+ 'path to a structured I/O log file of the (optional) test process',
2420+ str, pod.UNSET, assign_filter_list=[pod.unset_or_typed])
2421+
2422+ def add_comment(self, comment):
2423+ """
2424+ Add a new comment.
2425+
2426+ The comment is safely combined with any prior comments.
2427+ """
2428+ if self.comments is pod.UNSET:
2429+ self.comments = comments
2430+ else:
2431+ self.comments += '\n' + comment
2432+
2433+ @raises(ValueError)
2434+ def seal(self):
2435+ """
2436+ Seal the current state of the builder and create a new result.
2437+
2438+ :returns:
2439+ A new MemoryJobResult or DiskJobResult with all the data
2440+ :raises ValueError:
2441+ If both io_log and io_log_filename were used.
2442+ """
2443+ if not (self.io_log_filename is pod.UNSET or self.io_log is pod.UNSET):
2444+ raise ValueError(
2445+ "you can use only io_log or io_log_filename at a time")
2446+ if self.io_log_filename is not pod.UNSET:
2447+ cls = DiskJobResult
2448+ else:
2449+ cls = MemoryJobResult
2450+ return cls(self.as_dict())
2451+
2452+
2453 class _JobResultBase(IJobResult):
2454+
2455 """
2456 Base class for :`IJobResult` implementations.
2457
2458@@ -245,7 +297,7 @@
2459
2460 def __init__(self, data):
2461 """
2462- Initialize a new result with the specified data
2463+ Initialize a new result with the specified data.
2464
2465 Data is a dictionary that can hold arbitrary values. At least some
2466 values are explicitly used, such as 'outcome', 'comments' and
2467@@ -259,6 +311,10 @@
2468 key: value for key, value in data.items()
2469 if value is not None and value != []}
2470
2471+ def unseal(self):
2472+ """Create a new job result builder from the data in this result."""
2473+ return JobResultBuilder(**self._data)
2474+
2475 def __eq__(self, other):
2476 if not isinstance(other, _JobResultBase):
2477 return NotImplemented
2478@@ -275,9 +331,7 @@
2479
2480 @morris.signal
2481 def on_outcome_changed(self, old, new):
2482- """
2483- Signal sent when ``outcome`` property value is changed
2484- """
2485+ """Signal sent when ``outcome`` property value is changed."""
2486
2487 @property
2488 def outcome(self):
2489@@ -292,55 +346,46 @@
2490
2491 @outcome.setter
2492 def outcome(self, new):
2493+ logger.warning("writing to result.outcome is deprecated!")
2494 old = self.outcome
2495 if old != new:
2496 self._data['outcome'] = new
2497 self.on_outcome_changed(old, new)
2498
2499 def tr_outcome(self):
2500- """
2501- Get the translated value of the outcome
2502- """
2503+ """Get the translated value of the outcome."""
2504 return tr_outcome(self.outcome)
2505
2506 def outcome_color_hex(self):
2507- """
2508- Get the hexadecimal "#RRGGBB" color that represents this outcome
2509- """
2510+ """Get the hexadecimal "#RRGGBB" color that represents this outcome."""
2511 return outcome_color_hex(self.outcome)
2512
2513 def outcome_color_ansi(self):
2514- """
2515- Get an ANSI escape sequence that represents this outcome
2516- """
2517+ """Get an ANSI escape sequence that represents this outcome."""
2518 return outcome_color_ansi(self.outcome)
2519
2520 def outcome_meta(self):
2521- """
2522- Get the OutcomeMetadata object associated with this outcome.
2523- """
2524+ """Get the OutcomeMetadata object associated with this outcome."""
2525 return outcome_meta(self.outcome)
2526
2527 @property
2528 def execution_duration(self):
2529- """
2530- The amount of time in seconds it took to run this job.
2531- """
2532+ """The amount of time in seconds it took to run this job."""
2533 return self._data.get('execution_duration')
2534
2535 @execution_duration.setter
2536 def execution_duration(self, elapsed):
2537+ logger.warning("writing to result.execution_duration is deprecated!")
2538 self._data['execution_duration'] = elapsed
2539
2540 @property
2541 def comments(self):
2542- """
2543- comments of the test operator
2544- """
2545+ """Get the comments of the test operator."""
2546 return self._data.get('comments')
2547
2548 @comments.setter
2549 def comments(self, new):
2550+ logger.warning("writing to result.comments is deprecated!")
2551 old = self.comments
2552 if old != new:
2553 self._data['comments'] = new
2554@@ -348,7 +393,7 @@
2555
2556 def append_comments(self, comments):
2557 """
2558- Append new comments to the result
2559+ Append new comments to the result.
2560
2561 :param comments:
2562 The comments to append
2563@@ -365,15 +410,11 @@
2564
2565 @morris.signal
2566 def on_comments_changed(self, old, new):
2567- """
2568- Signal sent when ``comments`` property value is changed
2569- """
2570+ """Signal sent when ``comments`` property value is changed."""
2571
2572 @property
2573 def return_code(self):
2574- """
2575- return code of the command associated with the job, if any
2576- """
2577+ """return code of the command associated with the job, if any."""
2578 return self._data.get('return_code')
2579
2580 @property
2581@@ -383,6 +424,8 @@
2582 @property
2583 def io_log_as_flat_text(self):
2584 """
2585+ Perform a lossly conversion from the binary I/O log to text.
2586+
2587 Convert the I/O log to a text string, replacing non unicode characters
2588 with U+FFFD, the REPLACEMENT CHARACTER.
2589
2590@@ -413,6 +456,8 @@
2591 @property
2592 def io_log_as_text_attachment(self):
2593 """
2594+ Perform a conversion of the binary I/O log to text, if possible.
2595+
2596 Convert the I/O log to text attachment, if possible, otherwise return
2597 an empty string.
2598
2599@@ -437,7 +482,7 @@
2600 @property
2601 def is_hollow(self):
2602 """
2603- flag that indicates if the result is hollow
2604+ flag that indicates if the result is hollow.
2605
2606 Hollow results may have been created but hold no data at all.
2607 Hollow results are also tentatively deprecated, once we have some
2608@@ -451,6 +496,7 @@
2609
2610
2611 class MemoryJobResult(_JobResultBase):
2612+
2613 """
2614 A :class:`IJobResult` that keeps IO logs in memory.
2615
2616@@ -472,8 +518,9 @@
2617
2618
2619 class GzipFile(gzip.GzipFile):
2620+
2621 """
2622- Subclass of GzipFile that works around missing read1() on python3.2
2623+ Subclass of GzipFile that works around missing read1() on python3.2.
2624
2625 See: http://bugs.python.org/issue10791
2626 """
2627@@ -483,6 +530,7 @@
2628
2629
2630 class DiskJobResult(_JobResultBase):
2631+
2632 """
2633 A :class:`IJobResult` that keeps IO logs on disk.
2634
2635@@ -495,9 +543,7 @@
2636
2637 @property
2638 def io_log_filename(self):
2639- """
2640- pathname of the file containing serialized IO log records
2641- """
2642+ """pathname of the file containing serialized IO log records."""
2643 return self._data.get("io_log_filename")
2644
2645 def get_io_log(self):
2646@@ -519,9 +565,8 @@
2647
2648
2649 class IOLogRecordWriter:
2650- """
2651- Class for writing :class:`IOLogRecord` instances to a text stream
2652- """
2653+
2654+ """Class for writing :class:`IOLogRecord` instances to a text stream."""
2655
2656 def __init__(self, stream):
2657 self.stream = stream
2658@@ -530,9 +575,7 @@
2659 self.stream.close()
2660
2661 def write_record(self, record):
2662- """
2663- Write an :class:`IOLogRecord` to the stream.
2664- """
2665+ """Write an :class:`IOLogRecord` to the stream."""
2666 text = json.dumps([
2667 record[0], record[1],
2668 base64.standard_b64encode(record[2]).decode("ASCII")],
2669@@ -545,9 +588,8 @@
2670
2671
2672 class IOLogRecordReader:
2673- """
2674- Class for streaming :class`IOLogRecord` instances from a text stream
2675- """
2676+
2677+ """Class for streaming :class`IOLogRecord` instances from a text stream."""
2678
2679 def __init__(self, stream):
2680 self.stream = stream
2681@@ -572,8 +614,9 @@
2682
2683 def __iter__(self):
2684 """
2685- Iterate over the entire stream generating subsequent
2686- :class:`IOLogRecord` entries.
2687+ Iterate over the entire stream generating subsequent records.
2688+
2689+ This method generates subsequent :class:`IOLogRecord` entries.
2690 """
2691 while True:
2692 record = self.read_record()
2693
2694=== modified file 'plainbox/plainbox/impl/runner.py'
2695--- plainbox/plainbox/impl/runner.py 2015-04-16 20:49:41 +0000
2696+++ plainbox/plainbox/impl/runner.py 2015-05-14 11:57:31 +0000
2697@@ -1,13 +1,13 @@
2698+# encoding: utf-8
2699 # This file is part of Checkbox.
2700 #
2701-# Copyright 2012, 2013 Canonical Ltd.
2702+# Copyright 2012-2015 Canonical Ltd.
2703 # Written by:
2704 # Zygmunt Krynicki <zygmunt.krynicki@canonical.com>
2705 #
2706 # Checkbox is free software: you can redistribute it and/or modify
2707 # it under the terms of the GNU General Public License version 3,
2708 # as published by the Free Software Foundation.
2709-
2710 #
2711 # Checkbox is distributed in the hope that it will be useful,
2712 # but WITHOUT ANY WARRANTY; without even the implied warranty of
2713@@ -18,6 +18,8 @@
2714 # along with Checkbox. If not, see <http://www.gnu.org/licenses/>.
2715
2716 """
2717+Definition of JobRunner class.
2718+
2719 :mod:`plainbox.impl.runner` -- job runner
2720 =========================================
2721
2722@@ -36,14 +38,12 @@
2723 import sys
2724 import time
2725
2726-from plainbox.vendor import extcmd
2727-
2728-from plainbox.abc import IJobRunner, IJobResult
2729+from plainbox.abc import IJobResult, IJobRunner
2730 from plainbox.i18n import gettext as _
2731-from plainbox.impl.result import DiskJobResult
2732 from plainbox.impl.result import IOLogRecord
2733 from plainbox.impl.result import IOLogRecordWriter
2734-from plainbox.impl.result import MemoryJobResult
2735+from plainbox.impl.result import JobResultBuilder
2736+from plainbox.vendor import extcmd
2737 from plainbox.vendor import morris
2738
2739
2740@@ -51,23 +51,19 @@
2741
2742
2743 def slugify(_string):
2744- """
2745- Slugify - like Django does for URL - transform a random string to a valid
2746- slug that can be later used in filenames
2747- """
2748+ """Transform any string to onet that can be used in filenames."""
2749 valid_chars = frozenset(
2750 "-_.{}{}".format(string.ascii_letters, string.digits))
2751 return ''.join(c if c in valid_chars else '_' for c in _string)
2752
2753
2754 class IOLogRecordGenerator(extcmd.DelegateBase):
2755- """
2756- Delegate for extcmd that generates io_log entries.
2757- """
2758+
2759+ """Delegate for extcmd that generates io_log entries."""
2760
2761 def on_begin(self, args, kwargs):
2762 """
2763- Internal method of extcmd.DelegateBase
2764+ Internal method of extcmd.DelegateBase.
2765
2766 Called when a command is being invoked.
2767
2768@@ -77,7 +73,7 @@
2769
2770 def on_line(self, stream_name, line):
2771 """
2772- Internal method of extcmd.DelegateBase
2773+ Internal method of extcmd.DelegateBase.
2774
2775 Creates a new IOLogRecord and passes it to :meth:`on_new_record()`.
2776 Maintains a timestamp of the last message so that approximate delay
2777@@ -92,7 +88,7 @@
2778 @morris.signal
2779 def on_new_record(self, record):
2780 """
2781- Internal signal method of :class:`IOLogRecordGenerator`
2782+ Internal signal method of :class:`IOLogRecordGenerator`.
2783
2784 Called when a new record is generated and needs to be processed.
2785 """
2786@@ -101,6 +97,7 @@
2787
2788
2789 class CommandOutputWriter(extcmd.DelegateBase):
2790+
2791 """
2792 Delegate for extcmd that writes output to a file on disk.
2793
2794@@ -120,7 +117,7 @@
2795
2796 def on_begin(self, args, kwargs):
2797 """
2798- Internal method of extcmd.DelegateBase
2799+ Internal method of extcmd.DelegateBase.
2800
2801 Called when a command is being invoked
2802 """
2803@@ -129,7 +126,7 @@
2804
2805 def on_end(self, returncode):
2806 """
2807- Internal method of extcmd.DelegateBase
2808+ Internal method of extcmd.DelegateBase.
2809
2810 Called when a command finishes running
2811 """
2812@@ -138,7 +135,7 @@
2813
2814 def on_abnormal_end(self, signal_num):
2815 """
2816- Internal method of extcmd.DelegateBase
2817+ Internal method of extcmd.DelegateBase.
2818
2819 Called when a command abnormally finishes running
2820 """
2821@@ -147,7 +144,7 @@
2822
2823 def on_line(self, stream_name, line):
2824 """
2825- Internal method of extcmd.DelegateBase
2826+ Internal method of extcmd.DelegateBase.
2827
2828 Called for each line of output.
2829 """
2830@@ -158,6 +155,7 @@
2831
2832
2833 class FallbackCommandOutputPrinter(extcmd.DelegateBase):
2834+
2835 """
2836 Delegate for extcmd that prints all output to stdout.
2837
2838@@ -166,11 +164,19 @@
2839 """
2840
2841 def __init__(self, prompt):
2842+ """Initialize a new fallback command output printer."""
2843 self._prompt = prompt
2844 self._lineno = collections.defaultdict(int)
2845 self._abort = False
2846
2847 def on_line(self, stream_name, line):
2848+ """
2849+ Internal method of extcmd.DelegateBase.
2850+
2851+ Called for each line of output. Normally each line is just printed
2852+ (assuming UTF-8 encoding) If decoding fails for any reason that and all
2853+ subsequent lines are ignored.
2854+ """
2855 if self._abort:
2856 return
2857 self._lineno[stream_name] += 1
2858@@ -183,8 +189,9 @@
2859
2860
2861 class JobRunnerUIDelegate(extcmd.DelegateBase):
2862+
2863 """
2864- Delegate for extcmd that delegates extcmd events to IJobRunnerUI
2865+ Delegate for extcmd that delegates extcmd events to IJobRunnerUI.
2866
2867 The file itself is only opened once on_begin() gets called by extcmd. This
2868 makes it safe to instantiate this without worrying about dangling
2869@@ -197,7 +204,7 @@
2870
2871 def __init__(self, ui=None):
2872 """
2873- Initialize the JobRunnerUIDelegate
2874+ Initialize the JobRunnerUIDelegate.
2875
2876 :param ui:
2877 (optional) an instnace of IJobRunnerUI to delegate events to
2878@@ -206,7 +213,7 @@
2879
2880 def on_begin(self, args, kwargs):
2881 """
2882- Internal method of extcmd.DelegateBase
2883+ Internal method of extcmd.DelegateBase.
2884
2885 Called when a command is being invoked
2886 """
2887@@ -215,7 +222,7 @@
2888
2889 def on_end(self, returncode):
2890 """
2891- Internal method of extcmd.DelegateBase
2892+ Internal method of extcmd.DelegateBase.
2893
2894 Called when a command finishes running
2895 """
2896@@ -224,7 +231,7 @@
2897
2898 def on_abnormal_end(self, signal_num):
2899 """
2900- Internal method of extcmd.DelegateBase
2901+ Internal method of extcmd.DelegateBase.
2902
2903 Called when a command abnormally finishes running
2904
2905@@ -236,7 +243,7 @@
2906
2907 def on_line(self, stream_name, line):
2908 """
2909- Internal method of extcmd.DelegateBase
2910+ Internal method of extcmd.DelegateBase.
2911
2912 Called for each line of output.
2913 """
2914@@ -245,7 +252,7 @@
2915
2916 def on_chunk(self, stream_name, chunk):
2917 """
2918- Internal method of extcmd.DelegateBase
2919+ Internal method of extcmd.DelegateBase.
2920
2921 Called for each chunk of output.
2922 """
2923@@ -254,8 +261,9 @@
2924
2925
2926 class JobRunner(IJobRunner):
2927+
2928 """
2929- Runner for jobs - executes jobs and produces results
2930+ Runner for jobs - executes jobs and produces results.
2931
2932 The runner is somewhat de-coupled from jobs and session. It still carries
2933 all checkbox-specific logic about the various types of plugins.
2934@@ -333,7 +341,7 @@
2935 @property
2936 def log_leftovers(self):
2937 """
2938- flag controlling if leftover files should be logged
2939+ flag controlling if leftover files should be logged.
2940
2941 If you wish to connect a custom handler to :meth:`on_leftover_files()`
2942 then it is advisable to set this property to False so that leftover
2943@@ -345,6 +353,7 @@
2944
2945 @log_leftovers.setter
2946 def log_leftovers(self, value):
2947+ """setter for log_leftovers property."""
2948 self._log_leftovers = value
2949
2950 def get_warm_up_sequence(self, job_list):
2951@@ -375,7 +384,7 @@
2952
2953 def run_job(self, job, job_state, config=None, ui=None):
2954 """
2955- Run the specified job an return the result
2956+ Run the specified job an return the result.
2957
2958 :param job:
2959 A JobDefinition to run
2960@@ -412,10 +421,10 @@
2961 try:
2962 runner = getattr(self, func_name)
2963 except AttributeError:
2964- return MemoryJobResult({
2965- 'outcome': IJobResult.OUTCOME_NOT_IMPLEMENTED,
2966- 'comment': _('This type of job is not supported'),
2967- })
2968+ return JobResultBuilder(
2969+ outcome=IJobResult.OUTCOME_NOT_IMPLEMENTED,
2970+ comments=_('This type of job is not supported')
2971+ ).seal()
2972 else:
2973 if self._dry_run and job.plugin not in self._DRY_RUN_PLUGINS:
2974 return self._get_dry_run_result(job)
2975@@ -428,7 +437,7 @@
2976
2977 def run_shell_job(self, job, job_state, config):
2978 """
2979- Method called to run a job with plugin field equal to 'shell'
2980+ Method called to run a job with plugin field equal to 'shell'.
2981
2982 The 'shell' job implements the following scenario:
2983
2984@@ -448,11 +457,11 @@
2985 if job.plugin != "shell":
2986 # TRANSLATORS: please keep 'plugin' untranslated
2987 raise ValueError(_("bad job plugin value"))
2988- return self._just_run_command(job, job_state, config)
2989+ return self._just_run_command(job, job_state, config).seal()
2990
2991 def run_attachment_job(self, job, job_state, config):
2992 """
2993- Method called to run a job with plugin field equal to 'attachment'
2994+ Method called to run a job with plugin field equal to 'attachment'.
2995
2996 The 'attachment' job implements the following scenario:
2997
2998@@ -473,11 +482,11 @@
2999 if job.plugin != "attachment":
3000 # TRANSLATORS: please keep 'plugin' untranslated
3001 raise ValueError(_("bad job plugin value"))
3002- return self._just_run_command(job, job_state, config)
3003+ return self._just_run_command(job, job_state, config).seal()
3004
3005 def run_resource_job(self, job, job_state, config):
3006 """
3007- Method called to run a job with plugin field equal to 'resource'
3008+ Method called to run a job with plugin field equal to 'resource'.
3009
3010 The 'resource' job implements the following scenario:
3011
3012@@ -499,11 +508,11 @@
3013 if job.plugin != "resource":
3014 # TRANSLATORS: please keep 'plugin' untranslated
3015 raise ValueError(_("bad job plugin value"))
3016- return self._just_run_command(job, job_state, config)
3017+ return self._just_run_command(job, job_state, config).seal()
3018
3019 def run_local_job(self, job, job_state, config):
3020 """
3021- Method called to run a job with plugin field equal to 'local'
3022+ Method called to run a job with plugin field equal to 'local'.
3023
3024 The 'local' job implements the following scenario:
3025
3026@@ -525,11 +534,11 @@
3027 if job.plugin != "local":
3028 # TRANSLATORS: please keep 'plugin' untranslated
3029 raise ValueError(_("bad job plugin value"))
3030- return self._just_run_command(job, job_state, config)
3031+ return self._just_run_command(job, job_state, config).seal()
3032
3033 def run_manual_job(self, job, job_state, config):
3034 """
3035- Method called to run a job with plugin field equal to 'manual'
3036+ Method called to run a job with plugin field equal to 'manual'.
3037
3038 The 'manual' job implements the following scenario:
3039
3040@@ -551,11 +560,11 @@
3041 if job.plugin != "manual":
3042 # TRANSLATORS: please keep 'plugin' untranslated
3043 raise ValueError(_("bad job plugin value"))
3044- return MemoryJobResult({'outcome': IJobResult.OUTCOME_UNDECIDED})
3045+ return JobResultBuilder(outcome=IJobResult.OUTCOME_UNDECIDED).seal()
3046
3047 def run_user_interact_job(self, job, job_state, config):
3048 """
3049- Method called to run a job with plugin field equal to 'user-interact'
3050+ Method called to run a job with plugin field equal to 'user-interact'.
3051
3052 The 'user-interact' job implements the following scenario:
3053
3054@@ -592,11 +601,11 @@
3055 if job.plugin != "user-interact":
3056 # TRANSLATORS: please keep 'plugin' untranslated
3057 raise ValueError(_("bad job plugin value"))
3058- return self._just_run_command(job, job_state, config)
3059+ return self._just_run_command(job, job_state, config).seal()
3060
3061 def run_user_verify_job(self, job, job_state, config):
3062 """
3063- Method called to run a job with plugin field equal to 'user-verify'
3064+ Method called to run a job with plugin field equal to 'user-verify'.
3065
3066 The 'user-verify' job implements the following scenario:
3067
3068@@ -637,15 +646,14 @@
3069 # TRANSLATORS: please keep 'plugin' untranslated
3070 raise ValueError(_("bad job plugin value"))
3071 # Run the command
3072- result_cmd = self._just_run_command(job, job_state, config)
3073+ result_builder = self._just_run_command(job, job_state, config)
3074 # Maybe ask the user
3075- result_cmd.outcome = IJobResult.OUTCOME_UNDECIDED
3076- return result_cmd
3077+ result_builder.outcome = IJobResult.OUTCOME_UNDECIDED
3078+ return result_builder.seal()
3079
3080 def run_user_interact_verify_job(self, job, job_state, config):
3081 """
3082- Method called to run a job with plugin field equal to
3083- 'user-interact-verify'
3084+ Method for running jobs with plugin equal to 'user-interact-verify'.
3085
3086 The 'user-interact-verify' job implements the following scenario:
3087
3088@@ -686,14 +694,14 @@
3089 # TRANSLATORS: please keep 'plugin' untranslated
3090 raise ValueError(_("bad job plugin value"))
3091 # Run the command
3092- result_cmd = self._just_run_command(job, job_state, config)
3093+ result_builder = self._just_run_command(job, job_state, config)
3094 # Maybe ask the user
3095- result_cmd.outcome = IJobResult.OUTCOME_UNDECIDED
3096- return result_cmd
3097+ result_builder.outcome = IJobResult.OUTCOME_UNDECIDED
3098+ return result_builder.seal()
3099
3100 def run_qml_job(self, job, job_state, config):
3101 """
3102- Method called to run a job with plugin field equal to 'qml'
3103+ Method called to run a job with plugin field equal to 'qml'.
3104
3105 The 'qml' job implements the following scenario:
3106
3107@@ -713,10 +721,10 @@
3108 try:
3109 ctrl = self._get_ctrl_for_job(job)
3110 except LookupError:
3111- return MemoryJobResult({
3112- 'outcome': IJobResult.OUTCOME_NOT_SUPPORTED,
3113- 'comment': _('No suitable execution controller is available)'),
3114- })
3115+ return JobResultBuilder(
3116+ outcome=IJobResult.OUTCOME_NOT_SUPPORTED,
3117+ comments=_('No suitable execution controller is available)')
3118+ ).seal()
3119 # Run the embedded command
3120 start_time = time.time()
3121 delegate, io_log_gen = self._prepare_io_handling(job, config)
3122@@ -755,12 +763,12 @@
3123 else:
3124 outcome = IJobResult.OUTCOME_FAIL
3125 # Create a result object and return it
3126- return DiskJobResult({
3127- 'outcome': outcome,
3128- 'return_code': return_code,
3129- 'io_log_filename': record_path,
3130- 'execution_duration': execution_duration
3131- })
3132+ return JobResultBuilder(
3133+ outcome=outcome,
3134+ return_code=return_code,
3135+ io_log_filename=record_path,
3136+ execution_duration=execution_duration
3137+ ).seal()
3138
3139 def _get_dry_run_result(self, job):
3140 """
3141@@ -769,26 +777,24 @@
3142 Returns a result that is used when running in dry-run mode (where we
3143 don't really test anything)
3144 """
3145- return MemoryJobResult({
3146- 'outcome': IJobResult.OUTCOME_SKIP,
3147- 'comments': _("Job skipped in dry-run mode")
3148- })
3149+ return JobResultBuilder(
3150+ outcome=IJobResult.OUTCOME_SKIP,
3151+ comments=_("Job skipped in dry-run mode")
3152+ ).seal()
3153
3154 def _just_run_command(self, job, job_state, config):
3155 """
3156 Internal method of JobRunner.
3157
3158- Runs the command embedded in the job and returns the DiskJobResult that
3159- describes the result. If the command cannot be executed it returns
3160- a MemoryJobResult instead.
3161+ Runs the command embedded in the job and returns a JobResultBuilder
3162+ that describes the result.
3163 """
3164 try:
3165 ctrl = self._get_ctrl_for_job(job)
3166 except LookupError:
3167- return MemoryJobResult({
3168- 'outcome': IJobResult.OUTCOME_NOT_SUPPORTED,
3169- 'comment': _('No suitable execution controller is available)'),
3170- })
3171+ return JobResultBuilder(
3172+ outcome=IJobResult.OUTCOME_NOT_SUPPORTED,
3173+ comments=_('No suitable execution controller is available)'))
3174 # Run the embedded command
3175 start_time = time.time()
3176 return_code, record_path = self._run_command(
3177@@ -802,12 +808,11 @@
3178 else:
3179 outcome = IJobResult.OUTCOME_FAIL
3180 # Create a result object and return it
3181- return DiskJobResult({
3182- 'outcome': outcome,
3183- 'return_code': return_code,
3184- 'io_log_filename': record_path,
3185- 'execution_duration': execution_duration
3186- })
3187+ return JobResultBuilder(
3188+ outcome=outcome,
3189+ return_code=return_code,
3190+ io_log_filename=record_path,
3191+ execution_duration=execution_duration)
3192
3193 def _prepare_io_handling(self, job, config):
3194 ui_io_delegate = self._command_io_delegate
3195@@ -912,7 +917,7 @@
3196
3197 def _get_ctrl_for_job(self, job):
3198 """
3199- Get the execution controller most applicable to run this job
3200+ Get the execution controller most applicable to run this job.
3201
3202 :param job:
3203 A job definition to run
3204
3205=== modified file 'plainbox/plainbox/impl/session/jobs.py'
3206--- plainbox/plainbox/impl/session/jobs.py 2015-04-09 13:20:17 +0000
3207+++ plainbox/plainbox/impl/session/jobs.py 2015-05-14 11:57:31 +0000
3208@@ -16,6 +16,8 @@
3209 # You should have received a copy of the GNU General Public License
3210 # along with Checkbox. If not, see <http://www.gnu.org/licenses/>.
3211 """
3212+Job State.
3213+
3214 :mod:`plainbox.impl.session.jobs` -- jobs state handling
3215 ========================================================
3216
3217@@ -39,8 +41,9 @@
3218
3219
3220 class InhibitionCause(IntEnum):
3221+
3222 """
3223- There are four possible not-ready causes:
3224+ There are four possible not-ready causes.
3225
3226 UNDESIRED:
3227 This job was not selected to run in this session
3228@@ -58,6 +61,7 @@
3229 FAILED_RESOURCE:
3230 This job has a resource requirement that evaluated to a false value
3231 """
3232+
3233 UNDESIRED = 0
3234 PENDING_DEP = 1
3235 FAILED_DEP = 2
3236@@ -68,6 +72,8 @@
3237 def cause_convert_assign_filter(
3238 instance: pod.POD, field: pod.Field, old: "Any", new: "Any") -> "Any":
3239 """
3240+ Assign filter for for JobReadinessInhibitor.cause.
3241+
3242 Custom assign filter for the JobReadinessInhibitor.cause field that
3243 produces a very specific error message.
3244 """
3245@@ -78,6 +84,7 @@
3246
3247
3248 class JobReadinessInhibitor(pod.POD):
3249+
3250 """
3251 Class representing the cause of a job not being ready to execute.
3252
3253@@ -122,6 +129,7 @@
3254 Provides additional context for the problem caused by a failing
3255 resource expression.
3256 """
3257+
3258 # XXX: PENDING_RESOURCE is not strict, there are multiple states that are
3259 # clumped here which is something I don't like. A resource may be still
3260 # "pending" as in PENDING_DEP (it has not ran yet) or it could have ran but
3261@@ -176,11 +184,13 @@
3262 ).format(self.cause.name))
3263
3264 def __repr__(self):
3265+ """Get a custom debugging representation of an inhibitor."""
3266 return "<{} cause:{} related_job:{!r} related_expression:{!r}>".format(
3267 self.__class__.__name__, self.cause.name, self.related_job,
3268 self.related_expression)
3269
3270 def __str__(self):
3271+ """Get a human-readable text representation of an inhibitor."""
3272 if self.cause == InhibitionCause.UNDESIRED:
3273 # TRANSLATORS: as in undesired job
3274 return _("undesired")
3275@@ -211,7 +221,10 @@
3276
3277
3278 class OverridableJobField(pod.Field):
3279+
3280 """
3281+ A custom Field for modeling job values that can be overridden.
3282+
3283 A readable-writable field that has a special initial value ``JOB_VALUE``
3284 which is interpreted as "load this value from the corresponding job
3285 definition".
3286@@ -222,11 +235,13 @@
3287
3288 def __init__(self, job_field, doc=None, type=None, notify=False,
3289 assign_filter_list=None):
3290+ """Initialize a new overridable job field."""
3291 super().__init__(
3292 doc, type, JOB_VALUE, None, notify, assign_filter_list)
3293 self.job_field = job_field
3294
3295 def __get__(self, instance, owner):
3296+ """Get an overriden (if any) value of an overridable job field."""
3297 value = super().__get__(instance, owner)
3298 if value is JOB_VALUE:
3299 return getattr(instance.job, self.job_field)
3300@@ -235,22 +250,28 @@
3301
3302
3303 def job_assign_filter(instance, field, old_value, new_value):
3304- # FIXME: This setter should not exist. job attribute should be
3305- # read-only. This is a temporary kludge to get session restoring
3306- # over DBus working. Once a solution that doesn't involve setting
3307- # a JobState's job attribute is implemented, please remove this
3308- # awful method.
3309+ """
3310+ A custom setter for the JobState.job.
3311+
3312+ .. warning::
3313+ This setter should not exist. job attribute should be read-only. This
3314+ is a temporary kludge to get session restoring over DBus working. Once
3315+ a solution that doesn't involve setting a JobState's job attribute is
3316+ implemented, please remove this awful method.
3317+ """
3318 return new_value
3319
3320
3321 def job_via_assign_filter(instance, field, old_value, new_value):
3322+ """A custom setter for JobState.via_job."""
3323 if (old_value is not pod.UNSET and not isinstance(new_value, JobDefinition)
3324- and not new_value is None):
3325+ and new_value is not None):
3326 raise TypeError("via_job must be the actual job, not the checksum")
3327 return new_value
3328
3329
3330 class JobState(pod.POD):
3331+
3332 """
3333 Class representing the state of a job in a session.
3334
3335@@ -284,6 +305,11 @@
3336 initial_fn=lambda: MemoryJobResult({}),
3337 notify=True)
3338
3339+ result_history = pod.Field(
3340+ doc="a tuple of result_history of the associated job",
3341+ type=tuple, initial=(), notify=True,
3342+ assign_filter_list=[pod.typed, pod.typed.sequence(IJobResult)])
3343+
3344 via_job = pod.Field(
3345 doc="the parent job definition",
3346 type=JobDefinition,
3347@@ -299,16 +325,36 @@
3348 doc="the effective certification status of this job",
3349 type=str)
3350
3351+ # NOTE: the `result` property just exposes the last result from the
3352+ # `result_history` tuple above. The API is used everywhere so it should not
3353+ # be broken in any way but the way forward is the sequence stored in
3354+ # `result_history`.
3355+ #
3356+ # The one particularly annoying part of this implementation is that each
3357+ # job state always has at least one result. Even if there was no testing
3358+ # done yet. This OUTCOME_NONE result needs to be filtered out at various
3359+ # times. I think it would be better if we could not have it in the
3360+ # sequence-based API anymore. Otherwise each test will have two
3361+ # result_history (more if you count things like resuming a session).
3362+
3363+ @result.change_notifier
3364+ def _result_changed(self, old, new):
3365+ # Don't track the initial assignment over UNSET
3366+ if old is pod.UNSET:
3367+ return
3368+ assert new != old
3369+ assert isinstance(new, IJobResult)
3370+ if new.is_hollow:
3371+ return
3372+ logger.debug("Appending result %r to history: %r", new, self.result_history)
3373+ self.result_history += (new,)
3374+
3375 def can_start(self):
3376- """
3377- Quickly check if the associated job can run right now.
3378- """
3379+ """Quickly check if the associated job can run right now."""
3380 return len(self.readiness_inhibitor_list) == 0
3381
3382 def get_readiness_description(self):
3383- """
3384- Get a human readable description of the current readiness state
3385- """
3386+ """Get a human readable description of the current readiness state."""
3387 if self.readiness_inhibitor_list:
3388 return _("job cannot be started: {}").format(
3389 ", ".join((str(inhibitor)
3390
3391=== modified file 'plainbox/plainbox/impl/session/resume.py'
3392--- plainbox/plainbox/impl/session/resume.py 2015-04-13 18:20:44 +0000
3393+++ plainbox/plainbox/impl/session/resume.py 2015-05-14 11:57:31 +0000
3394@@ -707,13 +707,10 @@
3395 result = self._build_JobResult(
3396 result_repr, self.flags, self.location)
3397 result_list.append(result)
3398- # Show the _LAST_ result to the session. Currently we only store one
3399- # result but showing the most recent (last) result should be good
3400- # in general.
3401- if len(result_list) > 0:
3402- logger.debug(
3403- _("calling update_job_result(%r, %r)"), job, result_list[-1])
3404- session.update_job_result(job, result_list[-1])
3405+ # Replay each result, one by one
3406+ for result in result_list:
3407+ logger.debug(_("calling update_job_result(%r, %r)"), job, result)
3408+ session.update_job_result(job, result)
3409
3410 @classmethod
3411 def _restore_SessionState_desired_job_list(cls, session, session_repr):
3412
3413=== modified file 'plainbox/plainbox/impl/session/suspend.py'
3414--- plainbox/plainbox/impl/session/suspend.py 2015-04-13 13:58:00 +0000
3415+++ plainbox/plainbox/impl/session/suspend.py 2015-05-14 11:57:31 +0000
3416@@ -238,7 +238,7 @@
3417 }
3418
3419 def _repr_JobResult(self, obj, session_dir):
3420- """ Compute the representation of one of IJobResult subclasses. """
3421+ """Compute the representation of one of IJobResult subclasses."""
3422 if isinstance(obj, DiskJobResult):
3423 return self._repr_DiskJobResult(obj, session_dir)
3424 elif isinstance(obj, MemoryJobResult):
3425@@ -510,11 +510,10 @@
3426 if not state.result.is_hollow or state.job.id in id_run_list
3427 },
3428 "results": {
3429- # Currently we store only one result but we may store
3430- # more than that in a later version.
3431- state.job.id: [self._repr_JobResult(state.result, session_dir)]
3432+ state.job.id: [self._repr_JobResult(result, session_dir)
3433+ for result in state.result_history]
3434 for state in obj.job_state_map.values()
3435- if not state.result.is_hollow
3436+ if len(state.result_history) > 0
3437 },
3438 "desired_job_list": [
3439 job.id for job in obj.desired_job_list
3440
3441=== modified file 'plainbox/plainbox/impl/session/test_jobs.py'
3442--- plainbox/plainbox/impl/session/test_jobs.py 2015-04-09 13:20:17 +0000
3443+++ plainbox/plainbox/impl/session/test_jobs.py 2015-05-14 11:57:31 +0000
3444@@ -140,6 +140,7 @@
3445 def test_smoke(self):
3446 self.assertIsNotNone(self.job_state.result)
3447 self.assertIs(self.job_state.result.outcome, IJobResult.OUTCOME_NONE)
3448+ self.assertEqual(self.job_state.result_history, ())
3449 self.assertEqual(self.job_state.readiness_inhibitor_list, [
3450 UndesiredJobReadinessInhibitor])
3451 self.assertEqual(self.job_state.effective_category_id,
3452@@ -164,6 +165,16 @@
3453 self.job_state.result = result
3454 self.assertIs(self.job_state.result, result)
3455
3456+ def test_result_history_keeps_track_of_result_changes(self):
3457+ # XXX: this example will fail if subsequent results are identical
3458+ self.assertEqual(self.job_state.result_history, ())
3459+ result1 = make_job_result(outcome='fail')
3460+ self.job_state.result = result1
3461+ self.assertEqual(self.job_state.result_history, (result1,))
3462+ result2 = make_job_result(outcome='pass')
3463+ self.job_state.result = result2
3464+ self.assertEqual(self.job_state.result_history, (result1, result2))
3465+
3466 def test_setting_result_fires_signal(self):
3467 """
3468 verify that assigning state.result fires the on_result_changed signal
3469
3470=== modified file 'plainbox/plainbox/impl/test_pod.py'
3471--- plainbox/plainbox/impl/test_pod.py 2015-01-19 12:33:13 +0000
3472+++ plainbox/plainbox/impl/test_pod.py 2015-05-14 11:57:31 +0000
3473@@ -15,6 +15,9 @@
3474 #
3475 # You should have received a copy of the GNU General Public License
3476 # along with Checkbox. If not, see <http://www.gnu.org/licenses/>.
3477+
3478+"""Tests for the plainbox.impl.pod module."""
3479+
3480 from doctest import DocTestSuite
3481 from unittest import TestCase
3482
3483@@ -30,24 +33,36 @@
3484
3485
3486 def load_tests(loader, tests, ignore):
3487+ """
3488+ Protocol for loading unit tests.
3489+
3490+ This function ensures that doctests are executed as well.
3491+ """
3492 tests.addTests(DocTestSuite('plainbox.impl.pod'))
3493 return tests
3494
3495
3496 class SingletonTests(TestCase):
3497
3498+ """Tests for several singleton objects."""
3499+
3500 def test_MANDATORY_repr(self):
3501+ """MANDATORY.repr() returns "MANDATORY"."""
3502 self.assertEqual(repr(MANDATORY), "MANDATORY")
3503
3504 def test_UNSET_repr(self):
3505+ """UNSET.repr() returns "UNSET"."""
3506 self.assertEqual(repr(UNSET), "UNSET")
3507
3508
3509 class FieldTests(TestCase):
3510
3511+ """Tests for the Field class."""
3512+
3513 FIELD_CLS = Field
3514
3515 def setUp(self):
3516+ """Common set-up code."""
3517 self.doc = "doc" # not a mock because it gets set to __doc__
3518 self.type = mock.Mock(name='type')
3519 self.initial = mock.Mock(name='initial')
3520@@ -58,18 +73,14 @@
3521 self.owner = mock.Mock(name='owner')
3522
3523 def test_initializer(self):
3524- """
3525- Field initializer properly stored all attributes
3526- """
3527+ """.__init__() stored data correctly."""
3528 self.assertEqual(self.field.__doc__, self.doc)
3529 self.assertEqual(self.field.type, self.type)
3530 self.assertEqual(self.field.initial, self.initial)
3531 self.assertEqual(self.field.initial_fn, self.initial_fn)
3532
3533 def test_gain_name(self):
3534- """
3535- Using gain_name() sets three extra attributes
3536- """
3537+ """.gain_name() sets three extra attributes."""
3538 self.assertIsNone(self.field.name)
3539 self.assertIsNone(self.field.instance_attr)
3540 self.assertIsNone(self.field.signal_name)
3541@@ -79,31 +90,23 @@
3542 self.assertEqual(self.field.signal_name, "on_abcd_changed")
3543
3544 def test_repr(self):
3545- """
3546- Field has a working repr() method
3547- """
3548+ """.repr() works as expected."""
3549 self.field.gain_name("field")
3550 self.assertEqual(repr(self.field), "<Field name:'field'>")
3551
3552 def test_is_mandatory(self):
3553- """
3554- Fields with the initial value of MANDATORY are mandatory
3555- """
3556+ """.is_mandatory looks for initial value of MANDATORY."""
3557 self.field.initial = None
3558 self.assertFalse(self.field.is_mandatory)
3559 self.field.initial = MANDATORY
3560 self.assertTrue(self.field.is_mandatory)
3561
3562 def test_cls_reads(self):
3563- """
3564- Accessing fields on via the class exposes the field object itself
3565- """
3566+ """.__get__() returns the field if accessed via a class."""
3567 self.assertIs(self.field.__get__(None, self.owner), self.field)
3568
3569 def test_obj_reads(self):
3570- """
3571- Accessing fields via an object reads data from the object
3572- """
3573+ """.__get__() reads POD data if accessed via an object."""
3574 # Reading the field requires the field to know its name
3575 self.field.gain_name("field")
3576 self.assertEqual(
3577@@ -111,18 +114,14 @@
3578 self.instance._field)
3579
3580 def test_obj_writes(self):
3581- """
3582- Writing fields via an object writes data to the object
3583- """
3584+ """.__set__() writes POD data."""
3585 # Writing the field requires the field to know its name
3586 self.field.gain_name("field")
3587 self.field.__set__(self.instance, "data")
3588 self.assertEqual(self.instance._field, "data")
3589
3590 def test_obj_writes_fires_notification(self):
3591- """
3592- Writing fields via an object triggers notification, if enabled
3593- """
3594+ """.__set__() fires change notifications."""
3595 # Let's enable notification and set the name so that the field knows
3596 # what to do when it gets set. Let's set the instance data to "old" to
3597 # track the actual change.
3598@@ -135,9 +134,7 @@
3599 self.instance.on_field_changed.assert_called_with("old", "new")
3600
3601 def test_obj_writes_uses_assign_chain(self):
3602- """
3603- Writing fields via an object uses the assign filter list
3604- """
3605+ """.__set__() uses the assign filter list."""
3606 # Let's enable the assign filter composed out of two functions
3607 # and set some data using the field.
3608 fn1 = mock.Mock()
3609@@ -153,9 +150,7 @@
3610 self.assertEqual(self.instance._field, fn2())
3611
3612 def test_alter_cls_without_notification(self):
3613- """
3614- Using alter_cls() when notification is disabled does nothing
3615- """
3616+ """.alter_cls() doesn't do anything if notify is False."""
3617 cls = mock.Mock(name='cls')
3618 del cls.on_field_changed
3619 self.field.notify = False
3620@@ -164,9 +159,7 @@
3621 self.assertFalse(hasattr(cls, "on_field_changed"))
3622
3623 def test_alter_cls_with_notification(self):
3624- """
3625- Using alter_cls() when notification is enabled creates a signal
3626- """
3627+ """.alter_cls() adds a change signal if notify is True."""
3628 cls = mock.Mock(name='cls')
3629 del cls.on_field_changed
3630 cls.__name__ = "Klass"
3631@@ -180,7 +173,10 @@
3632
3633 class FieldCollectionTests(TestCase):
3634
3635+ """Tests for the _FieldCollection class."""
3636+
3637 def setUp(self):
3638+ """Common set-up code."""
3639 self.foo = Field()
3640 self.bar = Field()
3641 self.ns = {
3642@@ -192,13 +188,12 @@
3643 self.fc = _FieldCollection()
3644
3645 def set_field_names(self):
3646+ """Set names of the foo and bar fields."""
3647 self.foo.gain_name('foo')
3648 self.bar.gain_name('bar')
3649
3650 def test_add_field_builds_field_list(self):
3651- """
3652- .add_field() appends new fields to field_list
3653- """
3654+ """.add_field() appends new fields to field_list."""
3655 # because we're not calling inspect_namespace() which does that
3656 self.set_field_names()
3657 self.fc.add_field(self.foo, 'cls')
3658@@ -207,9 +202,7 @@
3659 self.assertEqual(self.fc.field_list, [self.foo, self.bar])
3660
3661 def test_add_field_builds_field_origin_map(self):
3662- """
3663- .add_field() builds and maintains field_origin_map
3664- """
3665+ """.add_field() builds and maintains field_origin_map."""
3666 # because we're not calling inspect_namespace() which does that
3667 self.set_field_names()
3668 self.fc.add_field(self.foo, 'cls')
3669@@ -219,9 +212,7 @@
3670 self.fc.field_origin_map, {'foo': 'cls', 'bar': 'cls'})
3671
3672 def test_add_field_detects_clashes(self):
3673- """
3674- .add_Field() detects field clashes and raises TypeError
3675- """
3676+ """.add_Field() detects field clashes and raises TypeError."""
3677 foo_clash = Field()
3678 foo_clash.name = 'foo'
3679 # because we're not calling inspect_namespace() which does that
3680@@ -232,10 +223,7 @@
3681 self.fc.add_field(foo_clash, 'other_cls')
3682
3683 def test_inspect_base_classes_calls_add_field(self):
3684- """
3685- .inspect_base_classes() calls add_field() on each Field found
3686- """
3687-
3688+ """.inspect_base_classes() calls add_field() on each Field found."""
3689 class Base1(POD):
3690 foo = Field()
3691 bar = Field()
3692@@ -255,18 +243,14 @@
3693 ])
3694
3695 def test_inspect_namespace_calls_add_field(self):
3696- """
3697- .inspect_namespace() calls add_field() on each Field
3698- """
3699+ """.inspect_namespace() calls add_field() on each Field."""
3700 with mock.patch.object(self.fc, 'add_field') as mock_add_field:
3701 self.fc.inspect_namespace(self.ns, 'cls')
3702 mock_add_field.assert_has_call(self.foo, 'cls')
3703 mock_add_field.assert_has_call(self.bar, 'cls')
3704
3705 def test_inspect_namespace_sets_field_name(self):
3706- """
3707- .inspect_namespace() sets the .name attribute of each field.
3708- """
3709+ """.inspect_namespace() sets .name of each field."""
3710 self.assertIsNone(self.foo.name)
3711 self.assertIsNone(self.bar.name)
3712 fc = _FieldCollection()
3713@@ -275,9 +259,7 @@
3714 self.assertEqual(self.bar.name, 'bar')
3715
3716 def test_inspect_namespace_sets_field_instance_attr(self):
3717- """
3718- .inspect_namespace() sets the .instance_attr attribute of each field.
3719- """
3720+ """.inspect_namespace() sets .instance_attr of each field."""
3721 self.assertIsNone(self.foo.instance_attr)
3722 self.assertIsNone(self.bar.instance_attr)
3723 fc = _FieldCollection()
3724@@ -285,13 +267,21 @@
3725 self.assertEqual(self.foo.instance_attr, '_foo')
3726 self.assertEqual(self.bar.instance_attr, '_bar')
3727
3728+ def test_notifier(self):
3729+ """@field.change_notifier changes the notify function."""
3730+ @self.foo.change_notifier
3731+ def on_foo_changed(pod, old, new):
3732+ pass
3733+ self.assertTrue(self.foo.notify)
3734+ self.assertEqual(self.foo.notify_fn, on_foo_changed)
3735+
3736
3737 class PODTests(TestCase):
3738
3739+ """Tests for the POD class."""
3740+
3741 def test_field_list(self):
3742- """
3743- Test that PODMeta correctly set up the field_list attribute
3744- """
3745+ """.field_list is set by PODMeta."""
3746 m = mock.Mock()
3747
3748 class T(POD):
3749@@ -302,9 +292,7 @@
3750 self.assertEqual(T.field_list, [T.f1, T.f2, T.f3])
3751
3752 def test_namedtuple_cls(self):
3753- """
3754- Test that PODMeta correctly set up the namedtuple_cls attribute
3755- """
3756+ """Check that .namedtuple_cls is set up by PODMeta."""
3757 m = mock.Mock()
3758
3759 class T(POD):
3760@@ -318,9 +306,7 @@
3761 self.assertIsInstance(T.namedtuple_cls.f3, property)
3762
3763 def test_initializer_positional_arguments(self):
3764- """
3765- Test initializer operation with positional arguments
3766- """
3767+ """.__init__() works correctly with positional arguments."""
3768 m = mock.Mock()
3769
3770 class T(POD):
3771@@ -339,9 +325,7 @@
3772 self.assertEqual(T(1, 2, 3).f3, 3)
3773
3774 def test_initializer_keyword_arguments(self):
3775- """
3776- Test initializer operation with positional arguments
3777- """
3778+ """.__init__() works correctly with keyword arguments."""
3779 m = mock.Mock()
3780
3781 class T(POD):
3782@@ -363,9 +347,7 @@
3783 self.assertEqual(T(f1=1, f2=2, f3=3).f3, 3)
3784
3785 def test_initializer_mandatory_arguments(self):
3786- """
3787- Test initializer's response to mishandling of MANDATORY fields
3788- """
3789+ """.__init__() understands MANDATORY fields."""
3790 class T(POD):
3791 m1 = Field(initial=MANDATORY)
3792 m2 = Field(initial=MANDATORY)
3793@@ -384,9 +366,7 @@
3794 T(m1=1)
3795
3796 def test_initializer_default_arguments(self):
3797- """
3798- Test initializer's response to default values
3799- """
3800+ """.__init__() understands initial (default) field values."""
3801 class T(POD):
3802 f = Field(initial=42)
3803 self.assertEqual(T().f, 42)
3804@@ -394,9 +374,7 @@
3805 self.assertEqual(T(f=1).f, 1)
3806
3807 def test_initializer_duplicate_field_value(self):
3808- """
3809- Test that double initialization is not permitted
3810- """
3811+ """.__init__() prevents double-initialization."""
3812 class T(POD):
3813 f = Field()
3814 with self.assertRaisesRegex(
3815@@ -404,9 +382,7 @@
3816 T(1, f=2)
3817
3818 def test_initializer_unknown_field(self):
3819- """
3820- Test that initializing unknown fields is not permitted
3821- """
3822+ """.__init__() prevents initializing unknown fields."""
3823 class T(POD):
3824 pass
3825 with self.assertRaisesRegex(TypeError, "too many arguments"):
3826@@ -415,9 +391,7 @@
3827 T(f=1)
3828
3829 def test_smoke(self):
3830- """
3831- Test that a simple Person POD can be used to demonstrate basic features
3832- """
3833+ """Check that basic POD behavior works okay."""
3834 class Person(POD):
3835 name = Field()
3836 age = Field()
3837@@ -452,10 +426,16 @@
3838 self.assertEqual(
3839 repr(joe), "Employee(name='Joe', age=42, salary=1000)")
3840
3841+ def test_as_dict_filters_out_UNSET(self):
3842+ class P(POD):
3843+ f = Field()
3844+
3845+ p = P()
3846+ p.f = UNSET
3847+ self.assertEqual(p.as_dict(), {})
3848+
3849 def test_notifications(self):
3850- """
3851- Test that change notifications get sent
3852- """
3853+ """.on_{field}_changed() gets fired by field modification."""
3854 class T(POD):
3855 f = Field(notify=True)
3856
3857@@ -471,7 +451,7 @@
3858 field_callback.assert_called_with(None, 1)
3859
3860 def test_pod_inheritance(self):
3861-
3862+ """Check that PODs can be subclassed and new fields can be added."""
3863 class B(POD):
3864 f1 = Field(notify=True)
3865
3866@@ -485,14 +465,11 @@
3867 self.assertEqual(D.field_list, [B.f1, D.f2])
3868
3869 def test_pod_ordering(self):
3870- """
3871- POD comparison doesn't care about the field names
3872- """
3873-
3874+ """Check that comparison among single POD class works okay."""
3875 class A(POD):
3876 a = Field()
3877
3878- B = A # easier to understand subsequent testds
3879+ B = A # easier to understand subsequent tests
3880 self.assertTrue(A(1) == B(1))
3881 self.assertTrue(A(1) != B(0))
3882 self.assertTrue(A(0) < B(1))
3883@@ -501,10 +478,7 @@
3884 self.assertTrue(A(1) <= B(1))
3885
3886 def test_pod_ordering_tricky1(self):
3887- """
3888- POD comparison doesn't care about actual classes
3889- """
3890-
3891+ """Check that comparison among different POD classes works okay."""
3892 class A(POD):
3893 f = Field()
3894
3895@@ -519,10 +493,7 @@
3896 self.assertTrue(A(1) <= B(1))
3897
3898 def test_pod_ordering_tricky2(self):
3899- """
3900- POD comparison doesn't care about the field names
3901- """
3902-
3903+ """Check that comparison doesn't care about field names."""
3904 class A(POD):
3905 a = Field()
3906
3907@@ -537,10 +508,7 @@
3908 self.assertTrue(A(1) <= B(1))
3909
3910 def test_pod_ordering_other_types(self):
3911- """
3912- POD comparison understands other types and is not equal to them
3913- """
3914-
3915+ """Check that comparison between POD and not-POD types doesn't work."""
3916 class A(POD):
3917 f = Field()
3918
3919@@ -551,10 +519,10 @@
3920
3921 class AssignFilterTests(TestCase):
3922
3923+ """Tests for assignment filters."""
3924+
3925 def test_read_only_assign_filter(self):
3926- """
3927- The read_only_assign_filter works as designed
3928- """
3929+ """The read_only_assign_filter works as designed."""
3930 instance = mock.Mock(name='instance')
3931 instance.__class__.__name__ = 'cls'
3932 field = mock.Mock(name='field')
3933@@ -569,9 +537,7 @@
3934 read_only_assign_filter(instance, field, old, new)
3935
3936 def test_type_convert_assign_filter(self):
3937- """
3938- The type_convert_assign_filter works as designed
3939- """
3940+ """The type_convert_assign_filter works as designed."""
3941 instance = mock.Mock(name='instance')
3942 old = mock.Mock(name='old')
3943 field = mock.Mock(name='field')
3944@@ -585,9 +551,7 @@
3945 type_convert_assign_filter(instance, field, old, 'hello?')
3946
3947 def test_type_check_assign_filter(self):
3948- """
3949- The type_convert_assign_filter works as designed
3950- """
3951+ """The type_convert_assign_filter works as designed."""
3952 instance = mock.Mock(name='instance')
3953 instance.__class__.__name__ = 'cls'
3954 old = mock.Mock(name='old')
3955
3956=== modified file 'plainbox/plainbox/impl/test_result.py'
3957--- plainbox/plainbox/impl/test_result.py 2015-03-30 19:25:56 +0000
3958+++ plainbox/plainbox/impl/test_result.py 2015-05-14 11:57:31 +0000
3959@@ -33,6 +33,7 @@
3960 from plainbox.impl.result import IOLogRecord
3961 from plainbox.impl.result import IOLogRecordReader
3962 from plainbox.impl.result import IOLogRecordWriter
3963+from plainbox.impl.result import JobResultBuilder
3964 from plainbox.impl.result import MemoryJobResult
3965 from plainbox.impl.testing_utils import make_io_log
3966
3967@@ -150,6 +151,7 @@
3968 })
3969 self.assertEqual(result.io_log_as_text_attachment, 'foo')
3970
3971+
3972 class IOLogRecordWriterTests(TestCase):
3973
3974 _RECORD = IOLogRecord(0.123, 'stdout', b'some\ndata')
3975@@ -180,3 +182,45 @@
3976 reader = IOLogRecordReader(stream)
3977 record_list = list(reader)
3978 self.assertEqual(record_list, [self._RECORD])
3979+
3980+
3981+class JobResultBuildeTests(TestCase):
3982+
3983+ def test_smoke_hollow(self):
3984+ self.assertTrue(JobResultBuilder().seal().is_hollow)
3985+
3986+ def test_smoke_memory(self):
3987+ builder = JobResultBuilder()
3988+ builder.comments = 'it works'
3989+ builder.execution_duration = 0.1
3990+ builder.io_log = [(0, 'stdout', b'ok\n')]
3991+ builder.outcome = 'pass'
3992+ builder.return_code = 0
3993+ result = builder.seal()
3994+ self.assertEqual(result.comments, "it works")
3995+ self.assertEqual(result.execution_duration, 0.1)
3996+ self.assertEqual(result.io_log, (
3997+ IOLogRecord(delay=0, stream_name='stdout', data=b'ok\n'),))
3998+ self.assertEqual(result.outcome, "pass")
3999+ self.assertEqual(result.return_code, 0)
4000+
4001+ def test_smoke_disk(self):
4002+ builder = JobResultBuilder()
4003+ builder.comments = 'it works'
4004+ builder.execution_duration = 0.1
4005+ builder.io_log_filename = 'log'
4006+ builder.outcome = 'pass'
4007+ builder.return_code = 0
4008+ result = builder.seal()
4009+ self.assertEqual(result.comments, "it works")
4010+ self.assertEqual(result.execution_duration, 0.1)
4011+ self.assertEqual(result.io_log_filename, 'log')
4012+ self.assertEqual(result.outcome, "pass")
4013+ self.assertEqual(result.return_code, 0)
4014+
4015+ def test_io_log_clash(self):
4016+ builder = JobResultBuilder()
4017+ builder.io_log = [(0, 'stout', b'hi')]
4018+ builder.io_log_filename = 'log'
4019+ with self.assertRaises(ValueError):
4020+ builder.seal()
4021
4022=== modified file 'plainbox/setup.py'
4023--- plainbox/setup.py 2015-05-10 18:38:53 +0000
4024+++ plainbox/setup.py 2015-05-14 11:57:31 +0000
4025@@ -70,6 +70,9 @@
4026 'Topic :: System :: Benchmark',
4027 'Topic :: Utilities',
4028 ],
4029+ install_requires=[
4030+ 'Jinja2 >= 2.7',
4031+ ],
4032 extras_require={
4033 'XLSX': 'XlsxWriter >= 0.3',
4034 'XML': 'lxml >= 2.3',
4035@@ -89,6 +92,7 @@
4036 'xlsx=plainbox.impl.exporter.xlsx:XLSXSessionStateExporter [XLSX]',
4037 'xml=plainbox.impl.exporter.xml:XMLSessionStateExporter [XML]',
4038 'html=plainbox.impl.exporter.html:HTMLSessionStateExporter [XML]',
4039+ 'hexr=plainbox.impl.exporter.hexr:HEXRExporter',
4040 ],
4041 'plainbox.buildsystem': [
4042 'make=plainbox.impl.buildsystems:MakefileBuildSystem',
4043
4044=== modified file 'providers/plainbox-provider-checkbox/whitelists/smoke.whitelist'
4045--- providers/plainbox-provider-checkbox/whitelists/smoke.whitelist 2014-03-20 14:11:44 +0000
4046+++ providers/plainbox-provider-checkbox/whitelists/smoke.whitelist 2015-05-14 11:57:31 +0000
4047@@ -1,27 +1,1 @@
4048-## This is an example whitelist to start from.
4049-## To use, copy this file and add the jobs you want to run to the copy. Delete
4050-## these comments. DO NOT delete the first 9 jobs in tis file. They are resource
4051-## gathering jobs and are necessary to do any testing at all.
4052-# Resource Jobs (listed in jobs/resource.txt)
4053-cpuinfo
4054-cdimage
4055-dmi
4056-dpkg
4057-efi
4058-environment
4059-gconf
4060-lsb
4061-meminfo
4062-module
4063-package
4064-device
4065-uname
4066-# Smoke test cases
4067-__smoke__
4068-smoke/true
4069-smoke/false
4070-smoke/dependency/good
4071-smoke/dependency/bad
4072-smoke/requirement/good
4073-smoke/requirement/bad
4074 smoke/manual
4075
4076=== modified file 'support/install-deb-dependencies'
4077--- support/install-deb-dependencies 2014-04-02 13:19:06 +0000
4078+++ support/install-deb-dependencies 2015-05-14 11:57:31 +0000
4079@@ -54,3 +54,6 @@
4080 apt-get install --quiet --quiet --yes $dpkgs_to_install_list
4081 fi
4082 fi
4083+
4084+# Update to latest packages, this is a bit annoying but this is what it takes
4085+sudo apt-get dist-upgrade --yes

Subscribers

People subscribed via source and target branches