Merge lp:~sylvain-pineau/checkbox/checkbox-ng-sa into lp:checkbox
- checkbox-ng-sa
- Merge into trunk
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 |
Related bugs: |
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
Sylvain Pineau (community) | Needs Resubmitting | ||
Maciej Kisielewski | Needs Fixing | ||
Review via email: mp+275925@code.launchpad.net |
Commit message
Description of the change
checkbox-cli rewritten using plainbox SessionAssistant as a standalone tool (called checkbox-cli2 to avoid conflicts)
Zygmunt Krynicki (zyga) wrote : | # |
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
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_
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
- 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.
Sylvain Pineau (sylvain-pineau) wrote : | # |
_export_results fixed to use sa export to stream. and rev 4121 typo
Sylvain Pineau (sylvain-pineau) wrote : | # |
Time to fix ppa packaging now
Preview Diff
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: |
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.