Merge lp:~sylvain-pineau/checkbox/checkbox-ng-sa into lp:checkbox

Proposed by Sylvain Pineau
Status: Merged
Approved by: Maciej Kisielewski
Approved revision: 4125
Merged at revision: 4122
Proposed branch: lp:~sylvain-pineau/checkbox/checkbox-ng-sa
Merge into: lp:checkbox
Diff against target: 748 lines (+637/-5)
6 files modified
checkbox-ng/checkbox_ng/commands/sru.py (+1/-1)
checkbox-ng/checkbox_ng/misc.py (+22/-0)
checkbox-ng/checkbox_ng/ui.py (+4/-1)
checkbox-ng/launchers/checkbox-cli2 (+596/-0)
plainbox/plainbox/impl/session/assistant.py (+11/-3)
plainbox/plainbox/impl/session/restart.py (+3/-0)
To merge this branch: bzr merge lp:~sylvain-pineau/checkbox/checkbox-ng-sa
Reviewer Review Type Date Requested Status
Sylvain Pineau (community) Needs Resubmitting
Maciej Kisielewski Needs Fixing
Review via email: mp+275925@code.launchpad.net

Description of the change

checkbox-cli rewritten using plainbox SessionAssistant as a standalone tool (called checkbox-cli2 to avoid conflicts)

To post a comment you must log in.
Revision history for this message
Zygmunt Krynicki (zyga) wrote :

Hey. Thanks for sending this. I made several small comments below. I like the direction this is going but I'd like to overall streamline the code. I realize we cherry picked functions from various locations but now we have a chance to simplify and unify this so that it is easier to understand.

I would also like to know exactly which features we aim to support here (in other words, this is not a launcher interpreter). It would be good if we could write this down in the docstring of the module.

Revision history for this message
Sylvain Pineau (sylvain-pineau) wrote :

Ready for review, I put the app in the launcher folder to keep it in checkbox-ng for packaging needs

review: Needs Resubmitting
Revision history for this message
Maciej Kisielewski (kissiel) wrote :

My only -1 is on the `._export_results`. I really believe that talking to plainbox internals behind SessionAssistant could be avoided. (See inline comment).

Building tree (create_simple_tree) smells bad, the nested coprehension in the loop.
But after understanding how the code works I guess that's the cleanest way (reading-wise) of doing it, and the list of categories in the root node *should* be small.

Super minor thing: rev 4121 ‘aa’ typo in commit msg

review: Needs Fixing
4121. By Sylvain Pineau

plainbox:session:assistant: Reset running_job_name when resuming with autorestart

When a session is resumed in autorestart mode, the running_job_name meta
data has to be reset and local self._metadata returned instead of the initial
self._resume_candidates[session_id].metadata.

4122. By Sylvain Pineau

plainbox:session:assistant: Create a checkpoint when calling use_job_result()

4123. By Sylvain Pineau

plainbox:session:restart: Append $SHELL to xdg desktop file command

To avoid closing the terminal when the resume command is over.

4124. By Sylvain Pineau

plainbox:session:assistant: Set FLAG_SUBMITTED after calling transport.send()

SessionAssistant export_to_transport() should set the flag FLAG_SUBMITTED if
the call to transport.send() succeeded.

4125. By Sylvain Pineau

checkbox-ng:checkbox-cli2: New CLI application based on Session Assistant

Not a true launcher but a standalone application aimed to replace checkbox-cli.
It relies on plainbox new Session Assistant to perform all low level operations.

The main differences between this version and current checkbox-cli are:
 - support for restart API
 - jobs are grouped by categories units (as opposed to Job.via links) in test
   selection screens.

Revision history for this message
Sylvain Pineau (sylvain-pineau) wrote :

_export_results fixed to use sa export to stream. and rev 4121 typo

review: Needs Resubmitting
Revision history for this message
Sylvain Pineau (sylvain-pineau) wrote :

