Merge lp:~barry/ubuntu-system-image/lp1423622 into lp:~registry/ubuntu-system-image/client

Proposed by Barry Warsaw
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
Reviewer Review Type Date Requested Status
Michael Vogt Pending
Review via email: mp+251819@code.launchpad.net

Description of the change

--progress

To post a comment you must log in.
Revision history for this message
Barry Warsaw (barry) wrote :

I'll add NEWS and update the manpage when I merge this into trunk.

Revision history for this message
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://bazaar.launchpad.net/~ubuntu-system-image/ubuntu-system-image/client/view/head:/systemimage/main.py#L381) 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.

Revision history for this message
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://bazaar.launchpad.net/~ubuntu-system-image/ubuntu-system-image/client/view/head:/systemimage/main.py#L381)
>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

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
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):

Subscribers

People subscribed via source and target branches