Don't abort if importing curses fails
[zeroinstall/solver.git] / zeroinstall / injector / handler.py
blob10b89c31a179d550fe7fb2be4007d4d123998352
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
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 raise SafeException(_('No valid signatures found on "%(url)s". Signatures:%(signatures)s') %
150 {'url': pending.url, 'signatures': ''.join(['\n- ' + str(s) for s in pending.sigs])})
152 # Start downloading information about the keys...
153 kfs = {}
154 for sig in valid_sigs:
155 kfs[sig] = fetch_key_info(sig.fingerprint)
157 return self._queue_confirm_import_feed(pending, kfs)
158 else:
159 # old-style class
160 from zeroinstall.injector import iface_cache
161 import warnings
162 warnings.warn(_("Should override confirm_import_feed(); using old confirm_trust_keys() for now"), DeprecationWarning, stacklevel = 2)
164 iface = iface_cache.iface_cache.get_interface(pending.url)
165 return self.confirm_trust_keys(iface, pending.sigs, pending.new_xml)
167 @tasks.async
168 def _queue_confirm_import_feed(self, pending, valid_sigs):
169 # If we're already confirming something else, wait for that to finish...
170 while self._current_confirm is not None:
171 yield self._current_confirm
173 # Check whether we still need to confirm. The user may have
174 # already approved one of the keys while dealing with another
175 # feed.
176 from zeroinstall.injector import trust
177 domain = trust.domain_from_url(pending.url)
178 for sig in valid_sigs:
179 is_trusted = trust.trust_db.is_trusted(sig.fingerprint, domain)
180 if is_trusted:
181 return
183 # Take the lock and confirm this feed
184 self._current_confirm = lock = tasks.Blocker('confirm key lock')
185 try:
186 done = self.confirm_import_feed(pending, valid_sigs)
187 if done is not None:
188 yield done
189 tasks.check(done)
190 finally:
191 self._current_confirm = None
192 lock.trigger()
194 @tasks.async
195 def confirm_import_feed(self, pending, valid_sigs):
196 """Sub-classes should override this method to interact with the user about new feeds.
197 If multiple feeds need confirmation, L{confirm_keys} will only invoke one instance of this
198 method at a time.
199 @param pending: the new feed to be imported
200 @type pending: L{PendingFeed}
201 @param valid_sigs: maps signatures to a list of fetchers collecting information about the key
202 @type valid_sigs: {L{gpg.ValidSig} : L{fetch.KeyInfoFetcher}}
203 @since: 0.42
204 @see: L{confirm_keys}"""
205 from zeroinstall.injector import trust
207 assert valid_sigs
209 domain = trust.domain_from_url(pending.url)
211 # Ask on stderr, because we may be writing XML to stdout
212 print >>sys.stderr, _("Feed: %s") % pending.url
213 print >>sys.stderr, _("The feed is correctly signed with the following keys:")
214 for x in valid_sigs:
215 print >>sys.stderr, "-", x
217 def text(parent):
218 text = ""
219 for node in parent.childNodes:
220 if node.nodeType == node.TEXT_NODE:
221 text = text + node.data
222 return text
224 shown = set()
225 key_info_fetchers = valid_sigs.values()
226 while key_info_fetchers:
227 old_kfs = key_info_fetchers
228 key_info_fetchers = []
229 for kf in old_kfs:
230 infos = set(kf.info) - shown
231 if infos:
232 if len(valid_sigs) > 1:
233 print "%s: " % kf.fingerprint
234 for info in infos:
235 print >>sys.stderr, "-", text(info)
236 shown.add(info)
237 if kf.blocker:
238 key_info_fetchers.append(kf)
239 if key_info_fetchers:
240 for kf in key_info_fetchers: print >>sys.stderr, kf.status
241 stdin = tasks.InputBlocker(0, 'console')
242 blockers = [kf.blocker for kf in key_info_fetchers] + [stdin]
243 yield blockers
244 for b in blockers:
245 try:
246 tasks.check(b)
247 except Exception, ex:
248 warn(_("Failed to get key info: %s"), ex)
249 if stdin.happened:
250 print >>sys.stderr, _("Skipping remaining key lookups due to input from user")
251 break
253 if len(valid_sigs) == 1:
254 print >>sys.stderr, _("Do you want to trust this key to sign feeds from '%s'?") % domain
255 else:
256 print >>sys.stderr, _("Do you want to trust all of these keys to sign feeds from '%s'?") % domain
257 while True:
258 print >>sys.stderr, _("Trust [Y/N] "),
259 i = raw_input()
260 if not i: continue
261 if i in 'Nn':
262 raise NoTrustedKeys(_('Not signed with a trusted key'))
263 if i in 'Yy':
264 break
265 for key in valid_sigs:
266 print >>sys.stderr, _("Trusting %(key_fingerprint)s for %(domain)s") % {'key_fingerprint': key.fingerprint, 'domain': domain}
267 trust.trust_db.trust_key(key.fingerprint, domain)
269 confirm_import_feed.original = True
271 def confirm_trust_keys(self, interface, sigs, iface_xml):
272 """We don't trust any of the signatures yet. Ask the user.
273 When done update the L{trust} database, and then call L{trust.TrustDB.notify}.
274 @deprecated: see L{confirm_keys}
275 @arg interface: the interface being updated
276 @arg sigs: a list of signatures (from L{gpg.check_stream})
277 @arg iface_xml: the downloaded data (not yet trusted)
278 @return: a blocker, if confirmation will happen asynchronously, or None
279 @rtype: L{tasks.Blocker}"""
280 import warnings
281 warnings.warn(_("Use confirm_keys, not confirm_trust_keys"), DeprecationWarning, stacklevel = 2)
282 from zeroinstall.injector import trust, gpg
283 assert sigs
284 valid_sigs = [s for s in sigs if isinstance(s, gpg.ValidSig)]
285 if not valid_sigs:
286 raise SafeException('No valid signatures found on "%s". Signatures:%s' %
287 (interface.uri, ''.join(['\n- ' + str(s) for s in sigs])))
289 domain = trust.domain_from_url(interface.uri)
291 # Ask on stderr, because we may be writing XML to stdout
292 print >>sys.stderr, "\nInterface:", interface.uri
293 print >>sys.stderr, "The interface is correctly signed with the following keys:"
294 for x in valid_sigs:
295 print >>sys.stderr, "-", x
297 if len(valid_sigs) == 1:
298 print >>sys.stderr, "Do you want to trust this key to sign feeds from '%s'?" % domain
299 else:
300 print >>sys.stderr, "Do you want to trust all of these keys to sign feeds from '%s'?" % domain
301 while True:
302 print >>sys.stderr, "Trust [Y/N] ",
303 i = raw_input()
304 if not i: continue
305 if i in 'Nn':
306 raise NoTrustedKeys(_('Not signed with a trusted key'))
307 if i in 'Yy':
308 break
309 for key in valid_sigs:
310 print >>sys.stderr, "Trusting", key.fingerprint, "for", domain
311 trust.trust_db.trust_key(key.fingerprint, domain)
313 trust.trust_db.notify()
315 confirm_trust_keys.original = True # Detect if someone overrides it
317 def report_error(self, exception, tb = None):
318 """Report an exception to the user.
319 @param exception: the exception to report
320 @type exception: L{SafeException}
321 @param tb: optional traceback
322 @since: 0.25"""
323 warn("%s", str(exception) or type(exception))
324 #import traceback
325 #traceback.print_exception(exception, None, tb)
327 class ConsoleHandler(Handler):
328 """A Handler that displays progress on stdout (a tty).
329 @since: 0.44"""
330 last_msg_len = None
331 update = None
332 disable_progress = 0
333 screen_width = None
335 def downloads_changed(self):
336 import gobject
337 if self.monitored_downloads and self.update is None:
338 if self.screen_width is None:
339 try:
340 import curses
341 curses.setupterm()
342 self.screen_width = curses.tigetnum('cols') or 80
343 except Exception, ex:
344 info("Failed to initialise curses library: %s", ex)
345 self.screen_width = 80
346 self.show_progress()
347 self.update = gobject.timeout_add(200, self.show_progress)
348 elif len(self.monitored_downloads) == 0:
349 if self.update:
350 gobject.source_remove(self.update)
351 self.update = None
352 print
353 self.last_msg_len = None
355 def show_progress(self):
356 urls = self.monitored_downloads.keys()
357 if not urls: return True
359 if self.disable_progress: return True
361 screen_width = self.screen_width - 2
362 item_width = max(16, screen_width / len(self.monitored_downloads))
363 url_width = item_width - 7
365 msg = ""
366 for url in sorted(urls):
367 dl = self.monitored_downloads[url]
368 so_far = dl.get_bytes_downloaded_so_far()
369 leaf = url.rsplit('/', 1)[-1]
370 if len(leaf) >= url_width:
371 display = leaf[:url_width]
372 else:
373 display = url[-url_width:]
374 if dl.expected_size:
375 msg += "[%s %d%%] " % (display, int(so_far * 100 / dl.expected_size))
376 else:
377 msg += "[%s] " % (display)
378 msg = msg[:screen_width]
380 if self.last_msg_len is None:
381 sys.stdout.write(msg)
382 else:
383 sys.stdout.write(chr(13) + msg)
384 if len(msg) < self.last_msg_len:
385 sys.stdout.write(" " * (self.last_msg_len - len(msg)))
387 self.last_msg_len = len(msg)
388 sys.stdout.flush()
390 return True
392 def clear_display(self):
393 if self.last_msg_len != None:
394 sys.stdout.write(chr(13) + " " * self.last_msg_len + chr(13))
395 sys.stdout.flush()
396 self.last_msg_len = None
398 def report_error(self, exception, tb = None):
399 self.clear_display()
400 Handler.report_error(self, exception, tb)
402 def confirm_import_feed(self, pending, valid_sigs):
403 self.clear_display()
404 self.disable_progress += 1
405 blocker = Handler.confirm_import_feed(self, pending, valid_sigs)
406 @tasks.async
407 def enable():
408 yield blocker
409 self.disable_progress -= 1
410 self.show_progress()
411 enable()
412 return blocker