Merge lp:~barry/ubuntu-system-image/lp1423622 into lp:~registry/ubuntu-system-image/client
- lp1423622
- Merge into client
Status: | Merged | ||||
---|---|---|---|---|---|
Merged at revision: | 318 | ||||
Proposed branch: | lp:~barry/ubuntu-system-image/lp1423622 | ||||
Merge into: | lp:~registry/ubuntu-system-image/client | ||||
Diff against target: |
369 lines (+201/-34) 6 files modified
systemimage/api.py (+5/-2) systemimage/curl.py (+3/-1) systemimage/download.py (+9/-5) systemimage/main.py (+54/-24) systemimage/tests/test_main.py (+127/-1) systemimage/udm.py (+3/-1) |
||||
To merge this branch: | bzr merge lp:~barry/ubuntu-system-image/lp1423622 | ||||
Related bugs: |
|
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
Michael Vogt | Pending | ||
Review via email: mp+251819@code.launchpad.net |
Commit message
Description of the change
--progress
Barry Warsaw (barry) wrote : | # |
Michael Vogt (mvo) wrote : | # |
Thanks for this work Barry, this looks very nice!
There are two changes I would love to see:
1. progress should be unbuffered (that was wrong in my initial branch, sorry). I.e. instead of print(), could you please use sys.stdout.write() and sys.stdout.flush() ?
2. if a exception happens in main.py (cf. http://
Barry Warsaw (barry) wrote : | # |
On Mar 05, 2015, at 08:05 AM, Michael Vogt wrote:
>1. progress should be unbuffered (that was wrong in my initial branch,
>sorry). I.e. instead of print(), could you please use sys.stdout.write() and
>sys.stdout.flush() ?
Good point, I should have noticed that. I'll fix that asap.
>2. if a exception happens in main.py
>(cf. http://
>it would be nice to get a json error on stdout. Not sure how well this fits
>into the new schema, I guess it is ok if I get a non-json error on stderr
>that I can just display to the user.
Okay, we chatted on IRC and decided we don't need much here. I've been
meaning to add a simple message to stderr in this case anyway, so that's what
I'll do now.
Preview Diff
1 | === modified file 'systemimage/api.py' |
2 | --- systemimage/api.py 2015-02-09 23:28:14 +0000 |
3 | +++ systemimage/api.py 2015-03-04 20:03:16 +0000 |
4 | @@ -26,7 +26,6 @@ |
5 | |
6 | from systemimage.apply import factory_reset, production_reset |
7 | from systemimage.state import State |
8 | -from unittest.mock import patch |
9 | |
10 | |
11 | log = logging.getLogger('systemimage') |
12 | @@ -119,8 +118,12 @@ |
13 | def download(self): |
14 | """Download the available update.""" |
15 | # We only want callback progress during the actual download. |
16 | - with patch.object(self._state.downloader, 'callback', self._callback): |
17 | + old_callbacks = self._state.downloader.callbacks[:] |
18 | + try: |
19 | + self._state.downloader.callbacks = [self._callback] |
20 | self._state.run_until('apply') |
21 | + finally: |
22 | + self._state.downloader.callbacks = old_callbacks |
23 | |
24 | def apply(self): |
25 | """Apply the update.""" |
26 | |
27 | === modified file 'systemimage/curl.py' |
28 | --- systemimage/curl.py 2015-02-13 21:44:50 +0000 |
29 | +++ systemimage/curl.py 2015-03-04 20:03:16 +0000 |
30 | @@ -130,7 +130,9 @@ |
31 | """The PyCURL based download manager.""" |
32 | |
33 | def __init__(self, callback=None): |
34 | - super().__init__(callback) |
35 | + super().__init__() |
36 | + if callback is not None: |
37 | + self.callbacks.append(callback) |
38 | self._pausables = [] |
39 | self._paused = False |
40 | |
41 | |
42 | === modified file 'systemimage/download.py' |
43 | --- systemimage/download.py 2015-02-13 21:44:50 +0000 |
44 | +++ systemimage/download.py 2015-03-04 20:03:16 +0000 |
45 | @@ -71,7 +71,7 @@ |
46 | class DownloadManagerBase: |
47 | """Base class for all download managers.""" |
48 | |
49 | - def __init__(self, callback=None): |
50 | + def __init__(self): |
51 | """ |
52 | :param callback: If given, a function that is called every so often |
53 | during downloading. |
54 | @@ -79,10 +79,14 @@ |
55 | of bytes received so far, and the total amount of bytes to be |
56 | downloaded. |
57 | """ |
58 | - self._queued_cancel = False |
59 | - self.callback = callback |
60 | + # This is a list of functions that are called every so often during |
61 | + # downloading. Functions in this list take two arguments, the number |
62 | + # of bytes received so far, and the total amount of bytes to be |
63 | + # downloaded. |
64 | + self.callbacks = [] |
65 | self.total = 0 |
66 | self.received = 0 |
67 | + self._queued_cancel = False |
68 | |
69 | def __repr__(self): # pragma: no cover |
70 | return '<{} at 0x{:x}>'.format(self.__class__.__name__, id(self)) |
71 | @@ -128,9 +132,9 @@ |
72 | def _do_callback(self): |
73 | # Be defensive, so yes, use a bare except. If an exception occurs in |
74 | # the callback, log it, but continue onward. |
75 | - if self.callback is not None: |
76 | + for callback in self.callbacks: |
77 | try: |
78 | - self.callback(self.received, self.total) |
79 | + callback(self.received, self.total) |
80 | except: |
81 | log.exception('Exception in progress callback') |
82 | |
83 | |
84 | === modified file 'systemimage/main.py' |
85 | --- systemimage/main.py 2015-02-09 18:46:24 +0000 |
86 | +++ systemimage/main.py 2015-03-04 20:03:16 +0000 |
87 | @@ -22,6 +22,7 @@ |
88 | |
89 | |
90 | import sys |
91 | +import json |
92 | import logging |
93 | import argparse |
94 | |
95 | @@ -43,6 +44,39 @@ |
96 | |
97 | DEFAULT_CONFIG_D = '/etc/system-image/config.d' |
98 | COLON = ':' |
99 | +LINE_LENGTH = 78 |
100 | + |
101 | + |
102 | +class _DotsProgress: |
103 | + def __init__(self): |
104 | + self._dot_count = 0 |
105 | + |
106 | + def callback(self, received, total): |
107 | + received = int(received) |
108 | + total = int(total) |
109 | + sys.stderr.write('.') |
110 | + sys.stderr.flush() |
111 | + self._dot_count += 1 |
112 | + show_dots = self._dot_count % LINE_LENGTH == 0 |
113 | + if show_dots or received >= total: # pragma: no cover |
114 | + sys.stderr.write('\n') |
115 | + sys.stderr.flush() |
116 | + |
117 | + |
118 | +class _LogfileProgress: |
119 | + def __init__(self, log): |
120 | + self._log = log |
121 | + |
122 | + def callback(self, received, total): |
123 | + self._log.debug('received: {} of {} bytes', received, total) |
124 | + |
125 | + |
126 | +def _json_progress(received, total): |
127 | + # For use with --progress=json output. LP: #1423622 |
128 | + print(json.dumps(dict( |
129 | + type='progress', |
130 | + now=received, |
131 | + total=total))) |
132 | |
133 | |
134 | def main(): |
135 | @@ -94,13 +128,18 @@ |
136 | default=False, action='store_true', |
137 | help="""Calculate and print the upgrade path, but do |
138 | not download or apply it""") |
139 | + parser.add_argument('-v', '--verbose', |
140 | + default=0, action='count', |
141 | + help='Increase verbosity') |
142 | + parser.add_argument('--progress', |
143 | + default=[], action='append', |
144 | + help="""Add a progress meter. Available meters are: |
145 | + dots, logfile, and json. Multiple --progress |
146 | + options are allowed.""") |
147 | parser.add_argument('-p', '--percentage', |
148 | default=None, action='store', |
149 | help="""Override the device's phased percentage value |
150 | during upgrade candidate calculation.""") |
151 | - parser.add_argument('-v', '--verbose', |
152 | - default=0, action='count', |
153 | - help='Increase verbosity') |
154 | parser.add_argument('--list-channels', |
155 | default=False, action='store_true', |
156 | help="""List all available channels, then exit""") |
157 | @@ -285,29 +324,20 @@ |
158 | print(' {} (alias for: {})'.format(key, alias)) |
159 | return 0 |
160 | |
161 | - # When verbosity is at 1, logging every progress signal from |
162 | - # ubuntu-download-manager would be way too noisy. OTOH, not printing |
163 | - # anything leads some folks to think the process is just hung, since it |
164 | - # can take a long time to download all the data files. As a compromise, |
165 | - # we'll output some dots to stderr at verbosity 1, but we won't log these |
166 | - # dots since they would just be noise. This doesn't have to be perfect. |
167 | - if args.verbose == 1: # pragma: no cover |
168 | - dot_count = 0 |
169 | - def callback(received, total): |
170 | - nonlocal dot_count |
171 | - sys.stderr.write('.') |
172 | - sys.stderr.flush() |
173 | - dot_count += 1 |
174 | - if dot_count % 78 == 0 or received >= total: |
175 | - sys.stderr.write('\n') |
176 | - sys.stderr.flush() |
177 | - else: |
178 | - def callback(received, total): |
179 | - log.debug('received: {} of {} bytes', received, total) |
180 | - |
181 | DBusGMainLoop(set_as_default=True) |
182 | state = State(candidate_filter=candidate_filter) |
183 | - state.downloader.callback = callback |
184 | + |
185 | + for meter in args.progress: |
186 | + if meter == 'dots': |
187 | + state.downloader.callbacks.append(_DotsProgress().callback) |
188 | + elif meter == 'json': |
189 | + state.downloader.callbacks.append(_json_progress) |
190 | + elif meter == 'logfile': |
191 | + state.downloader.callbacks.append(_LogfileProgress(log).callback) |
192 | + else: |
193 | + parser.error('Unknown progress meter: {}'.format(meter)) |
194 | + assert 'parser.error() does not return' # pragma: no cover |
195 | + |
196 | if args.dry_run: |
197 | try: |
198 | state.run_until('download_files') |
199 | |
200 | === modified file 'systemimage/tests/test_main.py' |
201 | --- systemimage/tests/test_main.py 2015-02-18 18:54:49 +0000 |
202 | +++ systemimage/tests/test_main.py 2015-03-04 20:03:16 +0000 |
203 | @@ -18,13 +18,14 @@ |
204 | __all__ = [ |
205 | 'TestCLIDuplicateDestinations', |
206 | 'TestCLIFactoryReset', |
207 | - 'TestCLIProductionReset', |
208 | 'TestCLIFilters', |
209 | 'TestCLIListChannels', |
210 | 'TestCLIMain', |
211 | 'TestCLIMainDryRun', |
212 | 'TestCLIMainDryRunAliases', |
213 | 'TestCLINoReboot', |
214 | + 'TestCLIProductionReset', |
215 | + 'TestCLIProgress', |
216 | 'TestCLISettings', |
217 | 'TestCLISignatures', |
218 | 'TestDBusMain', |
219 | @@ -35,6 +36,7 @@ |
220 | import os |
221 | import sys |
222 | import dbus |
223 | +import json |
224 | import stat |
225 | import time |
226 | import shutil |
227 | @@ -1480,3 +1482,127 @@ |
228 | WARNING: All GPG signature verifications have been disabled. |
229 | Your upgrades are INSECURE. |
230 | """) |
231 | + |
232 | + |
233 | +class TestCLIProgress(ServerTestBase): |
234 | + INDEX_FILE = 'main.index_01.json' |
235 | + CHANNEL_FILE = 'main.channels_01.json' |
236 | + CHANNEL = 'stable' |
237 | + DEVICE = 'nexus7' |
238 | + |
239 | + @configuration |
240 | + def test_dots_progress(self, config_d): |
241 | + # --progress=dots prints a bunch of dots to stderr. |
242 | + self._setup_server_keyrings() |
243 | + stderr = StringIO() |
244 | + with ExitStack() as resources: |
245 | + resources.enter_context( |
246 | + patch('systemimage.main.LINE_LENGTH', 10)) |
247 | + resources.enter_context( |
248 | + patch('systemimage.main.sys.stderr', stderr)) |
249 | + resources.enter_context( |
250 | + argv('-C', config_d, '-b', '0', '--no-reboot', |
251 | + '--progress', 'dots')) |
252 | + cli_main() |
253 | + # There should be some dots in the stderr. |
254 | + self.assertGreater(stderr.getvalue().count('.'), 2) |
255 | + |
256 | + @configuration |
257 | + def test_json_progress(self, config_d): |
258 | + # --progress=json prints some JSON to stdout. |
259 | + self._setup_server_keyrings() |
260 | + stdout = StringIO() |
261 | + with ExitStack() as resources: |
262 | + resources.enter_context(capture_print(stdout)) |
263 | + resources.enter_context( |
264 | + argv('-C', config_d, '-b', '0', '--no-reboot', |
265 | + '--progress', 'json')) |
266 | + cli_main() |
267 | + # stdout is now filled with JSON goodness. We can't assert too much |
268 | + # about the contents though. |
269 | + line_count = 0 |
270 | + for line in stdout.getvalue().splitlines(): |
271 | + line_count += 1 |
272 | + record = json.loads(line) |
273 | + self.assertEqual(record['type'], 'progress') |
274 | + self.assertIn('now', record) |
275 | + self.assertIn('total', record) |
276 | + self.assertGreater(line_count, 4) |
277 | + |
278 | + @configuration |
279 | + def test_logfile_progress(self, config_d): |
280 | + # --progress=logfile dumps some messages to the log file. |
281 | + self._setup_server_keyrings() |
282 | + log_mock = MagicMock() |
283 | + from systemimage.main import _LogfileProgress |
284 | + class Testable(_LogfileProgress): |
285 | + def __init__(self, log): |
286 | + super().__init__(log) |
287 | + self._log = log_mock |
288 | + with ExitStack() as resources: |
289 | + resources.enter_context( |
290 | + patch('systemimage.main._LogfileProgress', Testable)) |
291 | + resources.enter_context( |
292 | + argv('-C', config_d, '-b', '0', '--no-reboot', |
293 | + '--progress', 'logfile')) |
294 | + cli_main() |
295 | + self.assertGreater(log_mock.debug.call_count, 4) |
296 | + positional, keyword = log_mock.debug.call_args |
297 | + self.assertTrue(positional[0].startswith('received: ')) |
298 | + |
299 | + @configuration |
300 | + def test_all_progress(self, config_d): |
301 | + # We can have more than one --progress flag. |
302 | + self._setup_server_keyrings() |
303 | + stdout = StringIO() |
304 | + stderr = StringIO() |
305 | + log_mock = MagicMock() |
306 | + from systemimage.main import _LogfileProgress |
307 | + class Testable(_LogfileProgress): |
308 | + def __init__(self, log): |
309 | + super().__init__(log) |
310 | + self._log = log_mock |
311 | + with ExitStack() as resources: |
312 | + resources.enter_context( |
313 | + patch('systemimage.main.LINE_LENGTH', 10)) |
314 | + resources.enter_context( |
315 | + patch('systemimage.main.sys.stderr', stderr)) |
316 | + resources.enter_context(capture_print(stdout)) |
317 | + resources.enter_context( |
318 | + patch('systemimage.main._LogfileProgress', Testable)) |
319 | + resources.enter_context( |
320 | + argv('-C', config_d, '-b', '0', '--no-reboot', |
321 | + '--progress', 'dots', |
322 | + '--progress', 'json', |
323 | + '--progress', 'logfile')) |
324 | + cli_main() |
325 | + self.assertGreater(stderr.getvalue().count('.'), 2) |
326 | + line_count = 0 |
327 | + for line in stdout.getvalue().splitlines(): |
328 | + line_count += 1 |
329 | + record = json.loads(line) |
330 | + self.assertEqual(record['type'], 'progress') |
331 | + self.assertIn('now', record) |
332 | + self.assertIn('total', record) |
333 | + self.assertGreater(line_count, 4) |
334 | + self.assertGreater(log_mock.debug.call_count, 4) |
335 | + positional, keyword = log_mock.debug.call_args |
336 | + self.assertTrue(positional[0].startswith('received: ')) |
337 | + |
338 | + @configuration |
339 | + def test_bad_progress(self, config_d): |
340 | + # An unknown progress type results in an error. |
341 | + stderr = StringIO() |
342 | + with ExitStack() as resources: |
343 | + resources.enter_context( |
344 | + patch('systemimage.main.sys.stderr', stderr)) |
345 | + resources.enter_context( |
346 | + argv('-C', config_d, '-b', '0', '--no-reboot', |
347 | + '--progress', 'not-a-meter')) |
348 | + with self.assertRaises(SystemExit) as cm: |
349 | + cli_main() |
350 | + exit_code = cm.exception.code |
351 | + self.assertEqual(exit_code, 2) |
352 | + self.assertEqual( |
353 | + stderr.getvalue().splitlines()[-1], |
354 | + 'system-image-cli: error: Unknown progress meter: not-a-meter') |
355 | |
356 | === modified file 'systemimage/udm.py' |
357 | --- systemimage/udm.py 2015-02-13 21:44:50 +0000 |
358 | +++ systemimage/udm.py 2015-03-04 20:03:16 +0000 |
359 | @@ -122,7 +122,9 @@ |
360 | """Download via ubuntu-download-manager (UDM).""" |
361 | |
362 | def __init__(self, callback=None): |
363 | - super().__init__(callback) |
364 | + super().__init__() |
365 | + if callback is not None: |
366 | + self.callbacks.append(callback) |
367 | self._iface = None |
368 | |
369 | def _get_files(self, records, pausable): |
I'll add NEWS and update the manpage when I merge this into trunk.