Merge ~sylvain-pineau/checkbox-ng:urwid_manifest into checkbox-ng:master
- Git
- lp:~sylvain-pineau/checkbox-ng
- urwid_manifest
- Merge into master
Status: | Rejected |
---|---|
Rejected by: | Sylvain Pineau |
Proposed branch: | ~sylvain-pineau/checkbox-ng:urwid_manifest |
Merge into: | checkbox-ng:master |
Diff against target: |
537 lines (+343/-2) 8 files modified
checkbox_ng/launcher/master.py (+14/-1) checkbox_ng/launcher/subcommands.py (+12/-0) checkbox_ng/urwid_ui.py (+131/-0) docs/launcher-tutorial.rst (+20/-0) plainbox/impl/launcher.py (+2/-0) plainbox/impl/resource.py (+54/-0) plainbox/impl/session/assistant.py (+104/-1) plainbox/impl/session/remote_assistant.py (+6/-0) |
Related bugs: |
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
Devices Certification Bot | Needs Fixing | ||
Sylvain Pineau (community) | Needs Fixing | ||
Jonathan Cave (community) | Approve | ||
Maciej Kisielewski (community) | Needs Fixing | ||
Review via email:
|
Commit message
Description of the change
Manifest V2 (backward compatible with existing test plans, collect manifest will be automatically excluded from run list)
Tested with the following variants:
$ checkbox-cli
$ checkbox-cli launcher
$ checkbox-cli ./mylauncher
$ checkbox-cli master 127.0.0.1 ./mylauncher
$ checkbox-cli master 127.0.0.1
$ checkbox-cli master 192.168.1.47 ./mylauncher
$ checkbox-cli master 192.168.1.47
w/ and w/o an existing ~/.local/
using this type of launcher:
[launcher]
app_id = com.canonical.
launcher_version = 1
stock_reports = text, submission_files
[test plan]
unit = com.canonical.
forced = yes
[test selection]
forced = yes
[manifest]
com.canonical.
com.canonical.
com.canonical.
![](/+icing/build/overlay/assets/skins/sam/images/close.gif)
Jonathan Cave (jocave) wrote : | # |
Really like this, I think it is a big improvement over the existing implementation already.
A few of the usability tweaks Maciej mentions could elevate it even further, although I personally think the two radio buttons works in this case. The tweaks I would like are mostly the shortcut related functionality:
- shortcut keys y/n for selection of bool entries
- hitting y/n advances the active entry i.e. drops it down a line
- some kind of surround for the edit widget on natural entries to make them easier to spot
square brackets could be enough: [ 123 ]
- hitting return on natural entries advances the active entry
This means that for quick completion of e.g.:
bool
bool
natural
bool
you could hit:
y n 123 enter y t
![](/+icing/build/overlay/assets/skins/sam/images/close.gif)
Sylvain Pineau (sylvain-pineau) wrote : | # |
just rebased on master to include the latest commits (and resolving conflicts around chosen_jobs).
Thanks for the shortcuts ideas, I'll add them to the unhandled_input urwid method(s).
Original comments can still be visible if you select a the first version in the Preview Diff dropdown list below.
![](/+icing/build/overlay/assets/skins/sam/images/close.gif)
Sylvain Pineau (sylvain-pineau) wrote : | # |
resubmitting after another rebase
![](/+icing/build/overlay/assets/skins/sam/images/close.gif)
Jonathan Cave (jocave) wrote : | # |
My only objections are UI imperfections which I do not think should block landing the improvement in overall functionality.
![](/+icing/build/overlay/assets/skins/sam/images/close.gif)
Devices Certification Bot (ce-certification-qa) wrote : | # |
I tried to merge it but there are some problems. Typically you want to merge or rebase and try again.
![](/+icing/build/overlay/assets/skins/sam/images/close.gif)
Sylvain Pineau (sylvain-pineau) wrote : | # |
I tried to merge it but there are some problems. Typically you want to merge or rebase and try again.
![](/+icing/build/overlay/assets/skins/sam/images/close.gif)
Sylvain Pineau (sylvain-pineau) wrote : | # |
I tried to merge it but there are some problems. Typically you want to merge or rebase and try again.
![](/+icing/build/overlay/assets/skins/sam/images/close.gif)
Devices Certification Bot (ce-certification-qa) wrote : | # |
I tried to merge it but there are some problems. Typically you want to merge or rebase and try again.
![](/+icing/build/overlay/assets/skins/sam/images/close.gif)
Sylvain Pineau (sylvain-pineau) wrote : | # |
Unmerged commits
- d7ad6d6... by Sylvain Pineau
-
master: Save the launcher manifest section on disk when test_selection_
forced is set - d5da540... by Sylvain Pineau
-
master: Add the system manifest urwird step
- 507efcb... by Sylvain Pineau
-
subcommands:
launcher: Save the launcher manifest section on disk when test_selection_ forced is set - 05b98b1... by Sylvain Pineau
-
subcommands:
launcher: Add the system manifest urwird step - cc4bdae... by Sylvain Pineau
-
urwid: Add the ManifestBrowser screen
- 6a355c4... by Sylvain Pineau
-
session:
remote_ assistant: Add both get_manifest_repr and save_manifest
Preview Diff
1 | diff --git a/checkbox_ng/launcher/master.py b/checkbox_ng/launcher/master.py | |||
2 | index 5c9afcf..2107fbf 100644 | |||
3 | --- a/checkbox_ng/launcher/master.py | |||
4 | +++ b/checkbox_ng/launcher/master.py | |||
5 | @@ -36,11 +36,13 @@ from tempfile import SpooledTemporaryFile | |||
6 | 36 | 36 | ||
7 | 37 | from plainbox.impl.color import Colorizer | 37 | from plainbox.impl.color import Colorizer |
8 | 38 | from plainbox.impl.launcher import DefaultLauncherDefinition | 38 | from plainbox.impl.launcher import DefaultLauncherDefinition |
9 | 39 | from plainbox.impl.secure.config import Unset | ||
10 | 39 | from plainbox.impl.secure.sudo_broker import SudoProvider | 40 | from plainbox.impl.secure.sudo_broker import SudoProvider |
11 | 40 | from plainbox.impl.session.remote_assistant import RemoteSessionAssistant | 41 | from plainbox.impl.session.remote_assistant import RemoteSessionAssistant |
12 | 41 | from plainbox.vendor import rpyc | 42 | from plainbox.vendor import rpyc |
13 | 42 | from checkbox_ng.urwid_ui import TestPlanBrowser | 43 | from checkbox_ng.urwid_ui import TestPlanBrowser |
14 | 43 | from checkbox_ng.urwid_ui import CategoryBrowser | 44 | from checkbox_ng.urwid_ui import CategoryBrowser |
15 | 45 | from checkbox_ng.urwid_ui import ManifestBrowser | ||
16 | 44 | from checkbox_ng.urwid_ui import ReRunBrowser | 46 | from checkbox_ng.urwid_ui import ReRunBrowser |
17 | 45 | from checkbox_ng.urwid_ui import interrupt_dialog | 47 | from checkbox_ng.urwid_ui import interrupt_dialog |
18 | 46 | from checkbox_ng.urwid_ui import resume_dialog | 48 | from checkbox_ng.urwid_ui import resume_dialog |
19 | @@ -284,7 +286,13 @@ class RemoteMaster(ReportsStage, MainLoopStage): | |||
20 | 284 | self.jobs = self.sa.finish_bootstrap() | 286 | self.jobs = self.sa.finish_bootstrap() |
21 | 285 | 287 | ||
22 | 286 | def select_jobs(self, all_jobs): | 288 | def select_jobs(self, all_jobs): |
24 | 287 | if not self.launcher.test_selection_forced: | 289 | if self.launcher.test_selection_forced: |
25 | 290 | if self.launcher.manifest is not Unset: | ||
26 | 291 | self.sa.save_manifest( | ||
27 | 292 | {manifest_id: self.launcher.manifest[manifest_id] for | ||
28 | 293 | manifest_id in self.launcher.manifest} | ||
29 | 294 | ) | ||
30 | 295 | else: | ||
31 | 288 | _logger.info("master: Selecting jobs.") | 296 | _logger.info("master: Selecting jobs.") |
32 | 289 | reprs = self.sa.get_jobs_repr(all_jobs) | 297 | reprs = self.sa.get_jobs_repr(all_jobs) |
33 | 290 | wanted_set = CategoryBrowser( | 298 | wanted_set = CategoryBrowser( |
34 | @@ -296,6 +304,11 @@ class RemoteMaster(ReportsStage, MainLoopStage): | |||
35 | 296 | chosen_jobs = [job for job in all_jobs if job in wanted_set] | 304 | chosen_jobs = [job for job in all_jobs if job in wanted_set] |
36 | 297 | _logger.debug("master: Selected jobs: %s", chosen_jobs) | 305 | _logger.debug("master: Selected jobs: %s", chosen_jobs) |
37 | 298 | self.sa.modify_todo_list(chosen_jobs) | 306 | self.sa.modify_todo_list(chosen_jobs) |
38 | 307 | manifest_repr = self.sa.get_manifest_repr() | ||
39 | 308 | if manifest_repr: | ||
40 | 309 | manifest_answers = ManifestBrowser( | ||
41 | 310 | "System Manifest:", manifest_repr).run() | ||
42 | 311 | self.sa.save_manifest(manifest_answers) | ||
43 | 299 | self.sa.finish_job_selection() | 312 | self.sa.finish_job_selection() |
44 | 300 | self.run_jobs() | 313 | self.run_jobs() |
45 | 301 | 314 | ||
46 | diff --git a/checkbox_ng/launcher/subcommands.py b/checkbox_ng/launcher/subcommands.py | |||
47 | index 2a33b13..75e6770 100644 | |||
48 | --- a/checkbox_ng/launcher/subcommands.py | |||
49 | +++ b/checkbox_ng/launcher/subcommands.py | |||
50 | @@ -47,6 +47,7 @@ from plainbox.impl.providers import get_providers | |||
51 | 47 | from plainbox.impl.providers.embedded_providers import ( | 47 | from plainbox.impl.providers.embedded_providers import ( |
52 | 48 | EmbeddedProvider1PlugInCollection) | 48 | EmbeddedProvider1PlugInCollection) |
53 | 49 | from plainbox.impl.result import MemoryJobResult | 49 | from plainbox.impl.result import MemoryJobResult |
54 | 50 | from plainbox.impl.secure.config import Unset | ||
55 | 50 | from plainbox.impl.secure.sudo_broker import sudo_password_provider | 51 | from plainbox.impl.secure.sudo_broker import sudo_password_provider |
56 | 51 | from plainbox.impl.session.assistant import SessionAssistant, SA_RESTARTABLE | 52 | from plainbox.impl.session.assistant import SessionAssistant, SA_RESTARTABLE |
57 | 52 | from plainbox.impl.session.jobs import InhibitionCause | 53 | from plainbox.impl.session.jobs import InhibitionCause |
58 | @@ -64,6 +65,7 @@ from checkbox_ng.launcher.startprovider import ( | |||
59 | 64 | from checkbox_ng.launcher.run import Action | 65 | from checkbox_ng.launcher.run import Action |
60 | 65 | from checkbox_ng.launcher.run import NormalUI | 66 | from checkbox_ng.launcher.run import NormalUI |
61 | 66 | from checkbox_ng.urwid_ui import CategoryBrowser | 67 | from checkbox_ng.urwid_ui import CategoryBrowser |
62 | 68 | from checkbox_ng.urwid_ui import ManifestBrowser | ||
63 | 67 | from checkbox_ng.urwid_ui import ReRunBrowser | 69 | from checkbox_ng.urwid_ui import ReRunBrowser |
64 | 68 | from checkbox_ng.urwid_ui import TestPlanBrowser | 70 | from checkbox_ng.urwid_ui import TestPlanBrowser |
65 | 69 | 71 | ||
66 | @@ -401,6 +403,11 @@ class Launcher(MainLoopStage, ReportsStage): | |||
67 | 401 | 403 | ||
68 | 402 | def _pick_jobs_to_run(self): | 404 | def _pick_jobs_to_run(self): |
69 | 403 | if self.launcher.test_selection_forced: | 405 | if self.launcher.test_selection_forced: |
70 | 406 | if self.launcher.manifest is not Unset: | ||
71 | 407 | self.ctx.sa.save_manifest( | ||
72 | 408 | {manifest_id: self.launcher.manifest[manifest_id] for | ||
73 | 409 | manifest_id in self.launcher.manifest} | ||
74 | 410 | ) | ||
75 | 404 | # by default all tests are selected; so we're done here | 411 | # by default all tests are selected; so we're done here |
76 | 405 | return | 412 | return |
77 | 406 | job_list = [self.ctx.sa.get_job(job_id) for job_id in | 413 | job_list = [self.ctx.sa.get_job(job_id) for job_id in |
78 | @@ -411,6 +418,11 @@ class Launcher(MainLoopStage, ReportsStage): | |||
79 | 411 | test_info_list = self._generate_job_infos(job_list) | 418 | test_info_list = self._generate_job_infos(job_list) |
80 | 412 | wanted_set = CategoryBrowser( | 419 | wanted_set = CategoryBrowser( |
81 | 413 | _("Choose tests to run on your system:"), test_info_list).run() | 420 | _("Choose tests to run on your system:"), test_info_list).run() |
82 | 421 | manifest_repr = self.ctx.sa.get_manifest_repr() | ||
83 | 422 | if manifest_repr: | ||
84 | 423 | manifest_answers = ManifestBrowser( | ||
85 | 424 | "System Manifest:", manifest_repr).run() | ||
86 | 425 | self.ctx.sa.save_manifest(manifest_answers) | ||
87 | 414 | # no need to set an alternate selection if the job list not changed | 426 | # no need to set an alternate selection if the job list not changed |
88 | 415 | if len(test_info_list) == len(wanted_set): | 427 | if len(test_info_list) == len(wanted_set): |
89 | 416 | return | 428 | return |
90 | diff --git a/checkbox_ng/urwid_ui.py b/checkbox_ng/urwid_ui.py | |||
91 | index 569e0df..2d5b579 100644 | |||
92 | --- a/checkbox_ng/urwid_ui.py | |||
93 | +++ b/checkbox_ng/urwid_ui.py | |||
94 | @@ -744,6 +744,137 @@ class CountdownWidget(urwid.BigText): | |||
95 | 744 | raise urwid.ExitMainLoop | 744 | raise urwid.ExitMainLoop |
96 | 745 | 745 | ||
97 | 746 | 746 | ||
98 | 747 | class ManifestNaturalEdit(urwid.IntEdit): | ||
99 | 748 | |||
100 | 749 | def keypress(self, size, key): | ||
101 | 750 | (maxcol,) = size | ||
102 | 751 | return urwid.Edit.keypress(self, (maxcol,), key) | ||
103 | 752 | |||
104 | 753 | def value(self): | ||
105 | 754 | if self.edit_text: | ||
106 | 755 | return int(self.edit_text) | ||
107 | 756 | |||
108 | 757 | |||
109 | 758 | class ManifestQuestion(urwid.WidgetWrap): | ||
110 | 759 | |||
111 | 760 | def __init__(self, question): | ||
112 | 761 | self.id = question['id'] | ||
113 | 762 | self._value = question['value'] | ||
114 | 763 | self._value_type = question['value_type'] | ||
115 | 764 | if self._value_type == 'bool': | ||
116 | 765 | self.options = [] | ||
117 | 766 | yes = urwid.RadioButton( | ||
118 | 767 | self.options, "Yes", state=False, | ||
119 | 768 | on_state_change=self._set_bool_value) | ||
120 | 769 | no = urwid.RadioButton( | ||
121 | 770 | self.options, "No", state=False, | ||
122 | 771 | on_state_change=self._set_bool_value) | ||
123 | 772 | if question['value'] is not None: | ||
124 | 773 | if question['value'] is True: | ||
125 | 774 | yes.set_state(True) | ||
126 | 775 | else: | ||
127 | 776 | no.set_state(True) | ||
128 | 777 | self.display_widget = urwid.Columns([ | ||
129 | 778 | urwid.Padding(urwid.Text(question['name']), left=2), | ||
130 | 779 | urwid.GridFlow([yes, no], 7, 3, 1, align='left') | ||
131 | 780 | ], dividechars=5) | ||
132 | 781 | urwid.WidgetWrap.__init__(self, self.display_widget) | ||
133 | 782 | elif self._value_type == 'natural': | ||
134 | 783 | self._edit_widget = ManifestNaturalEdit(u"", self._value) | ||
135 | 784 | self.display_widget = urwid.Columns([ | ||
136 | 785 | urwid.Padding(urwid.Text(question['name']), left=2), | ||
137 | 786 | (8, urwid.Padding(urwid.Text("["), left=7)), | ||
138 | 787 | self._edit_widget, | ||
139 | 788 | (1, urwid.Text("]")) | ||
140 | 789 | ]) | ||
141 | 790 | urwid.WidgetWrap.__init__(self, self.display_widget) | ||
142 | 791 | |||
143 | 792 | def _set_bool_value(self, w, new_state, user_data=None): | ||
144 | 793 | if w.label == 'Yes' and new_state: | ||
145 | 794 | self._value = new_state | ||
146 | 795 | elif w.label == 'No' and new_state: | ||
147 | 796 | self._value = False | ||
148 | 797 | |||
149 | 798 | @property | ||
150 | 799 | def value(self): | ||
151 | 800 | if self._value_type == 'bool': | ||
152 | 801 | return self._value | ||
153 | 802 | elif self._value_type == 'natural': | ||
154 | 803 | return self._edit_widget.value() | ||
155 | 804 | |||
156 | 805 | |||
157 | 806 | class ManifestBrowser: | ||
158 | 807 | palette = [ | ||
159 | 808 | ('body', 'light gray', 'black'), | ||
160 | 809 | ('buttnf', 'black', 'light gray'), | ||
161 | 810 | ('buttn', 'light gray', 'black', 'bold'), | ||
162 | 811 | ('head', 'black', 'light gray', 'standout'), | ||
163 | 812 | ('foot', 'light gray', 'black'), | ||
164 | 813 | ('title', 'white', 'black', 'bold'), | ||
165 | 814 | ('start', 'dark green,bold', 'black'), | ||
166 | 815 | ('bold', 'bold', 'black'), | ||
167 | 816 | ] | ||
168 | 817 | |||
169 | 818 | footer_text = [('Press ('), ('start', 'T'), (') to start Testing')] | ||
170 | 819 | footer_shortcuts = [('Shortcuts: '), ('bold', 'y'), ('/'), ('bold', 'n ')] | ||
171 | 820 | |||
172 | 821 | def __init__(self, title, manifest): | ||
173 | 822 | self.manifest = manifest | ||
174 | 823 | self._manifest_out = {} | ||
175 | 824 | self._widget_cache = [] | ||
176 | 825 | # Header | ||
177 | 826 | self.header = urwid.Padding(urwid.Text(title), left=1) | ||
178 | 827 | # Body | ||
179 | 828 | content = [] | ||
180 | 829 | for prompt, questions in sorted(self.manifest.items()): | ||
181 | 830 | content.append(urwid.Text(prompt)) | ||
182 | 831 | for q in sorted(questions, key=lambda i: i['name']): | ||
183 | 832 | question_widget = ManifestQuestion(q) | ||
184 | 833 | content.append(urwid.AttrWrap(question_widget, | ||
185 | 834 | 'buttn', 'buttnf')) | ||
186 | 835 | self._widget_cache.append(question_widget) | ||
187 | 836 | self._pile = urwid.Pile(content) | ||
188 | 837 | listbox_content = [ | ||
189 | 838 | urwid.Padding(self._pile, left=1, right=1, min_width=13), | ||
190 | 839 | ] | ||
191 | 840 | self.listbox = urwid.ListBox(urwid.SimpleListWalker(listbox_content)) | ||
192 | 841 | # Footer | ||
193 | 842 | self.default_footer = urwid.AttrWrap(urwid.Columns( | ||
194 | 843 | [urwid.Padding(urwid.Text(self.footer_text), left=1), | ||
195 | 844 | urwid.Text(self.footer_shortcuts, 'right')]), 'foot') | ||
196 | 845 | # Main frame | ||
197 | 846 | self.frame = urwid.Frame( | ||
198 | 847 | urwid.AttrWrap(urwid.LineBox(self.listbox), 'body'), | ||
199 | 848 | header=urwid.AttrWrap(self.header, 'head'), | ||
200 | 849 | footer=self.default_footer) | ||
201 | 850 | |||
202 | 851 | def run(self): | ||
203 | 852 | """Run the urwid MainLoop.""" | ||
204 | 853 | self.loop = urwid.MainLoop( | ||
205 | 854 | self.frame, self.palette, unhandled_input=self.unhandled_input, | ||
206 | 855 | handle_mouse=False) | ||
207 | 856 | self.loop.run() | ||
208 | 857 | for w in self._widget_cache: | ||
209 | 858 | self._manifest_out.update({w.id: w.value}) | ||
210 | 859 | return self._manifest_out | ||
211 | 860 | |||
212 | 861 | def unhandled_input(self, key): | ||
213 | 862 | if key in ('t', 'T'): | ||
214 | 863 | for w in self._widget_cache: | ||
215 | 864 | if w.value is None: | ||
216 | 865 | break | ||
217 | 866 | else: | ||
218 | 867 | raise urwid.ExitMainLoop() | ||
219 | 868 | if self._pile.focus._value_type == 'bool': | ||
220 | 869 | if key in ('y', 'Y'): | ||
221 | 870 | self.loop.process_input(["left", " ", "down"]) | ||
222 | 871 | elif key in ('n', 'N'): | ||
223 | 872 | self.loop.process_input(["right", " ", "down"]) | ||
224 | 873 | elif self._pile.focus._value_type == 'natural': | ||
225 | 874 | if key == 'enter': | ||
226 | 875 | self.loop.process_input(["down"]) | ||
227 | 876 | |||
228 | 877 | |||
229 | 747 | def resume_dialog(duration): | 878 | def resume_dialog(duration): |
230 | 748 | palette = [ | 879 | palette = [ |
231 | 749 | ('body', 'light gray', 'black', 'standout'), | 880 | ('body', 'light gray', 'black', 'standout'), |
232 | diff --git a/docs/launcher-tutorial.rst b/docs/launcher-tutorial.rst | |||
233 | index 81c8d41..5d50e48 100644 | |||
234 | --- a/docs/launcher-tutorial.rst | |||
235 | +++ b/docs/launcher-tutorial.rst | |||
236 | @@ -392,6 +392,26 @@ Checkbox-slave daemon is run by root so in order to run some jobs as an | |||
237 | 392 | unpriviledged user this variable can be used. | 392 | unpriviledged user this variable can be used. |
238 | 393 | 393 | ||
239 | 394 | 394 | ||
240 | 395 | Manifest section | ||
241 | 396 | ================ | ||
242 | 397 | |||
243 | 398 | ``[manifest]`` | ||
244 | 399 | |||
245 | 400 | Beginning of the manifest section. | ||
246 | 401 | |||
247 | 402 | Each variable present in the ``manifest`` section will be used a a preset value | ||
248 | 403 | for the system manifest, taking precedence over the disk cache. | ||
249 | 404 | |||
250 | 405 | Example: | ||
251 | 406 | |||
252 | 407 | :: | ||
253 | 408 | |||
254 | 409 | [manifest] | ||
255 | 410 | com.canonical.certification::has_touchscreen = yes | ||
256 | 411 | com.canonical.certification::has_usb_type_c = true | ||
257 | 412 | com.canonical.certification::foo = 23 | ||
258 | 413 | |||
259 | 414 | |||
260 | 395 | Generating reports | 415 | Generating reports |
261 | 396 | ================== | 416 | ================== |
262 | 397 | 417 | ||
263 | diff --git a/plainbox/impl/launcher.py b/plainbox/impl/launcher.py | |||
264 | index 9c0b8f6..713d0af 100644 | |||
265 | --- a/plainbox/impl/launcher.py | |||
266 | +++ b/plainbox/impl/launcher.py | |||
267 | @@ -252,5 +252,7 @@ class LauncherDefinition1(LauncherDefinition): | |||
268 | 252 | name='daemon', | 252 | name='daemon', |
269 | 253 | help_text=_('Daemon-specific configuration')) | 253 | help_text=_('Daemon-specific configuration')) |
270 | 254 | 254 | ||
271 | 255 | manifest = config.Section( | ||
272 | 256 | help_text=_('Manifest entries to use')) | ||
273 | 255 | 257 | ||
274 | 256 | DefaultLauncherDefinition = LauncherDefinition1 | 258 | DefaultLauncherDefinition = LauncherDefinition1 |
275 | diff --git a/plainbox/impl/resource.py b/plainbox/impl/resource.py | |||
276 | index 26dacc7..a0a74a6 100644 | |||
277 | --- a/plainbox/impl/resource.py | |||
278 | +++ b/plainbox/impl/resource.py | |||
279 | @@ -394,6 +394,7 @@ class ResourceNodeVisitor(ast.NodeVisitor): | |||
280 | 394 | """ | 394 | """ |
281 | 395 | self._ids_seen_set = set() | 395 | self._ids_seen_set = set() |
282 | 396 | self._ids_seen_list = [] | 396 | self._ids_seen_list = [] |
283 | 397 | self._manifest_attr_seen_list = [] | ||
284 | 397 | 398 | ||
285 | 398 | @property | 399 | @property |
286 | 399 | def ids_seen_set(self): | 400 | def ids_seen_set(self): |
287 | @@ -409,6 +410,13 @@ class ResourceNodeVisitor(ast.NodeVisitor): | |||
288 | 409 | """ | 410 | """ |
289 | 410 | return self._ids_seen_list | 411 | return self._ids_seen_list |
290 | 411 | 412 | ||
291 | 413 | @property | ||
292 | 414 | def manifest_attr_seen_list(self): | ||
293 | 415 | """ | ||
294 | 416 | list() of ast.Attribute().attr values seen | ||
295 | 417 | """ | ||
296 | 418 | return self._manifest_attr_seen_list | ||
297 | 419 | |||
298 | 412 | def visit_Name(self, node): | 420 | def visit_Name(self, node): |
299 | 413 | """ | 421 | """ |
300 | 414 | Internal method of NodeVisitor. | 422 | Internal method of NodeVisitor. |
301 | @@ -421,6 +429,20 @@ class ResourceNodeVisitor(ast.NodeVisitor): | |||
302 | 421 | self._ids_seen_set.add(node.id) | 429 | self._ids_seen_set.add(node.id) |
303 | 422 | self._ids_seen_list.append(node.id) | 430 | self._ids_seen_list.append(node.id) |
304 | 423 | 431 | ||
305 | 432 | def visit_Attribute(self, node): | ||
306 | 433 | """ | ||
307 | 434 | Internal method of NodeVisitor. | ||
308 | 435 | |||
309 | 436 | This method is called whenever generic_visit() looks at an instance of | ||
310 | 437 | ast.Attribute(). It records the attr identifier | ||
311 | 438 | """ | ||
312 | 439 | self._check_node(node) | ||
313 | 440 | if isinstance(node.value, ast.Name): | ||
314 | 441 | self.visit_Name(node.value) | ||
315 | 442 | if node.value.id == 'manifest': | ||
316 | 443 | if node.attr not in self._manifest_attr_seen_list: | ||
317 | 444 | self._manifest_attr_seen_list.append(node.attr) | ||
318 | 445 | |||
319 | 424 | def visit_Call(self, node): | 446 | def visit_Call(self, node): |
320 | 425 | """ | 447 | """ |
321 | 426 | Internal method of NodeVisitor. | 448 | Internal method of NodeVisitor. |
322 | @@ -516,6 +538,7 @@ class ResourceExpression: | |||
323 | 516 | """ | 538 | """ |
324 | 517 | self._implicit_namespace = implicit_namespace | 539 | self._implicit_namespace = implicit_namespace |
325 | 518 | self._resource_alias_list = self._analyze(text) | 540 | self._resource_alias_list = self._analyze(text) |
326 | 541 | self._manifest_id_list = self._analyze_manifest(text) | ||
327 | 519 | self._resource_id_list = [] | 542 | self._resource_id_list = [] |
328 | 520 | if imports is None: | 543 | if imports is None: |
329 | 521 | imports = () | 544 | imports = () |
330 | @@ -572,6 +595,10 @@ class ResourceExpression: | |||
331 | 572 | ] | 595 | ] |
332 | 573 | 596 | ||
333 | 574 | @property | 597 | @property |
334 | 598 | def manifest_id_list(self): | ||
335 | 599 | return self._manifest_id_list | ||
336 | 600 | |||
337 | 601 | @property | ||
338 | 575 | def resource_alias_list(self): | 602 | def resource_alias_list(self): |
339 | 576 | """ | 603 | """ |
340 | 577 | The alias of the resource object this expression operates on | 604 | The alias of the resource object this expression operates on |
341 | @@ -648,6 +675,33 @@ class ResourceExpression: | |||
342 | 648 | else: | 675 | else: |
343 | 649 | return list(visitor.ids_seen_list) | 676 | return list(visitor.ids_seen_list) |
344 | 650 | 677 | ||
345 | 678 | def _analyze_manifest(self, text): | ||
346 | 679 | """ | ||
347 | 680 | Analyze the expression and return the id of the manifest resource | ||
348 | 681 | |||
349 | 682 | May raise SyntaxError or a ResourceProgramError subclass | ||
350 | 683 | """ | ||
351 | 684 | # Use the ast module to build an abstract syntax tree of the expression | ||
352 | 685 | try: | ||
353 | 686 | node = ast.parse(text) | ||
354 | 687 | except SyntaxError: | ||
355 | 688 | raise ResourceSyntaxError | ||
356 | 689 | # Use ResourceNodeVisitor to see what kind of ast.Name objects are | ||
357 | 690 | # referenced by the expression. This may also raise CodeNotAllowed | ||
358 | 691 | # which should be captured by the higher layers. | ||
359 | 692 | visitor = ResourceNodeVisitor() | ||
360 | 693 | visitor.visit(node) | ||
361 | 694 | # Bail if the expression is not using exactly one resource id | ||
362 | 695 | if len(visitor.ids_seen_list) == 0: | ||
363 | 696 | raise NoResourcesReferenced() | ||
364 | 697 | else: | ||
365 | 698 | return [ | ||
366 | 699 | "{}::{}".format(self._implicit_namespace, manifest_id) | ||
367 | 700 | if "::" not in manifest_id and self._implicit_namespace | ||
368 | 701 | else manifest_id | ||
369 | 702 | for manifest_id in list(visitor.manifest_attr_seen_list) | ||
370 | 703 | ] | ||
371 | 704 | |||
372 | 651 | 705 | ||
373 | 652 | def parse_imports_stmt(imports): | 706 | def parse_imports_stmt(imports): |
374 | 653 | """ | 707 | """ |
375 | diff --git a/plainbox/impl/session/assistant.py b/plainbox/impl/session/assistant.py | |||
376 | index 8ef05e7..653bc4c 100644 | |||
377 | --- a/plainbox/impl/session/assistant.py | |||
378 | +++ b/plainbox/impl/session/assistant.py | |||
379 | @@ -50,9 +50,11 @@ from plainbox.impl.providers.embedded_providers import ( | |||
380 | 50 | from plainbox.impl.result import JobResultBuilder | 50 | from plainbox.impl.result import JobResultBuilder |
381 | 51 | from plainbox.impl.result import MemoryJobResult | 51 | from plainbox.impl.result import MemoryJobResult |
382 | 52 | from plainbox.impl.runner import JobRunnerUIDelegate | 52 | from plainbox.impl.runner import JobRunnerUIDelegate |
383 | 53 | from plainbox.impl.secure.config import Unset | ||
384 | 53 | from plainbox.impl.secure.origin import Origin | 54 | from plainbox.impl.secure.origin import Origin |
385 | 54 | from plainbox.impl.secure.qualifiers import select_jobs | 55 | from plainbox.impl.secure.qualifiers import select_jobs |
386 | 55 | from plainbox.impl.secure.qualifiers import FieldQualifier | 56 | from plainbox.impl.secure.qualifiers import FieldQualifier |
387 | 57 | from plainbox.impl.secure.qualifiers import JobIdQualifier | ||
388 | 56 | from plainbox.impl.secure.qualifiers import PatternMatcher | 58 | from plainbox.impl.secure.qualifiers import PatternMatcher |
389 | 57 | from plainbox.impl.secure.qualifiers import RegExpJobQualifier | 59 | from plainbox.impl.secure.qualifiers import RegExpJobQualifier |
390 | 58 | from plainbox.impl.session import SessionMetaData | 60 | from plainbox.impl.session import SessionMetaData |
391 | @@ -209,6 +211,9 @@ class SessionAssistant: | |||
392 | 209 | "configure automatic restart capability") | 211 | "configure automatic restart capability") |
393 | 210 | allowed_calls[self.use_alternate_restart_strategy] = ( | 212 | allowed_calls[self.use_alternate_restart_strategy] = ( |
394 | 211 | "configure automatic restart capability") | 213 | "configure automatic restart capability") |
395 | 214 | # Manifest | ||
396 | 215 | self._manifest_path = os.path.expanduser( | ||
397 | 216 | '~/.local/share/plainbox/machine-manifest.json') | ||
398 | 212 | 217 | ||
399 | 213 | @raises(UnexpectedMethodCall, LookupError) | 218 | @raises(UnexpectedMethodCall, LookupError) |
400 | 214 | def configure_application_restart( | 219 | def configure_application_restart( |
401 | @@ -930,7 +935,9 @@ class SessionAssistant: | |||
402 | 930 | desired_job_list = select_jobs( | 935 | desired_job_list = select_jobs( |
403 | 931 | self._context.state.job_list, | 936 | self._context.state.job_list, |
404 | 932 | [plan.get_qualifier() for plan in self._manager.test_plans] + | 937 | [plan.get_qualifier() for plan in self._manager.test_plans] + |
406 | 933 | self._exclude_qualifiers) | 938 | self._exclude_qualifiers + |
407 | 939 | [JobIdQualifier( | ||
408 | 940 | 'com.canonical.plainbox::collect-manifest', None, False)]) | ||
409 | 934 | self._context.state.update_desired_job_list(desired_job_list) | 941 | self._context.state.update_desired_job_list(desired_job_list) |
410 | 935 | # Set subsequent usage expectations i.e. all of the runtime parts are | 942 | # Set subsequent usage expectations i.e. all of the runtime parts are |
411 | 936 | # available now. | 943 | # available now. |
412 | @@ -1244,6 +1251,100 @@ class SessionAssistant: | |||
413 | 1244 | if jsm[job.id].result.outcome is None | 1251 | if jsm[job.id].result.outcome is None |
414 | 1245 | ] | 1252 | ] |
415 | 1246 | 1253 | ||
416 | 1254 | def _strtobool(self, val): | ||
417 | 1255 | return val.lower() in ('y', 'yes', 't', 'true', 'on', '1') | ||
418 | 1256 | |||
419 | 1257 | @raises(SystemExit, UnexpectedMethodCall) | ||
420 | 1258 | def get_manifest_repr(self) -> 'Dict[List[Dict]]': | ||
421 | 1259 | """ | ||
422 | 1260 | Get the manifest units required by the jobs selection. | ||
423 | 1261 | |||
424 | 1262 | :returns: | ||
425 | 1263 | A dict of manifest questions. | ||
426 | 1264 | :raises SystemExit: | ||
427 | 1265 | When the launcher manifest section contains invalid entries. | ||
428 | 1266 | :raises UnexpectedMethodCall: | ||
429 | 1267 | If the call is made at an unexpected time. Do not catch this error. | ||
430 | 1268 | It is a bug in your program. The error message will indicate what | ||
431 | 1269 | is the likely cause. | ||
432 | 1270 | """ | ||
433 | 1271 | UsageExpectation.of(self).enforce() | ||
434 | 1272 | # XXX: job_state_map is a bit low level, can we avoid that? | ||
435 | 1273 | jsm = self._context.state.job_state_map | ||
436 | 1274 | todo_list = [ | ||
437 | 1275 | job for job in self._context.state.run_list | ||
438 | 1276 | if jsm[job.id].result.outcome is None | ||
439 | 1277 | ] | ||
440 | 1278 | expression_list = [] | ||
441 | 1279 | manifest_id_set = set() | ||
442 | 1280 | for job in todo_list: | ||
443 | 1281 | if job.get_resource_program(): | ||
444 | 1282 | expression_list.extend( | ||
445 | 1283 | job.get_resource_program().expression_list) | ||
446 | 1284 | for e in expression_list: | ||
447 | 1285 | manifest_id_set.update(e.manifest_id_list) | ||
448 | 1286 | manifest_list = [unit for unit in self._context.unit_list | ||
449 | 1287 | if unit.Meta.name == 'manifest entry' | ||
450 | 1288 | and unit.id in manifest_id_set] | ||
451 | 1289 | manifest_cache = {} | ||
452 | 1290 | if os.path.isfile(self._manifest_path): | ||
453 | 1291 | with open(self._manifest_path, 'rt', encoding='UTF-8') as stream: | ||
454 | 1292 | manifest_cache = json.load(stream) | ||
455 | 1293 | if self._config is not None and self._config.manifest is not Unset: | ||
456 | 1294 | for manifest_id in self._config.manifest: | ||
457 | 1295 | manifest_cache.update( | ||
458 | 1296 | {manifest_id: self._config.manifest[manifest_id]}) | ||
459 | 1297 | manifest_info_dict = dict() | ||
460 | 1298 | for m in manifest_list: | ||
461 | 1299 | prompt = m.prompt() | ||
462 | 1300 | if prompt is None: | ||
463 | 1301 | if m.value_type == 'bool': | ||
464 | 1302 | prompt = "Does this machine have this piece of hardware?" | ||
465 | 1303 | elif m.value_type == 'natural': | ||
466 | 1304 | prompt = "Please enter the requested data:" | ||
467 | 1305 | else: | ||
468 | 1306 | _logger.error("Unsupported value-type: '%s'", m.value_type) | ||
469 | 1307 | continue | ||
470 | 1308 | if prompt not in manifest_info_dict: | ||
471 | 1309 | manifest_info_dict[prompt] = [] | ||
472 | 1310 | manifest_info = { | ||
473 | 1311 | "id": m.id, | ||
474 | 1312 | "partial_id": m.partial_id, | ||
475 | 1313 | "name": m.name, | ||
476 | 1314 | "value_type": m.value_type, | ||
477 | 1315 | } | ||
478 | 1316 | try: | ||
479 | 1317 | value = manifest_cache[m.id] | ||
480 | 1318 | if m.value_type == 'bool': | ||
481 | 1319 | if isinstance(manifest_cache[m.id], str): | ||
482 | 1320 | value = self._strtobool(manifest_cache[m.id]) | ||
483 | 1321 | elif m.value_type == 'natural': | ||
484 | 1322 | value = int(manifest_cache[m.id]) | ||
485 | 1323 | except ValueError: | ||
486 | 1324 | _logger.error( | ||
487 | 1325 | ("Invalid manifest %s value '%s'"), | ||
488 | 1326 | m.id, manifest_cache[m.id]) | ||
489 | 1327 | raise SystemExit(1) | ||
490 | 1328 | except KeyError: | ||
491 | 1329 | value = None | ||
492 | 1330 | manifest_info.update({'value': value}) | ||
493 | 1331 | manifest_info_dict[prompt].append(manifest_info) | ||
494 | 1332 | return manifest_info_dict | ||
495 | 1333 | |||
496 | 1334 | def save_manifest(self, manifest_answers): | ||
497 | 1335 | """ | ||
498 | 1336 | Record the manifest on disk. | ||
499 | 1337 | """ | ||
500 | 1338 | manifest_cache = dict() | ||
501 | 1339 | if os.path.isfile(self._manifest_path): | ||
502 | 1340 | with open(self._manifest_path, 'rt', encoding='UTF-8') as stream: | ||
503 | 1341 | manifest_cache = json.load(stream) | ||
504 | 1342 | os.makedirs(os.path.dirname(self._manifest_path), exist_ok=True) | ||
505 | 1343 | manifest_cache.update(manifest_answers) | ||
506 | 1344 | print("Saving manifest to {}".format(self._manifest_path)) | ||
507 | 1345 | with open(self._manifest_path, 'wt', encoding='UTF-8') as stream: | ||
508 | 1346 | json.dump(manifest_cache, stream, sort_keys=True, indent=2) | ||
509 | 1347 | |||
510 | 1247 | @raises(ValueError, TypeError, UnexpectedMethodCall) | 1348 | @raises(ValueError, TypeError, UnexpectedMethodCall) |
511 | 1248 | def run_job( | 1349 | def run_job( |
512 | 1249 | self, job_id: str, ui: 'Union[str, IJobRunnerUI]', | 1350 | self, job_id: str, ui: 'Union[str, IJobRunnerUI]', |
513 | @@ -1728,6 +1829,8 @@ class SessionAssistant: | |||
514 | 1728 | self.remove_all_filters: "to remove all filters", | 1829 | self.remove_all_filters: "to remove all filters", |
515 | 1729 | self.get_static_todo_list: "to see what is meant to be executed", | 1830 | self.get_static_todo_list: "to see what is meant to be executed", |
516 | 1730 | self.get_dynamic_todo_list: "to see what is yet to be executed", | 1831 | self.get_dynamic_todo_list: "to see what is yet to be executed", |
517 | 1832 | self.get_manifest_repr: ( | ||
518 | 1833 | "to get participating manifest units"), | ||
519 | 1731 | self.run_job: "to run a given job", | 1834 | self.run_job: "to run a given job", |
520 | 1732 | self.use_alternate_selection: "to change the selection", | 1835 | self.use_alternate_selection: "to change the selection", |
521 | 1733 | self.hand_pick_jobs: "to generate new selection and use it", | 1836 | self.hand_pick_jobs: "to generate new selection and use it", |
522 | diff --git a/plainbox/impl/session/remote_assistant.py b/plainbox/impl/session/remote_assistant.py | |||
523 | index 9b91aeb..f5eb00e 100644 | |||
524 | --- a/plainbox/impl/session/remote_assistant.py | |||
525 | +++ b/plainbox/impl/session/remote_assistant.py | |||
526 | @@ -286,6 +286,12 @@ class RemoteSessionAssistant(): | |||
527 | 286 | job_state.attempts = self._launcher.max_attempts | 286 | job_state.attempts = self._launcher.max_attempts |
528 | 287 | return self._sa.get_static_todo_list() | 287 | return self._sa.get_static_todo_list() |
529 | 288 | 288 | ||
530 | 289 | def get_manifest_repr(self): | ||
531 | 290 | return self._sa.get_manifest_repr() | ||
532 | 291 | |||
533 | 292 | def save_manifest(self, manifest_answers): | ||
534 | 293 | return self._sa.save_manifest(manifest_answers) | ||
535 | 294 | |||
536 | 289 | def modify_todo_list(self, chosen_jobs): | 295 | def modify_todo_list(self, chosen_jobs): |
537 | 290 | self._sa.use_alternate_selection(chosen_jobs) | 296 | self._sa.use_alternate_selection(chosen_jobs) |
538 | 291 | 297 |
URWID review:
The new urwid screen is very difficult to use.
It took me solid one minute to figure out you have to use left/right arrows and a space-bar.
For a screen with one manifest option, there's no cursor movement, giving an impression that the program is frozen.
The '-' in front of the manifest option is misleading (on other screens it means unfolded tree).
I would prefer radio buttons to be on the left of the screen (or anywhere nearby to the label) - on panoramic screens it's easy to miss (happened to me the first time I tried it).
I think tooltip in the footer is necessary to guide the operator.
I would like a shortcut to select options (like y/n), so I could quickly go through the list.
I don't get why do we need two radio buttons for boolean variables. Why not use checkboxes as we do on other screens? Simpler code, simpler UX, etc.
Remote review:
- branch is not based on a fresh master, so testing remote as-is can by crashy, but I found that it can be rebased on master cleanly
- there's no distinct interaction step on the manifest screen, breaking the remote connection on that screen rolls the session back to test selection screen (just noticing, I'm not bothered by it)
- new RemoteSA method call means API bump is necessary
Code review:
Pleasant read. Some codes below.