Fixed Pyflakes warnings
[zeroinstall/zeroinstall-afb.git] / zeroinstall / injector / handler.py
blob3170496c114c1a25ec33f5eece074620993ff16c
1 """
2 Integrates download callbacks with an external mainloop.
3 While things are being downloaded, Zero Install returns control to your program.
4 Your mainloop is responsible for monitoring the state of the downloads and notifying
5 Zero Install when they are complete.
7 To do this, you supply a L{Handler} to the L{policy}.
8 """
10 # Copyright (C) 2009, Thomas Leonard
11 # See the README file for details, or visit http://0install.net.
13 from zeroinstall import _
14 import sys
15 from logging import warn, info
17 from zeroinstall import NeedDownload, SafeException
18 from zeroinstall.support import tasks
19 from zeroinstall.injector import download
21 KEY_INFO_TIMEOUT = 10 # Maximum time to wait for response from key-info-server
23 class NoTrustedKeys(SafeException):
24 """Thrown by L{Handler.confirm_import_feed} on failure."""
25 pass
27 class Handler(object):
28 """
29 This implementation uses the GLib mainloop. Note that QT4 can use the GLib mainloop too.
31 @ivar monitored_downloads: dict of downloads in progress
32 @type monitored_downloads: {URL: L{download.Download}}
33 @ivar n_completed_downloads: number of downloads which have finished for GUIs, etc (can be reset as desired).
34 @type n_completed_downloads: int
35 @ivar total_bytes_downloaded: informational counter for GUIs, etc (can be reset as desired). Updated when download finishes.
36 @type total_bytes_downloaded: int
37 @ivar dry_run: instead of starting a download, just report what we would have downloaded
38 @type dry_run: bool
39 """
41 __slots__ = ['monitored_downloads', 'dry_run', 'total_bytes_downloaded', 'n_completed_downloads', '_current_confirm']
43 def __init__(self, mainloop = None, dry_run = False):
44 self.monitored_downloads = {}
45 self.dry_run = dry_run
46 self.n_completed_downloads = 0
47 self.total_bytes_downloaded = 0
48 self._current_confirm = None
50 def monitor_download(self, dl):
51 """Called when a new L{download} is started.
52 This is mainly used by the GUI to display the progress bar."""
53 dl.start()
54 self.monitored_downloads[dl.url] = dl
55 self.downloads_changed()
57 @tasks.async
58 def download_done_stats():
59 yield dl.downloaded
60 # NB: we don't check for exceptions here; someone else should be doing that
61 try:
62 self.n_completed_downloads += 1
63 self.total_bytes_downloaded += dl.get_bytes_downloaded_so_far()
64 del self.monitored_downloads[dl.url]
65 self.downloads_changed()
66 except Exception, ex:
67 self.report_error(ex)
68 download_done_stats()
70 def impl_added_to_store(self, impl):
71 """Called by the L{fetch.Fetcher} when adding an implementation.
72 The GUI uses this to update its display.
73 @param impl: the implementation which has been added
74 @type impl: L{model.Implementation}
75 """
76 pass
78 def downloads_changed(self):
79 """This is just for the GUI to override to update its display."""
80 pass
82 def wait_for_blocker(self, blocker):
83 """@deprecated: use tasks.wait_for_blocker instead"""
84 tasks.wait_for_blocker(blocker)
86 def get_download(self, url, force = False, hint = None, factory = None):
87 """Return the Download object currently downloading 'url'.
88 If no download for this URL has been started, start one now (and
89 start monitoring it).
90 If the download failed and force is False, return it anyway.
91 If force is True, abort any current or failed download and start
92 a new one.
93 @rtype: L{download.Download}
94 """
95 if self.dry_run:
96 raise NeedDownload(url)
98 try:
99 dl = self.monitored_downloads[url]
100 if dl and force:
101 dl.abort()
102 raise KeyError
103 except KeyError:
104 if factory is None:
105 dl = download.Download(url, hint)
106 else:
107 dl = factory(url, hint)
108 self.monitor_download(dl)
109 return dl
111 def confirm_keys(self, pending, fetch_key_info):
112 """We don't trust any of the signatures yet. Ask the user.
113 When done update the L{trust} database, and then call L{trust.TrustDB.notify}.
114 This method starts downloading information about the signing keys and calls L{confirm_import_feed}.
115 @since: 0.42
116 @arg pending: an object holding details of the updated feed
117 @type pending: L{PendingFeed}
118 @arg fetch_key_info: a function which can be used to fetch information about a key fingerprint
119 @type fetch_key_info: str -> L{Blocker}
120 @return: A blocker that triggers when the user has chosen, or None if already done.
121 @rtype: None | L{Blocker}"""
123 assert pending.sigs
125 from zeroinstall.injector import gpg
126 valid_sigs = [s for s in pending.sigs if isinstance(s, gpg.ValidSig)]
127 if not valid_sigs:
128 def format_sig(sig):
129 msg = str(sig)
130 if sig.messages:
131 msg += "\nMessages from GPG:\n" + sig.messages
132 return msg
133 raise SafeException(_('No valid signatures found on "%(url)s". Signatures:%(signatures)s') %
134 {'url': pending.url, 'signatures': ''.join(['\n- ' + format_sig(s) for s in pending.sigs])})
136 # Start downloading information about the keys...
137 kfs = {}
138 for sig in valid_sigs:
139 kfs[sig] = fetch_key_info(sig.fingerprint)
141 return self._queue_confirm_import_feed(pending, kfs)
143 @tasks.async
144 def _queue_confirm_import_feed(self, pending, valid_sigs):
145 # Wait up to KEY_INFO_TIMEOUT seconds for key information to arrive. Avoids having the dialog
146 # box update while the user is looking at it, and may allow it to be skipped completely in some
147 # cases.
148 timeout = tasks.TimeoutBlocker(KEY_INFO_TIMEOUT, "key info timeout")
149 while True:
150 key_info_blockers = [sig_info.blocker for sig_info in valid_sigs.values() if sig_info.blocker is not None]
151 if not key_info_blockers:
152 break
153 info("Waiting for response from key-info server: %s", key_info_blockers)
154 yield [timeout] + key_info_blockers
155 if timeout.happened:
156 info("Timeout waiting for key info response")
157 break
159 # If we're already confirming something else, wait for that to finish...
160 while self._current_confirm is not None:
161 info("Waiting for previous key confirmations to finish")
162 yield self._current_confirm
164 # Check whether we still need to confirm. The user may have
165 # already approved one of the keys while dealing with another
166 # feed.
167 from zeroinstall.injector import trust
168 domain = trust.domain_from_url(pending.url)
169 for sig in valid_sigs:
170 is_trusted = trust.trust_db.is_trusted(sig.fingerprint, domain)
171 if is_trusted:
172 return
174 # Take the lock and confirm this feed
175 self._current_confirm = lock = tasks.Blocker('confirm key lock')
176 try:
177 done = self.confirm_import_feed(pending, valid_sigs)
178 if done is not None:
179 yield done
180 tasks.check(done)
181 finally:
182 self._current_confirm = None
183 lock.trigger()
185 @tasks.async
186 def confirm_import_feed(self, pending, valid_sigs):
187 """Sub-classes should override this method to interact with the user about new feeds.
188 If multiple feeds need confirmation, L{confirm_keys} will only invoke one instance of this
189 method at a time.
190 @param pending: the new feed to be imported
191 @type pending: L{PendingFeed}
192 @param valid_sigs: maps signatures to a list of fetchers collecting information about the key
193 @type valid_sigs: {L{gpg.ValidSig} : L{fetch.KeyInfoFetcher}}
194 @since: 0.42
195 @see: L{confirm_keys}"""
196 from zeroinstall.injector import trust
198 assert valid_sigs
200 domain = trust.domain_from_url(pending.url)
202 # Ask on stderr, because we may be writing XML to stdout
203 print >>sys.stderr, _("Feed: %s") % pending.url
204 print >>sys.stderr, _("The feed is correctly signed with the following keys:")
205 for x in valid_sigs:
206 print >>sys.stderr, "-", x
208 def text(parent):
209 text = ""
210 for node in parent.childNodes:
211 if node.nodeType == node.TEXT_NODE:
212 text = text + node.data
213 return text
215 shown = set()
216 key_info_fetchers = valid_sigs.values()
217 while key_info_fetchers:
218 old_kfs = key_info_fetchers
219 key_info_fetchers = []
220 for kf in old_kfs:
221 infos = set(kf.info) - shown
222 if infos:
223 if len(valid_sigs) > 1:
224 print "%s: " % kf.fingerprint
225 for key_info in infos:
226 print >>sys.stderr, "-", text(key_info)
227 shown.add(key_info)
228 if kf.blocker:
229 key_info_fetchers.append(kf)
230 if key_info_fetchers:
231 for kf in key_info_fetchers: print >>sys.stderr, kf.status
232 stdin = tasks.InputBlocker(0, 'console')
233 blockers = [kf.blocker for kf in key_info_fetchers] + [stdin]
234 yield blockers
235 for b in blockers:
236 try:
237 tasks.check(b)
238 except Exception, ex:
239 warn(_("Failed to get key info: %s"), ex)
240 if stdin.happened:
241 print >>sys.stderr, _("Skipping remaining key lookups due to input from user")
242 break
244 if len(valid_sigs) == 1:
245 print >>sys.stderr, _("Do you want to trust this key to sign feeds from '%s'?") % domain
246 else:
247 print >>sys.stderr, _("Do you want to trust all of these keys to sign feeds from '%s'?") % domain
248 while True:
249 print >>sys.stderr, _("Trust [Y/N] "),
250 i = raw_input()
251 if not i: continue
252 if i in 'Nn':
253 raise NoTrustedKeys(_('Not signed with a trusted key'))
254 if i in 'Yy':
255 break
256 for key in valid_sigs:
257 print >>sys.stderr, _("Trusting %(key_fingerprint)s for %(domain)s") % {'key_fingerprint': key.fingerprint, 'domain': domain}
258 trust.trust_db.trust_key(key.fingerprint, domain)
260 confirm_import_feed.original = True
262 @tasks.async
263 def confirm_install(self, msg):
264 """We need to check something with the user before continuing with the install.
265 @raise download.DownloadAborted: if the user cancels"""
266 yield
267 print >>sys.stderr, msg
268 while True:
269 sys.stderr.write(_("Install [Y/N] "))
270 i = raw_input()
271 if not i: continue
272 if i in 'Nn':
273 raise download.DownloadAborted()
274 if i in 'Yy':
275 break
277 def report_error(self, exception, tb = None):
278 """Report an exception to the user.
279 @param exception: the exception to report
280 @type exception: L{SafeException}
281 @param tb: optional traceback
282 @since: 0.25"""
283 warn("%s", str(exception) or type(exception))
284 #import traceback
285 #traceback.print_exception(exception, None, tb)
287 class ConsoleHandler(Handler):
288 """A Handler that displays progress on stdout (a tty).
289 @since: 0.44"""
290 last_msg_len = None
291 update = None
292 disable_progress = 0
293 screen_width = None
295 def downloads_changed(self):
296 import gobject
297 if self.monitored_downloads and self.update is None:
298 if self.screen_width is None:
299 try:
300 import curses
301 curses.setupterm()
302 self.screen_width = curses.tigetnum('cols') or 80
303 except Exception, ex:
304 info("Failed to initialise curses library: %s", ex)
305 self.screen_width = 80
306 self.show_progress()
307 self.update = gobject.timeout_add(200, self.show_progress)
308 elif len(self.monitored_downloads) == 0:
309 if self.update:
310 gobject.source_remove(self.update)
311 self.update = None
312 print
313 self.last_msg_len = None
315 def show_progress(self):
316 urls = self.monitored_downloads.keys()
317 if not urls: return True
319 if self.disable_progress: return True
321 screen_width = self.screen_width - 2
322 item_width = max(16, screen_width / len(self.monitored_downloads))
323 url_width = item_width - 7
325 msg = ""
326 for url in sorted(urls):
327 dl = self.monitored_downloads[url]
328 so_far = dl.get_bytes_downloaded_so_far()
329 leaf = url.rsplit('/', 1)[-1]
330 if len(leaf) >= url_width:
331 display = leaf[:url_width]
332 else:
333 display = url[-url_width:]
334 if dl.expected_size:
335 msg += "[%s %d%%] " % (display, int(so_far * 100 / dl.expected_size))
336 else:
337 msg += "[%s] " % (display)
338 msg = msg[:screen_width]
340 if self.last_msg_len is None:
341 sys.stdout.write(msg)
342 else:
343 sys.stdout.write(chr(13) + msg)
344 if len(msg) < self.last_msg_len:
345 sys.stdout.write(" " * (self.last_msg_len - len(msg)))
347 self.last_msg_len = len(msg)
348 sys.stdout.flush()
350 return True
352 def clear_display(self):
353 if self.last_msg_len != None:
354 sys.stdout.write(chr(13) + " " * self.last_msg_len + chr(13))
355 sys.stdout.flush()
356 self.last_msg_len = None
358 def report_error(self, exception, tb = None):
359 self.clear_display()
360 Handler.report_error(self, exception, tb)
362 def confirm_import_feed(self, pending, valid_sigs):
363 self.clear_display()
364 self.disable_progress += 1
365 blocker = Handler.confirm_import_feed(self, pending, valid_sigs)
366 @tasks.async
367 def enable():
368 yield blocker
369 self.disable_progress -= 1
370 self.show_progress()
371 enable()
372 return blocker