Release 1.4.1
[zeroinstall.git] / zeroinstall / injector / handler.py
blob81b7c4fb4e6196f6bcfbd9dc7033997e307d1bab
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 __future__ import print_function
15 from zeroinstall import _
16 import sys
17 from logging import warn, info
19 from zeroinstall import NeedDownload, SafeException
20 from zeroinstall.support import tasks
21 from zeroinstall.injector import download
23 class NoTrustedKeys(SafeException):
24 """Thrown by L{Handler.confirm_import_feed} on failure."""
25 pass
27 class Handler(object):
28 """
29 A Handler is used to interact with the user (e.g. to confirm keys, display download progress, etc).
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']
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
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 as 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 """@deprecated: use tasks.wait_for_blocker instead"""
83 tasks.wait_for_blocker(blocker)
85 def get_download(self, url, force = False, hint = None, factory = None):
86 """Return the Download object currently downloading 'url'.
87 If no download for this URL has been started, start one now (and
88 start monitoring it).
89 If the download failed and force is False, return it anyway.
90 If force is True, abort any current or failed download and start
91 a new one.
92 @rtype: L{download.Download}
93 """
94 if self.dry_run:
95 raise NeedDownload(url)
97 try:
98 dl = self.monitored_downloads[url]
99 if dl and force:
100 dl.abort()
101 raise KeyError
102 except KeyError:
103 if factory is None:
104 dl = download.Download(url, hint)
105 else:
106 dl = factory(url, hint)
107 self.monitor_download(dl)
108 return dl
110 @tasks.async
111 def confirm_import_feed(self, pending, valid_sigs):
112 """Sub-classes should override this method to interact with the user about new feeds.
113 If multiple feeds need confirmation, L{trust.TrustMgr.confirm_keys} will only invoke one instance of this
114 method at a time.
115 @param pending: the new feed to be imported
116 @type pending: L{PendingFeed}
117 @param valid_sigs: maps signatures to a list of fetchers collecting information about the key
118 @type valid_sigs: {L{gpg.ValidSig} : L{fetch.KeyInfoFetcher}}
119 @since: 0.42"""
120 from zeroinstall.injector import trust
122 assert valid_sigs
124 domain = trust.domain_from_url(pending.url)
126 # Ask on stderr, because we may be writing XML to stdout
127 print(_("Feed: %s") % pending.url, file=sys.stderr)
128 print(_("The feed is correctly signed with the following keys:"), file=sys.stderr)
129 for x in valid_sigs:
130 print("-", x, file=sys.stderr)
132 def text(parent):
133 text = ""
134 for node in parent.childNodes:
135 if node.nodeType == node.TEXT_NODE:
136 text = text + node.data
137 return text
139 shown = set()
140 key_info_fetchers = valid_sigs.values()
141 while key_info_fetchers:
142 old_kfs = key_info_fetchers
143 key_info_fetchers = []
144 for kf in old_kfs:
145 infos = set(kf.info) - shown
146 if infos:
147 if len(valid_sigs) > 1:
148 print("%s: " % kf.fingerprint)
149 for key_info in infos:
150 print("-", text(key_info), file=sys.stderr)
151 shown.add(key_info)
152 if kf.blocker:
153 key_info_fetchers.append(kf)
154 if key_info_fetchers:
155 for kf in key_info_fetchers: print(kf.status, file=sys.stderr)
156 stdin = tasks.InputBlocker(0, 'console')
157 blockers = [kf.blocker for kf in key_info_fetchers] + [stdin]
158 yield blockers
159 for b in blockers:
160 try:
161 tasks.check(b)
162 except Exception as ex:
163 warn(_("Failed to get key info: %s"), ex)
164 if stdin.happened:
165 print(_("Skipping remaining key lookups due to input from user"), file=sys.stderr)
166 break
167 if not shown:
168 print(_("Warning: Nothing known about this key!"), file=sys.stderr)
170 if len(valid_sigs) == 1:
171 print(_("Do you want to trust this key to sign feeds from '%s'?") % domain, file=sys.stderr)
172 else:
173 print(_("Do you want to trust all of these keys to sign feeds from '%s'?") % domain, file=sys.stderr)
174 while True:
175 print(_("Trust [Y/N] "), end=' ', file=sys.stderr)
176 i = raw_input()
177 if not i: continue
178 if i in 'Nn':
179 raise NoTrustedKeys(_('Not signed with a trusted key'))
180 if i in 'Yy':
181 break
182 for key in valid_sigs:
183 print(_("Trusting %(key_fingerprint)s for %(domain)s") % {'key_fingerprint': key.fingerprint, 'domain': domain}, file=sys.stderr)
184 trust.trust_db.trust_key(key.fingerprint, domain)
186 @tasks.async
187 def confirm_install(self, msg):
188 """We need to check something with the user before continuing with the install.
189 @raise download.DownloadAborted: if the user cancels"""
190 yield
191 print(msg, file=sys.stderr)
192 while True:
193 sys.stderr.write(_("Install [Y/N] "))
194 i = raw_input()
195 if not i: continue
196 if i in 'Nn':
197 raise download.DownloadAborted()
198 if i in 'Yy':
199 break
201 def report_error(self, exception, tb = None):
202 """Report an exception to the user.
203 @param exception: the exception to report
204 @type exception: L{SafeException}
205 @param tb: optional traceback
206 @since: 0.25"""
207 warn("%s", str(exception) or type(exception))
208 #import traceback
209 #traceback.print_exception(exception, None, tb)
211 class ConsoleHandler(Handler):
212 """A Handler that displays progress on stdout (a tty).
213 @since: 0.44"""
214 last_msg_len = None
215 update = None
216 disable_progress = 0
217 screen_width = None
219 def downloads_changed(self):
220 import gobject
221 if self.monitored_downloads and self.update is None:
222 if self.screen_width is None:
223 try:
224 import curses
225 curses.setupterm()
226 self.screen_width = curses.tigetnum('cols') or 80
227 except Exception as ex:
228 info("Failed to initialise curses library: %s", ex)
229 self.screen_width = 80
230 self.show_progress()
231 self.update = gobject.timeout_add(200, self.show_progress)
232 elif len(self.monitored_downloads) == 0:
233 if self.update:
234 gobject.source_remove(self.update)
235 self.update = None
236 print()
237 self.last_msg_len = None
239 def show_progress(self):
240 urls = self.monitored_downloads.keys()
241 if not urls: return True
243 if self.disable_progress: return True
245 screen_width = self.screen_width - 2
246 item_width = max(16, screen_width / len(self.monitored_downloads))
247 url_width = item_width - 7
249 msg = ""
250 for url in sorted(urls):
251 dl = self.monitored_downloads[url]
252 so_far = dl.get_bytes_downloaded_so_far()
253 leaf = url.rsplit('/', 1)[-1]
254 if len(leaf) >= url_width:
255 display = leaf[:url_width]
256 else:
257 display = url[-url_width:]
258 if dl.expected_size:
259 msg += "[%s %d%%] " % (display, int(so_far * 100 / dl.expected_size))
260 else:
261 msg += "[%s] " % (display)
262 msg = msg[:screen_width]
264 if self.last_msg_len is None:
265 sys.stdout.write(msg)
266 else:
267 sys.stdout.write(chr(13) + msg)
268 if len(msg) < self.last_msg_len:
269 sys.stdout.write(" " * (self.last_msg_len - len(msg)))
271 self.last_msg_len = len(msg)
272 sys.stdout.flush()
274 return True
276 def clear_display(self):
277 if self.last_msg_len != None:
278 sys.stdout.write(chr(13) + " " * self.last_msg_len + chr(13))
279 sys.stdout.flush()
280 self.last_msg_len = None
282 def report_error(self, exception, tb = None):
283 self.clear_display()
284 Handler.report_error(self, exception, tb)
286 def confirm_import_feed(self, pending, valid_sigs):
287 self.clear_display()
288 self.disable_progress += 1
289 blocker = Handler.confirm_import_feed(self, pending, valid_sigs)
290 @tasks.async
291 def enable():
292 yield blocker
293 self.disable_progress -= 1
294 self.show_progress()
295 enable()
296 return blocker