Time to fix ppa packaging now

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/sru.py'
2--- checkbox-ng/checkbox_ng/commands/sru.py 2015-09-07 13:40:15 +0000
3+++ checkbox-ng/checkbox_ng/commands/sru.py 2015-12-02 09:12:28 +0000
4@@ -128,7 +128,7 @@
5
6 def _collect_info(self, rc, sa):
7 sa.select_providers('*')
8- sa.start_new_session(_("Hardware Collection Session"))
9+ sa.start_new_session(_("SRU Session"))
10 sa.select_test_plan(self.config.test_plan)
11 sa.bootstrap()
12 for job_id in sa.get_static_todo_list():
13
14=== modified file 'checkbox-ng/checkbox_ng/misc.py'
15--- checkbox-ng/checkbox_ng/misc.py 2015-10-13 13:41:17 +0000
16+++ checkbox-ng/checkbox_ng/misc.py 2015-12-02 09:12:28 +0000
17@@ -145,6 +145,28 @@
18 builder.auto_add_job(job)
19 return builder.root_node
20
21+ @classmethod
22+ def create_simple_tree(cls, sa, job_list):
23+ """
24+ Build a rooted JobTreeNode from a job list.
25+
26+ :argument sa:
27+ A session assistant object
28+ :argument job_list:
29+ List of jobs to consider for building the tree.
30+ """
31+ root_node = cls()
32+ for job in job_list:
33+ cat_name = sa.get_category(job.category_id).tr_name()
34+ matches = [n for n in root_node.categories if n.name == cat_name]
35+ if not matches:
36+ node = cls(cat_name)
37+ root_node.add_category(node)
38+ else:
39+ node = matches[0]
40+ node.add_job(job)
41+ return root_node
42+
43
44 class TreeBuilder:
45
46
47=== modified file 'checkbox-ng/checkbox_ng/ui.py'
48--- checkbox-ng/checkbox_ng/ui.py 2015-06-10 15:04:50 +0000
49+++ checkbox-ng/checkbox_ng/ui.py 2015-12-02 09:12:28 +0000
50@@ -77,12 +77,13 @@
51 """
52 Display the appropriate menu and return the selected options
53 """
54- def __init__(self, title, menu, selection=[0]):
55+ def __init__(self, title, menu, selection=[0], multiple_allowed=True):
56 self.image = TextImage(Size(0, 0))
57 self.title = title
58 self.menu = menu
59 self.option_count = len(menu)
60 self.position = 0 # Zero-based index of the selected menu option
61+ self.multiple_allowed = multiple_allowed
62 if self.option_count:
63 self.selection = selection
64 else:
65@@ -110,6 +111,8 @@
66 self.selection.remove(self.position)
67 elif self.position < self.option_count:
68 self.selection.append(self.position)
69+ if not self.multiple_allowed:
70+ self.selection = [self.position]
71 self.repaint(event)
72 return self.image
73
74
75=== added file 'checkbox-ng/launchers/checkbox-cli2'
76--- checkbox-ng/launchers/checkbox-cli2 1970-01-01 00:00:00 +0000
77+++ checkbox-ng/launchers/checkbox-cli2 2015-12-02 09:12:28 +0000
78@@ -0,0 +1,596 @@
79+#!/usr/bin/env python3
80+# This file is part of Checkbox.
81+#
82+# Copyright 2015 Canonical Ltd.
83+# Written by:
84+# Sylvain Pineau <sylvain.pineau@canonical.com>
85+#
86+# Checkbox is free software: you can redistribute it and/or modify
87+# it under the terms of the GNU General Public License version 3,
88+# as published by the Free Software Foundation.
89+#
90+# Checkbox is distributed in the hope that it will be useful,
91+# but WITHOUT ANY WARRANTY; without even the implied warranty of
92+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
93+# GNU General Public License for more details.
94+#
95+# You should have received a copy of the GNU General Public License
96+# along with Checkbox. If not, see <http://www.gnu.org/licenses/>.
97+
98+"""
99+Checkbox CLI Application.
100+
101+WARNING: this is not a launcher interpreter.
102+"""
103+
104+from argparse import SUPPRESS
105+from shutil import copyfileobj
106+import gettext
107+import io
108+import json
109+import logging
110+import os
111+import sys
112+
113+from guacamole import Command
114+from guacamole.core import Ingredient
115+from guacamole.ingredients import ansi
116+from guacamole.ingredients import argparse
117+from guacamole.ingredients import cmdtree
118+from guacamole.recipes.cmd import CommandRecipe
119+
120+# TODO: use public APIs here
121+from plainbox.abc import IJobResult
122+from plainbox.i18n import ngettext
123+from plainbox.i18n import pgettext as C_
124+from plainbox.impl.commands.inv_run import seconds_to_human_duration
125+from plainbox.impl.commands.inv_run import Action
126+from plainbox.impl.commands.inv_run import ActionUI
127+from plainbox.impl.commands.inv_run import NormalUI
128+from plainbox.impl.commands.inv_run import ReRunJob
129+from plainbox.impl.color import Colorizer
130+from plainbox.impl.exporter import ByteStringStreamTranslator
131+from plainbox.impl.ingredients import CanonicalCrashIngredient
132+from plainbox.impl.ingredients import RenderingContextIngredient
133+from plainbox.impl.ingredients import SessionAssistantIngredient
134+from plainbox.impl.result import tr_outcome
135+from plainbox.impl.result import JobResultBuilder
136+from plainbox.impl.result import MemoryJobResult
137+from plainbox.impl.session.assistant import SA_RESTARTABLE
138+from plainbox.impl.session.jobs import InhibitionCause
139+from plainbox.vendor.textland import get_display
140+
141+from checkbox_ng.misc import SelectableJobTreeNode
142+from checkbox_ng.ui import ScrollableTreeNode
143+from checkbox_ng.ui import ShowMenu
144+from checkbox_ng.ui import ShowRerun
145+
146+
147+_ = gettext.gettext
148+
149+_logger = logging.getLogger("checkbox-cli")
150+
151+
152+class DisplayIngredient(Ingredient):
153+
154+ """Ingredient that adds a Textland display to guacamole."""
155+
156+ def late_init(self, context):
157+ """Add a DisplayIngredient as ``display`` to the guacamole context."""
158+ context.display = get_display()
159+
160+
161+class CheckboxCommandRecipe(CommandRecipe):
162+
163+ """A recipe for using Checkbox-enhanced commands."""
164+
165+ def get_ingredients(self):
166+ """Get a list of ingredients for guacamole."""
167+ return [
168+ cmdtree.CommandTreeBuilder(self.command),
169+ cmdtree.CommandTreeDispatcher(),
170+ argparse.ParserIngredient(),
171+ CanonicalCrashIngredient(),
172+ ansi.ANSIIngredient(),
173+ SessionAssistantIngredient(),
174+ RenderingContextIngredient(),
175+ DisplayIngredient()
176+ ]
177+
178+
179+class CheckboxUI(NormalUI):
180+
181+ def considering_job(self, job, job_state):
182+ pass
183+
184+
185+class CheckboxCommand(Command):
186+
187+ """
188+ A command with Checkbox-enhanced ingredients.
189+
190+ This command has two additional items in the guacamole execution context,
191+ the :class:`DisplayIngredient` object ``display`` and the
192+ :class:`SessionAssistant` object ``sa``.
193+ """
194+
195+ bug_report_url = "https://bugs.launchpad.net/checkbox-ng/+filebug"
196+
197+ def main(self, argv=None, exit=True):
198+ """
199+ Shortcut for running a command.
200+
201+ See :meth:`guacamole.recipes.Recipe.main()` for details.
202+ """
203+ return CheckboxCommandRecipe(self).main(argv, exit)
204+
205+
206+class checkbox_cli(CheckboxCommand):
207+
208+ """Tool to run Checkbox jobs interactively from the command line."""
209+
210+ app_id = 'checkbox-cli'
211+
212+ def get_sa_api_version(self):
213+ return '0.99'
214+
215+ def get_sa_api_flags(self):
216+ return (SA_RESTARTABLE,)
217+
218+ def register_arguments(self, parser):
219+ """Method called to register command line arguments."""
220+ parser.add_argument(
221+ '-t', '--test-plan', action="store", metavar=_("TEST-PLAN-ID"),
222+ default=None,
223+ # TRANSLATORS: this is in imperative form
224+ help=_("load the specified test plan"))
225+ parser.add_argument(
226+ '--secure_id', metavar="SECURE_ID",
227+ help=_("Canonical hardware identifier (optional)"))
228+ parser.add_argument(
229+ '--non-interactive', action='store_true',
230+ help=_("skip tests that require interactivity"))
231+ parser.add_argument(
232+ '--dont-suppress-output', action="store_true", default=False,
233+ help=_("don't suppress the output of certain job plugin types"))
234+ parser.add_argument(
235+ '--staging', action='store_true', default=False,
236+ # Hide staging from help message (See pad.lv/1350005)
237+ help=SUPPRESS)
238+ parser.add_argument(
239+ '--resume', dest='session_id', metavar="SESSION_ID",
240+ help=SUPPRESS)
241+
242+ def invoked(self, ctx):
243+ """Method called when the command is invoked."""
244+ self.ctx = ctx
245+ self.transport = self._create_transport()
246+ self.C = Colorizer()
247+ try:
248+ self._do_normal_sequence()
249+ self._export_results()
250+ if ctx.args.secure_id:
251+ self._send_results()
252+ ctx.sa.finalize_session()
253+ except KeyboardInterrupt:
254+ return 1
255+
256+ def _export_results(self):
257+ if self.is_interactive:
258+ print(self.C.header(_("Results")))
259+ # This requires a bit more finesse, as exporters output bytes
260+ # and stdout needs a string.
261+ translating_stream = ByteStringStreamTranslator(
262+ sys.stdout, "utf-8")
263+ self.ctx.sa.export_to_stream(
264+ '2013.com.canonical.plainbox::text', (), translating_stream)
265+ base_dir = os.path.join(
266+ os.getenv(
267+ 'XDG_DATA_HOME', os.path.expanduser("~/.local/share/")),
268+ "checkbox-ng")
269+ if not os.path.exists(base_dir):
270+ os.makedirs(base_dir)
271+ exp_options = ['with-sys-info', 'with-summary', 'with-job-description',
272+ 'with-text-attachments', 'with-certification-status',
273+ 'with-job-defs', 'with-io-log', 'with-comments']
274+ exporters = [
275+ '2013.com.canonical.plainbox::hexr',
276+ '2013.com.canonical.plainbox::html',
277+ '2013.com.canonical.plainbox::xlsx',
278+ '2013.com.canonical.plainbox::json',
279+ ]
280+ print()
281+ for unit_name in exporters:
282+ results_path = self.ctx.sa.export_to_file(unit_name, exp_options,
283+ base_dir)
284+ print("file://{}".format(results_path))
285+
286+ def _send_results(self):
287+ print()
288+ print(_("Sending hardware report to Canonical Certification"))
289+ print(_("Server URL is: {0}").format(self.transport.url))
290+ result = self.ctx.sa.export_to_transport(
291+ "2013.com.canonical.plainbox::hexr", self.transport)
292+ if 'url' in result:
293+ print(result['url'])
294+
295+ def _create_transport(self):
296+ if self.ctx.args.secure_id:
297+ return self.ctx.sa.get_canonical_certification_transport(
298+ self.ctx.args.secure_id, staging=self.ctx.args.staging)
299+
300+ def _get_interactively_picked_testplans(self):
301+ test_plan_ids = self.ctx.sa.get_test_plans()
302+ test_plan_names = [self.ctx.sa.get_test_plan(tp_id).name for tp_id in
303+ test_plan_ids]
304+ try:
305+ selected_index = self.ctx.display.run(
306+ ShowMenu(_("Select test plan"),
307+ test_plan_names, [],
308+ multiple_allowed=False))[0]
309+ except IndexError:
310+ return None
311+ return test_plan_ids[selected_index]
312+
313+ def _interactively_pick_jobs_to_run(self):
314+ job_list = [self.ctx.sa.get_job(job_id) for job_id in
315+ self.ctx.sa.get_static_todo_list()]
316+ tree = SelectableJobTreeNode.create_simple_tree(self.ctx.sa, job_list)
317+ title = _('Choose tests to run on your system:')
318+ self.ctx.display.run(ScrollableTreeNode(tree, title))
319+ # NOTE: tree.selection is correct but ordered badly. To retain
320+ # the original ordering we should just treat it as a mask and
321+ # use it to filter jobs from get_static_todo_list.
322+ wanted_set = frozenset([job.id for job in tree.selection])
323+ job_id_list = [job_id for job_id in self.ctx.sa.get_static_todo_list()
324+ if job_id in wanted_set]
325+ self.ctx.sa.use_alternate_selection(job_id_list)
326+
327+ @property
328+ def is_interactive(self):
329+ """
330+ Flag indicating that this is an interactive invocation.
331+
332+ We can then interact with the user when we encounter OUTCOME_UNDECIDED.
333+ """
334+ return (sys.stdin.isatty() and sys.stdout.isatty() and not
335+ self.ctx.args.non_interactive)
336+
337+ def _get_ui_for_job(self, job):
338+ if self.ctx.args.dont_suppress_output is False and job.plugin in (
339+ 'local', 'resource', 'attachment'):
340+ return CheckboxUI(self.C.c, show_cmd_output=False)
341+ else:
342+ return CheckboxUI(self.C.c, show_cmd_output=True)
343+
344+ def _run_single_job_with_ui_loop(self, job, ui):
345+ print(self.C.header(job.tr_summary(), fill='-'))
346+ print(_("ID: {0}").format(job.id))
347+ print(_("Category: {0}").format(
348+ self.ctx.sa.get_job_state(job.id).effective_category_id))
349+ comments = ""
350+ while True:
351+ if job.plugin in ('user-interact', 'user-interact-verify',
352+ 'user-verify', 'manual'):
353+ ui.notify_about_purpose(job)
354+ if (self.is_interactive and
355+ job.plugin in ('user-interact',
356+ 'user-interact-verify',
357+ 'manual')):
358+ ui.notify_about_steps(job)
359+ if job.plugin == 'manual':
360+ cmd = 'run'
361+ else:
362+ cmd = ui.wait_for_interaction_prompt(job)
363+ if cmd == 'run' or cmd is None:
364+ result_builder = self.ctx.sa.run_job(job.id, ui, False)
365+ elif cmd == 'comment':
366+ new_comment = input(self.C.BLUE(
367+ _('Please enter your comments:') + '\n'))
368+ if new_comment:
369+ comments += new_comment + '\n'
370+ continue
371+ elif cmd == 'skip':
372+ result_builder = JobResultBuilder(
373+ outcome=IJobResult.OUTCOME_SKIP,
374+ comments=_("Explicitly skipped before"
375+ " execution"))
376+ if comments != "":
377+ result_builder.comments = comments
378+ break
379+ elif cmd == 'quit':
380+ raise SystemExit()
381+ else:
382+ result_builder = self.ctx.sa.run_job(job.id, ui, False)
383+ else:
384+ if 'noreturn' in job.get_flag_set():
385+ ui.noreturn_job()
386+ result_builder = self.ctx.sa.run_job(job.id, ui, False)
387+ if (self.is_interactive and
388+ result_builder.outcome == IJobResult.OUTCOME_UNDECIDED):
389+ try:
390+ if comments != "":
391+ result_builder.comments = comments
392+ ui.notify_about_verification(job)
393+ self._interaction_callback(job, result_builder)
394+ except ReRunJob:
395+ self.ctx.sa.use_job_result(job.id,
396+ result_builder.get_result())
397+ continue
398+ break
399+ return result_builder
400+
401+ def _pick_action_cmd(self, action_list, prompt=None):
402+ return ActionUI(action_list, prompt).run()
403+
404+ def _interaction_callback(self, job, result_builder,
405+ prompt=None, allowed_outcome=None):
406+ result = result_builder.get_result()
407+ if prompt is None:
408+ prompt = _("Select an outcome or an action: ")
409+ if allowed_outcome is None:
410+ allowed_outcome = [IJobResult.OUTCOME_PASS,
411+ IJobResult.OUTCOME_FAIL,
412+ IJobResult.OUTCOME_SKIP]
413+ allowed_actions = [
414+ Action('c', _('add a comment'), 'set-comments')
415+ ]
416+ if IJobResult.OUTCOME_PASS in allowed_outcome:
417+ allowed_actions.append(
418+ Action('p', _('set outcome to {0}').format(
419+ self.C.GREEN(C_('set outcome to <pass>', 'pass'))),
420+ 'set-pass'))
421+ if IJobResult.OUTCOME_FAIL in allowed_outcome:
422+ allowed_actions.append(
423+ Action('f', _('set outcome to {0}').format(
424+ self.C.RED(C_('set outcome to <fail>', 'fail'))),
425+ 'set-fail'))
426+ if IJobResult.OUTCOME_SKIP in allowed_outcome:
427+ allowed_actions.append(
428+ Action('s', _('set outcome to {0}').format(
429+ self.C.YELLOW(C_('set outcome to <skip>', 'skip'))),
430+ 'set-skip'))
431+ if job.command is not None:
432+ allowed_actions.append(
433+ Action('r', _('re-run this job'), 're-run'))
434+ if result.return_code is not None:
435+ if result.return_code == 0:
436+ suggested_outcome = IJobResult.OUTCOME_PASS
437+ else:
438+ suggested_outcome = IJobResult.OUTCOME_FAIL
439+ allowed_actions.append(
440+ Action('', _('set suggested outcome [{0}]').format(
441+ tr_outcome(suggested_outcome)), 'set-suggested'))
442+ while result.outcome not in allowed_outcome:
443+ print(_("Please decide what to do next:"))
444+ print(" " + _("outcome") + ": {0}".format(
445+ self.C.result(result)))
446+ if result.comments is None:
447+ print(" " + _("comments") + ": {0}".format(
448+ C_("none comment", "none")))
449+ else:
450+ print(" " + _("comments") + ": {0}".format(
451+ self.C.CYAN(result.comments, bright=False)))
452+ cmd = self._pick_action_cmd(allowed_actions)
453+ if cmd == 'set-pass':
454+ result_builder.outcome = IJobResult.OUTCOME_PASS
455+ elif cmd == 'set-fail':
456+ result_builder.outcome = IJobResult.OUTCOME_FAIL
457+ elif cmd == 'set-skip' or cmd is None:
458+ result_builder.outcome = IJobResult.OUTCOME_SKIP
459+ elif cmd == 'set-suggested':
460+ result_builder.outcome = suggested_outcome
461+ elif cmd == 'set-comments':
462+ new_comment = input(self.C.BLUE(
463+ _('Please enter your comments:') + '\n'))
464+ if new_comment:
465+ result_builder.add_comment(new_comment)
466+ elif cmd == 're-run':
467+ raise ReRunJob
468+ result = result_builder.get_result()
469+
470+ def _run_jobs(self, jobs_to_run):
471+ estimated_time = 0
472+ for job_id in jobs_to_run:
473+ job = self.ctx.sa.get_job(job_id)
474+ if (job.estimated_duration is not None
475+ and estimated_time is not None):
476+ estimated_time += job.estimated_duration
477+ else:
478+ estimated_time = None
479+ for job_no, job_id in enumerate(jobs_to_run, start=1):
480+ print(self.C.header(
481+ _('Running job {} / {}. Estimated time left: {}').format(
482+ job_no, len(jobs_to_run),
483+ seconds_to_human_duration(max(0, estimated_time))
484+ if estimated_time is not None else _("unknown")),
485+ fill='-'))
486+ job = self.ctx.sa.get_job(job_id)
487+ builder = self._run_single_job_with_ui_loop(
488+ job, self._get_ui_for_job(job))
489+ result = builder.get_result()
490+ self.ctx.sa.use_job_result(job_id, result)
491+ if (job.estimated_duration is not None
492+ and estimated_time is not None):
493+ estimated_time -= job.estimated_duration
494+
495+ def _get_rerun_candidates(self):
496+ """Get all the tests that might be selected for rerunning."""
497+ def rerun_predicate(job_state):
498+ return job_state.result.outcome in (
499+ IJobResult.OUTCOME_FAIL, IJobResult.OUTCOME_CRASH,
500+ IJobResult.OUTCOME_NOT_SUPPORTED, IJobResult.OUTCOME_SKIP)
501+ rerun_candidates = []
502+ todo_list = self.ctx.sa.get_static_todo_list()
503+ job_states = {job_id: self.ctx.sa.get_job_state(job_id) for job_id
504+ in todo_list}
505+ for job_id, job_state in job_states.items():
506+ if rerun_predicate(job_state):
507+ rerun_candidates.append(self.ctx.sa.get_job(job_id))
508+ return rerun_candidates
509+
510+ def _maybe_rerun_jobs(self):
511+ # create a list of jobs that qualify for rerunning
512+ rerun_candidates = self._get_rerun_candidates()
513+ # bail-out early if no job qualifies for rerunning
514+ if not rerun_candidates:
515+ return False
516+ tree = SelectableJobTreeNode.create_simple_tree(self.ctx.sa,
517+ rerun_candidates)
518+ # nothing to select in root node and categories - bailing out
519+ if not tree.jobs and not tree._categories:
520+ return False
521+ # deselect all by default
522+ tree.set_descendants_state(False)
523+ self.ctx.display.run(ShowRerun(tree, _("Select jobs to re-run")))
524+ wanted_set = frozenset(tree.selection)
525+ if not wanted_set:
526+ # nothing selected - nothing to run
527+ return False
528+ rerun_candidates = []
529+ # include resource jobs that selected jobs depend on
530+ resources_to_rerun = []
531+ for job in wanted_set:
532+ job_state = self.ctx.sa.get_job_state(job.id)
533+ for inhibitor in job_state.readiness_inhibitor_list:
534+ if inhibitor.cause == InhibitionCause.FAILED_DEP:
535+ resources_to_rerun.append(inhibitor.related_job)
536+ # reset outcome of jobs that are selected for re-running
537+ for job in list(wanted_set) + resources_to_rerun:
538+ self.ctx.sa.get_job_state(job.id).result = MemoryJobResult({})
539+ rerun_candidates.append(job.id)
540+ self._run_jobs(rerun_candidates)
541+ return True
542+
543+ def _do_normal_sequence(self):
544+ self.ctx.sa.select_providers("*")
545+ self.ctx.sa.configure_application_restart(
546+ lambda session_id: [
547+ 'sh', '-c', ' '.join([
548+ os.path.abspath(__file__),
549+ "--resume", session_id])
550+ ])
551+ resumed = self._maybe_resume_session()
552+ if not resumed:
553+ print(_("Preparing..."))
554+ self.ctx.sa.start_new_session(_("Checkbox CLI Session"))
555+ testplan_id = None
556+ if self.ctx.args.test_plan:
557+ if self.ctx.args.test_plan in self.ctx.sa.get_test_plans():
558+ testplan_id = self.ctx.args.test_plan
559+ elif self.is_interactive:
560+ testplan_id = self._get_interactively_picked_testplans()
561+ if not testplan_id:
562+ self.ctx.rc.reset()
563+ self.ctx.rc.bg = 'red'
564+ self.ctx.rc.fg = 'bright_white'
565+ self.ctx.rc.bold = 1
566+ self.ctx.rc.para(_("Test plan not found!"))
567+ raise SystemExit(1)
568+ self.ctx.sa.select_test_plan(testplan_id)
569+ self.ctx.sa.update_app_blob(json.dumps(
570+ {'testplan_id': testplan_id, }).encode("UTF-8"))
571+ self.ctx.sa.bootstrap()
572+ if self.is_interactive:
573+ self._interactively_pick_jobs_to_run()
574+ self._run_jobs(self.ctx.sa.get_dynamic_todo_list())
575+ if self.is_interactive:
576+ while True:
577+ if self._maybe_rerun_jobs():
578+ continue
579+ else:
580+ break
581+
582+ def _handle_last_job_after_resume(self, metadata):
583+ last_job = metadata.running_job_name
584+ if last_job is None:
585+ return
586+ print(_("Previous session run tried to execute job: {}").format(
587+ last_job))
588+ cmd = self._pick_action_cmd([
589+ Action('s', _("skip that job"), 'skip'),
590+ Action('p', _("mark it as passed and continue"), 'pass'),
591+ Action('f', _("mark it as failed and continue"), 'fail'),
592+ Action('r', _("run it again"), 'run'),
593+ ], _("What do you want to do with that job?"))
594+ if cmd == 'skip' or cmd is None:
595+ result = MemoryJobResult({
596+ 'outcome': IJobResult.OUTCOME_SKIP,
597+ 'comments': _("Skipped after resuming execution")
598+ })
599+ elif cmd == 'pass':
600+ result = MemoryJobResult({
601+ 'outcome': IJobResult.OUTCOME_PASS,
602+ 'comments': _("Passed after resuming execution")
603+ })
604+ elif cmd == 'fail':
605+ result = MemoryJobResult({
606+ 'outcome': IJobResult.OUTCOME_FAIL,
607+ 'comments': _("Failed after resuming execution")
608+ })
609+ elif cmd == 'run':
610+ result = None
611+ if result:
612+ self.ctx.sa.use_job_result(last_job, result)
613+
614+ def _maybe_resume_session(self):
615+ # Try to use the first session that can be resumed if the user agrees
616+ resume_candidates = list(self.ctx.sa.get_resumable_sessions())
617+ resumed = False
618+ if resume_candidates:
619+ if self.ctx.args.session_id:
620+ for candidate in resume_candidates:
621+ if candidate.id == self.args.session_id:
622+ resume_candidates = (candidate, )
623+ break
624+ else:
625+ raise RuntimeError("Requested session is not resumable!")
626+ elif self.is_interactive:
627+ print(self.C.header(_("Resume Incomplete Session")))
628+ print(ngettext(
629+ "There is {0} incomplete session that might be resumed",
630+ "There are {0} incomplete sessions that might be resumed",
631+ len(resume_candidates)
632+ ).format(len(resume_candidates)))
633+ else:
634+ return
635+ for candidate in resume_candidates:
636+ if self.ctx.args.session_id:
637+ cmd = 'resume'
638+ else:
639+ # Skip sessions that the user doesn't want to resume
640+ cmd = self._pick_action_cmd([
641+ Action('r', _("resume this session"), 'resume'),
642+ Action('n', _("next session"), 'next'),
643+ Action('c', _("create new session"), 'create')
644+ ], _("Do you want to resume session {0!a}?").format(
645+ candidate.id))
646+ if cmd == 'next':
647+ continue
648+ elif cmd == 'create' or cmd is None:
649+ break
650+ elif cmd == 'resume':
651+ metadata = self.ctx.sa.resume_session(candidate.id)
652+ app_blob = json.loads(metadata.app_blob.decode("UTF-8"))
653+ test_plan_id = app_blob['testplan_id']
654+ # FIXME selecting again the testplan on resume resets both
655+ # the static and dynamic todo lists.
656+ # We're then saving the selection from the saved run_list
657+ # by accessing the session private context object.
658+ selected_id_list = [job.id for job in
659+ self.ctx.sa._context.state.run_list]
660+ self.ctx.sa.select_test_plan(test_plan_id)
661+ self.ctx.sa.bootstrap()
662+ self.ctx.sa.use_alternate_selection(selected_id_list)
663+ # If we resumed maybe not rerun the same, probably broken
664+ # job
665+ self._handle_last_job_after_resume(metadata)
666+ self._run_jobs(self.ctx.sa.get_dynamic_todo_list())
667+ resumed = True
668+ # Finally ignore other sessions that can be resumed
669+ break
670+ return resumed
671+
672+
673+if __name__ == '__main__':
674+ checkbox_cli().main()
675
676=== modified file 'plainbox/plainbox/impl/session/assistant.py'
677--- plainbox/plainbox/impl/session/assistant.py 2015-11-04 14:25:10 +0000
678+++ plainbox/plainbox/impl/session/assistant.py 2015-12-02 09:12:28 +0000
679@@ -551,6 +551,8 @@
680 io_log_filename=self._runner.get_record_path_for_job(job),
681 ).get_result()
682 self._context.state.update_job_result(job, result)
683+ self._metadata.running_job_name = None
684+ self._manager.checkpoint()
685 if self._restart_strategy is not None:
686 self._restart_strategy.diffuse_application_restart(self._app_id)
687 self.session_available(self._manager.storage.id)
688@@ -558,7 +560,7 @@
689 UsageExpectation.of(self).allowed_calls = {
690 self.select_test_plan: "to save test plan selection",
691 }
692- return self._resume_candidates[session_id].metadata
693+ return self._metadata
694
695 @raises(UnexpectedMethodCall)
696 def get_resumable_sessions(self) -> 'Tuple[str, SessionMetaData]':
697@@ -1187,6 +1189,7 @@
698 UsageExpectation.of(self).enforce()
699 job = self._context.get_unit(job_id, 'job')
700 self._context.state.update_job_result(job, result)
701+ self._manager.checkpoint()
702 # Set up expectations so that run_job() and use_job_result() must be
703 # called in pairs and applications cannot just forget and call
704 # run_job() all the time.
705@@ -1284,7 +1287,11 @@
706 exported_stream = io.BytesIO()
707 exporter.dump_from_session_manager(self._manager, exported_stream)
708 exported_stream.seek(0)
709- return transport.send(exported_stream)
710+ result = transport.send(exported_stream)
711+ if SessionMetaData.FLAG_SUBMITTED not in self._metadata.flags:
712+ self._metadata.flags.add(SessionMetaData.FLAG_SUBMITTED)
713+ self._manager.checkpoint()
714+ return result
715
716 @raises(KeyError, OSError)
717 def export_to_file(
718@@ -1312,7 +1319,8 @@
719 When there is a problem when writing the output.
720 """
721 UsageExpectation.of(self).enforce()
722- exporter = self._manager.create_exporter(exporter_id, option_list)
723+ exporter = self._manager.create_exporter(exporter_id, option_list,
724+ strict=False)
725 timestamp = datetime.datetime.utcnow().isoformat()
726 path = os.path.join(dir_path, ''.join(
727 ['submission_', timestamp, '.', exporter.unit.file_extension]))
728
729=== modified file 'plainbox/plainbox/impl/session/restart.py'
730--- plainbox/plainbox/impl/session/restart.py 2015-11-04 14:25:10 +0000
731+++ plainbox/plainbox/impl/session/restart.py 2015-12-02 09:12:28 +0000
732@@ -75,6 +75,7 @@
733 The command callback
734 """
735 self.config = config = PlainBoxConfigParser()
736+ self.app_terminal = app_terminal
737 section = 'Desktop Entry'
738 config.add_section(section)
739 config.set(section, 'Type', 'Application')
740@@ -99,6 +100,8 @@
741
742 def prime_application_restart(self, app_id: str, cmd: str) -> None:
743 filename = self.get_desktop_filename(app_id)
744+ if self.app_terminal:
745+ cmd += ';$SHELL'
746 self.config.set('Desktop Entry', 'Exec', cmd)
747 os.makedirs(os.path.dirname(filename), exist_ok=True)
748 with open(filename, 'wt') as stream:

Subscribers

People subscribed via source and target branches