Fixed some bugs spotted by pychecker
[zeroinstall/solver.git] / zeroinstall / injector / handler.py
blob1916074f760fc3426f1cd38eac88b881931b20ee
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 debug, warn, info
17 from zeroinstall import NeedDownload, SafeException
18 from zeroinstall.support import tasks
19 from zeroinstall.injector import download
21 class NoTrustedKeys(SafeException):
22 """Thrown by L{Handler.confirm_trust_keys} on failure."""
23 pass
25 class Handler(object):
26 """
27 This implementation uses the GLib mainloop. Note that QT4 can use the GLib mainloop too.
29 @ivar monitored_downloads: dict of downloads in progress
30 @type monitored_downloads: {URL: L{download.Download}}
31 @ivar n_completed_downloads: number of downloads which have finished for GUIs, etc (can be reset as desired).
32 @type n_completed_downloads: int
33 @ivar total_bytes_downloaded: informational counter for GUIs, etc (can be reset as desired). Updated when download finishes.
34 @type total_bytes_downloaded: int
35 @ivar dry_run: instead of starting a download, just report what we would have downloaded
36 @type dry_run: bool
37 """
39 __slots__ = ['monitored_downloads', '_loop', 'dry_run', 'total_bytes_downloaded', 'n_completed_downloads', '_current_confirm']
41 def __init__(self, mainloop = None, dry_run = False):
42 self.monitored_downloads = {}
43 self._loop = None
44 self.dry_run = dry_run
45 self.n_completed_downloads = 0
46 self.total_bytes_downloaded = 0
47 self._current_confirm = None
49 def monitor_download(self, dl):
50 """Called when a new L{download} is started.
51 This is mainly used by the GUI to display the progress bar."""
52 dl.start()
53 self.monitored_downloads[dl.url] = dl
54 self.downloads_changed()
56 @tasks.async
57 def download_done_stats():
58 yield dl.downloaded
59 # NB: we don't check for exceptions here; someone else should be doing that
60 try:
61 self.n_completed_downloads += 1
62 self.total_bytes_downloaded += dl.get_bytes_downloaded_so_far()
63 del self.monitored_downloads[dl.url]
64 self.downloads_changed()
65 except Exception, ex:
66 self.report_error(ex)
67 download_done_stats()
69 def impl_added_to_store(self, impl):
70 """Called by the L{fetch.Fetcher} when adding an implementation.
71 The GUI uses this to update its display.
72 @param impl: the implementation which has been added
73 @type impl: L{model.Implementation}
74 """
75 pass
77 def downloads_changed(self):
78 """This is just for the GUI to override to update its display."""
79 pass
81 def wait_for_blocker(self, blocker):
82 """Run a recursive mainloop until blocker is triggered.
83 @param blocker: event to wait on
84 @type blocker: L{tasks.Blocker}"""
85 if not blocker.happened:
86 import gobject
88 def quitter():
89 yield blocker
90 self._loop.quit()
91 quit = tasks.Task(quitter(), "quitter")
93 assert self._loop is None # Avoid recursion
94 self._loop = gobject.MainLoop(gobject.main_context_default())
95 try:
96 debug(_("Entering mainloop, waiting for %s"), blocker)
97 self._loop.run()
98 finally:
99 self._loop = None
101 assert blocker.happened, "Someone quit the main loop!"
103 tasks.check(blocker)
105 def get_download(self, url, force = False, hint = None):
106 """Return the Download object currently downloading 'url'.
107 If no download for this URL has been started, start one now (and
108 start monitoring it).
109 If the download failed and force is False, return it anyway.
110 If force is True, abort any current or failed download and start
111 a new one.
112 @rtype: L{download.Download}
114 if self.dry_run:
115 raise NeedDownload(url)
117 try:
118 dl = self.monitored_downloads[url]
119 if dl and force:
120 dl.abort()
121 raise KeyError
122 except KeyError:
123 dl = download.Download(url, hint)
124 self.monitor_download(dl)
125 return dl
127 def confirm_keys(self, pending, fetch_key_info):
128 """We don't trust any of the signatures yet. Ask the user.
129 When done update the L{trust} database, and then call L{trust.TrustDB.notify}.
130 This method just calls L{confirm_import_feed} if the handler (self) is
131 new-style, or L{confirm_trust_keys} for older classes. A class
132 is considered old-style if it overrides confirm_trust_keys and
133 not confirm_import_feed.
134 @since: 0.42
135 @arg pending: an object holding details of the updated feed
136 @type pending: L{PendingFeed}
137 @arg fetch_key_info: a function which can be used to fetch information about a key fingerprint
138 @type fetch_key_info: str -> L{Blocker}
139 @return: A blocker that triggers when the user has chosen, or None if already done.
140 @rtype: None | L{Blocker}"""
142 assert pending.sigs
144 if hasattr(self.confirm_trust_keys, 'original') or not hasattr(self.confirm_import_feed, 'original'):
145 # new-style class
146 from zeroinstall.injector import gpg
147 valid_sigs = [s for s in pending.sigs if isinstance(s, gpg.ValidSig)]
148 if not valid_sigs:
149 def format_sig(sig):
150 msg = str(sig)
151 if sig.messages:
152 msg += "\nMessages from GPG:\n" + sig.messages
153 return msg
154 raise SafeException(_('No valid signatures found on "%(url)s". Signatures:%(signatures)s') %
155 {'url': pending.url, 'signatures': ''.join(['\n- ' + format_sig(s) for s in pending.sigs])})
157 # Start downloading information about the keys...
158 kfs = {}
159 for sig in valid_sigs:
160 kfs[sig] = fetch_key_info(sig.fingerprint)
162 return self._queue_confirm_import_feed(pending, kfs)
163 else:
164 # old-style class
165 from zeroinstall.injector import iface_cache
166 import warnings
167 warnings.warn(_("Should override confirm_import_feed(); using old confirm_trust_keys() for now"), DeprecationWarning, stacklevel = 2)
169 iface = iface_cache.iface_cache.get_interface(pending.url)
170 return self.confirm_trust_keys(iface, pending.sigs, pending.new_xml)
172 @tasks.async
173 def _queue_confirm_import_feed(self, pending, valid_sigs):
174 # If we're already confirming something else, wait for that to finish...
175 while self._current_confirm is not None:
176 yield self._current_confirm
178 # Check whether we still need to confirm. The user may have
179 # already approved one of the keys while dealing with another
180 # feed.
181 from zeroinstall.injector import trust
182 domain = trust.domain_from_url(pending.url)
183 for sig in valid_sigs:
184 is_trusted = trust.trust_db.is_trusted(sig.fingerprint, domain)
185 if is_trusted:
186 return
188 # Take the lock and confirm this feed
189 self._current_confirm = lock = tasks.Blocker('confirm key lock')
190 try:
191 done = self.confirm_import_feed(pending, valid_sigs)
192 if done is not None:
193 yield done
194 tasks.check(done)
195 finally:
196 self._current_confirm = None
197 lock.trigger()
199 @tasks.async
200 def confirm_import_feed(self, pending, valid_sigs):
201 """Sub-classes should override this method to interact with the user about new feeds.
202 If multiple feeds need confirmation, L{confirm_keys} will only invoke one instance of this
203 method at a time.
204 @param pending: the new feed to be imported
205 @type pending: L{PendingFeed}
206 @param valid_sigs: maps signatures to a list of fetchers collecting information about the key
207 @type valid_sigs: {L{gpg.ValidSig} : L{fetch.KeyInfoFetcher}}
208 @since: 0.42
209 @see: L{confirm_keys}"""
210 from zeroinstall.injector import trust
212 assert valid_sigs
214 domain = trust.domain_from_url(pending.url)
216 # Ask on stderr, because we may be writing XML to stdout
217 print >>sys.stderr, _("Feed: %s") % pending.url
218 print >>sys.stderr, _("The feed is correctly signed with the following keys:")
219 for x in valid_sigs:
220 print >>sys.stderr, "-", x
222 def text(parent):
223 text = ""
224 for node in parent.childNodes:
225 if node.nodeType == node.TEXT_NODE:
226 text = text + node.data
227 return text
229 shown = set()
230 key_info_fetchers = valid_sigs.values()
231 while key_info_fetchers:
232 old_kfs = key_info_fetchers
233 key_info_fetchers = []
234 for kf in old_kfs:
235 infos = set(kf.info) - shown
236 if infos:
237 if len(valid_sigs) > 1:
238 print "%s: " % kf.fingerprint
239 for info in infos:
240 print >>sys.stderr, "-", text(info)
241 shown.add(info)
242 if kf.blocker:
243 key_info_fetchers.append(kf)
244 if key_info_fetchers:
245 for kf in key_info_fetchers: print >>sys.stderr, kf.status
246 stdin = tasks.InputBlocker(0, 'console')
247 blockers = [kf.blocker for kf in key_info_fetchers] + [stdin]
248 yield blockers
249 for b in blockers:
250 try:
251 tasks.check(b)
252 except Exception, ex:
253 warn(_("Failed to get key info: %s"), ex)
254 if stdin.happened:
255 print >>sys.stderr, _("Skipping remaining key lookups due to input from user")
256 break
258 if len(valid_sigs) == 1:
259 print >>sys.stderr, _("Do you want to trust this key to sign feeds from '%s'?") % domain
260 else:
261 print >>sys.stderr, _("Do you want to trust all of these keys to sign feeds from '%s'?") % domain
262 while True:
263 print >>sys.stderr, _("Trust [Y/N] "),
264 i = raw_input()
265 if not i: continue
266 if i in 'Nn':
267 raise NoTrustedKeys(_('Not signed with a trusted key'))
268 if i in 'Yy':
269 break
270 for key in valid_sigs:
271 print >>sys.stderr, _("Trusting %(key_fingerprint)s for %(domain)s") % {'key_fingerprint': key.fingerprint, 'domain': domain}
272 trust.trust_db.trust_key(key.fingerprint, domain)
274 confirm_import_feed.original = True
276 def confirm_trust_keys(self, interface, sigs, iface_xml):
277 """We don't trust any of the signatures yet. Ask the user.
278 When done update the L{trust} database, and then call L{trust.TrustDB.notify}.
279 @deprecated: see L{confirm_keys}
280 @arg interface: the interface being updated
281 @arg sigs: a list of signatures (from L{gpg.check_stream})
282 @arg iface_xml: the downloaded data (not yet trusted)
283 @return: a blocker, if confirmation will happen asynchronously, or None
284 @rtype: L{tasks.Blocker}"""
285 import warnings
286 warnings.warn(_("Use confirm_keys, not confirm_trust_keys"), DeprecationWarning, stacklevel = 2)
287 from zeroinstall.injector import trust, gpg
288 assert sigs
289 valid_sigs = [s for s in sigs if isinstance(s, gpg.ValidSig)]
290 if not valid_sigs:
291 raise SafeException('No valid signatures found on "%s". Signatures:%s' %
292 (interface.uri, ''.join(['\n- ' + str(s) for s in sigs])))
294 domain = trust.domain_from_url(interface.uri)
296 # Ask on stderr, because we may be writing XML to stdout
297 print >>sys.stderr, "\nInterface:", interface.uri
298 print >>sys.stderr, "The interface is correctly signed with the following keys:"
299 for x in valid_sigs:
300 print >>sys.stderr, "-", x
302 if len(valid_sigs) == 1:
303 print >>sys.stderr, "Do you want to trust this key to sign feeds from '%s'?" % domain
304 else:
305 print >>sys.stderr, "Do you want to trust all of these keys to sign feeds from '%s'?" % domain
306 while True:
307 print >>sys.stderr, "Trust [Y/N] ",
308 i = raw_input()
309 if not i: continue
310 if i in 'Nn':
311 raise NoTrustedKeys(_('Not signed with a trusted key'))
312 if i in 'Yy':
313 break
314 for key in valid_sigs:
315 print >>sys.stderr, "Trusting", key.fingerprint, "for", domain
316 trust.trust_db.trust_key(key.fingerprint, domain)
318 trust.trust_db.notify()
320 confirm_trust_keys.original = True # Detect if someone overrides it
322 def report_error(self, exception, tb = None):
323 """Report an exception to the user.
324 @param exception: the exception to report
325 @type exception: L{SafeException}
326 @param tb: optional traceback
327 @since: 0.25"""
328 warn("%s", str(exception) or type(exception))
329 #import traceback
330 #traceback.print_exception(exception, None, tb)
332 class ConsoleHandler(Handler):
333 """A Handler that displays progress on stdout (a tty).
334 @since: 0.44"""
335 last_msg_len = None
336 update = None
337 disable_progress = 0
338 screen_width = None
340 def downloads_changed(self):
341 import gobject
342 if self.monitored_downloads and self.update is None:
343 if self.screen_width is None:
344 try:
345 import curses
346 curses.setupterm()
347 self.screen_width = curses.tigetnum('cols') or 80
348 except Exception, ex:
349 info("Failed to initialise curses library: %s", ex)
350 self.screen_width = 80
351 self.show_progress()
352 self.update = gobject.timeout_add(200, self.show_progress)
353 elif len(self.monitored_downloads) == 0:
354 if self.update:
355 gobject.source_remove(self.update)
356 self.update = None
357 print
358 self.last_msg_len = None
360 def show_progress(self):
361 urls = self.monitored_downloads.keys()
362 if not urls: return True
364 if self.disable_progress: return True
366 screen_width = self.screen_width - 2
367 item_width = max(16, screen_width / len(self.monitored_downloads))
368 url_width = item_width - 7
370 msg = ""
371 for url in sorted(urls):
372 dl = self.monitored_downloads[url]
373 so_far = dl.get_bytes_downloaded_so_far()
374 leaf = url.rsplit('/', 1)[-1]
375 if len(leaf) >= url_width:
376 display = leaf[:url_width]
377 else:
378 display = url[-url_width:]
379 if dl.expected_size:
380 msg += "[%s %d%%] " % (display, int(so_far * 100 / dl.expected_size))
381 else:
382 msg += "[%s] " % (display)
383 msg = msg[:screen_width]
385 if self.last_msg_len is None:
386 sys.stdout.write(msg)
387 else:
388 sys.stdout.write(chr(13) + msg)
389 if len(msg) < self.last_msg_len:
390 sys.stdout.write(" " * (self.last_msg_len - len(msg)))
392 self.last_msg_len = len(msg)
393 sys.stdout.flush()
395 return True
397 def clear_display(self):
398 if self.last_msg_len != None:
399 sys.stdout.write(chr(13) + " " * self.last_msg_len + chr(13))
400 sys.stdout.flush()
401 self.last_msg_len = None
403 def report_error(self, exception, tb = None):
404 self.clear_display()
405 Handler.report_error(self, exception, tb)
407 def confirm_import_feed(self, pending, valid_sigs):
408 self.clear_display()
409 self.disable_progress += 1
410 blocker = Handler.confirm_import_feed(self, pending, valid_sigs)
411 @tasks.async
412 def enable():
413 yield blocker
414 self.disable_progress -= 1
415 self.show_progress()
416 enable()
417 return blocker