Import _ into each module rather than using a builtin
[zeroinstall.git] / zeroinstall / injector / fetch.py
blobdb1c5e9de3ec2a879dc0d6136a4da694a4b3dcaa
1 """
2 Downloads feeds, keys, packages and icons.
3 """
5 # Copyright (C) 2009, Thomas Leonard
6 # See the README file for details, or visit http://0install.net.
8 from zeroinstall import _
9 import os
10 from logging import info, debug, warn
12 from zeroinstall.support import tasks, basedir
13 from zeroinstall.injector.namespaces import XMLNS_IFACE, config_site
14 from zeroinstall.injector.model import DownloadSource, Recipe, SafeException, escape
15 from zeroinstall.injector.iface_cache import PendingFeed, ReplayAttack
16 from zeroinstall.injector.handler import NoTrustedKeys
17 from zeroinstall.injector import download
19 def _escape_slashes(path):
20 return path.replace('/', '%23')
22 def _get_feed_dir(feed):
23 """The algorithm from 0mirror."""
24 if '#' in feed:
25 raise SafeException(_("Invalid URL '%s'") % feed)
26 scheme, rest = feed.split('://', 1)
27 domain, rest = rest.split('/', 1)
28 for x in [scheme, domain, rest]:
29 if not x or x.startswith(','):
30 raise SafeException(_("Invalid URL '%s'") % feed)
31 return os.path.join('feeds', scheme, domain, _escape_slashes(rest))
33 class Fetcher(object):
34 """Downloads and stores various things.
35 @ivar handler: handler to use for user-interaction
36 @type handler: L{handler.Handler}
37 @ivar feed_mirror: the base URL of a mirror site for keys and feeds
38 @type feed_mirror: str
39 """
40 __slots__ = ['handler', 'feed_mirror']
42 def __init__(self, handler):
43 self.handler = handler
44 self.feed_mirror = "http://roscidus.com/0mirror"
46 @tasks.async
47 def cook(self, required_digest, recipe, stores, force = False, impl_hint = None):
48 """Follow a Recipe.
49 @param impl_hint: the Implementation this is for (if any) as a hint for the GUI
50 @see: L{download_impl} uses this method when appropriate"""
51 # Maybe we're taking this metaphor too far?
53 # Start downloading all the ingredients.
54 downloads = {} # Downloads that are not yet successful
55 streams = {} # Streams collected from successful downloads
57 # Start a download for each ingredient
58 blockers = []
59 for step in recipe.steps:
60 blocker, stream = self.download_archive(step, force = force, impl_hint = impl_hint)
61 assert stream
62 blockers.append(blocker)
63 streams[step] = stream
65 while blockers:
66 yield blockers
67 tasks.check(blockers)
68 blockers = [b for b in blockers if not b.happened]
70 from zeroinstall.zerostore import unpack
72 # Create an empty directory for the new implementation
73 store = stores.stores[0]
74 tmpdir = store.get_tmp_dir_for(required_digest)
75 try:
76 # Unpack each of the downloaded archives into it in turn
77 for step in recipe.steps:
78 stream = streams[step]
79 stream.seek(0)
80 unpack.unpack_archive_over(step.url, stream, tmpdir, step.extract)
81 # Check that the result is correct and store it in the cache
82 store.check_manifest_and_rename(required_digest, tmpdir)
83 tmpdir = None
84 finally:
85 # If unpacking fails, remove the temporary directory
86 if tmpdir is not None:
87 from zeroinstall import support
88 support.ro_rmtree(tmpdir)
90 def get_feed_mirror(self, url):
91 """Return the URL of a mirror for this feed."""
92 import urlparse
93 if urlparse.urlparse(url).hostname == 'localhost':
94 return None
95 return '%s/%s/latest.xml' % (self.feed_mirror, _get_feed_dir(url))
97 def download_and_import_feed(self, feed_url, iface_cache, force = False):
98 """Download the feed, download any required keys, confirm trust if needed and import.
99 @param feed_url: the feed to be downloaded
100 @type feed_url: str
101 @param iface_cache: cache in which to store the feed
102 @type iface_cache: L{iface_cache.IfaceCache}
103 @param force: whether to abort and restart an existing download"""
104 from download import DownloadAborted
106 debug(_("download_and_import_feed %(url)s (force = %(force)d)"), {'url': feed_url, 'force': force})
107 assert not feed_url.startswith('/')
109 primary = self._download_and_import_feed(feed_url, iface_cache, force, use_mirror = False)
111 @tasks.named_async("monitor feed downloads for " + feed_url)
112 def wait_for_downloads(primary):
113 # Download just the upstream feed, unless it takes too long...
114 timeout = tasks.TimeoutBlocker(5, 'Mirror timeout') # 5 seconds
116 yield primary, timeout
117 tasks.check(timeout)
119 try:
120 tasks.check(primary)
121 if primary.happened:
122 return # OK, primary succeeded!
123 # OK, maybe it's just being slow...
124 info("Feed download from %s is taking a long time. Trying mirror too...", feed_url)
125 primary_ex = None
126 except NoTrustedKeys, ex:
127 raise # Don't bother trying the mirror if we have a trust problem
128 except ReplayAttack, ex:
129 raise # Don't bother trying the mirror if we have a replay attack
130 except DownloadAborted, ex:
131 raise # Don't bother trying the mirror if the user cancelled
132 except SafeException, ex:
133 # Primary failed
134 primary = None
135 primary_ex = ex
136 warn(_("Trying mirror, as feed download from %(url)s failed: %(exception)s"), {'url': feed_url, 'exception': ex})
138 # Start downloading from mirror...
139 mirror = self._download_and_import_feed(feed_url, iface_cache, force, use_mirror = True)
141 # Wait until both mirror and primary tasks are complete...
142 while True:
143 blockers = filter(None, [primary, mirror])
144 if not blockers:
145 break
146 yield blockers
148 if primary:
149 try:
150 tasks.check(primary)
151 if primary.happened:
152 primary = None
153 # No point carrying on with the mirror once the primary has succeeded
154 if mirror:
155 info(_("Primary feed download succeeded; aborting mirror download for %s") % feed_url)
156 mirror.dl.abort()
157 except SafeException, ex:
158 primary = None
159 primary_ex = ex
160 info(_("Feed download from %(url)s failed; still trying mirror: %(exception)s"), {'url': feed_url, 'exception': ex})
162 if mirror:
163 try:
164 tasks.check(mirror)
165 if mirror.happened:
166 mirror = None
167 if primary_ex:
168 # We already warned; no need to raise an exception too,
169 # as the mirror download succeeded.
170 primary_ex = None
171 except ReplayAttack, ex:
172 info(_("Version from mirror is older than cached version; ignoring it: %s"), ex)
173 mirror = None
174 primary_ex = None
175 except SafeException, ex:
176 info(_("Mirror download failed: %s"), ex)
177 mirror = None
179 if primary_ex:
180 raise primary_ex
182 return wait_for_downloads(primary)
184 def _download_and_import_feed(self, feed_url, iface_cache, force, use_mirror):
185 """Download and import a feed.
186 @param use_mirror: False to use primary location; True to use mirror."""
187 if use_mirror:
188 url = self.get_feed_mirror(feed_url)
189 if url is None: return None
190 else:
191 url = feed_url
193 dl = self.handler.get_download(url, force = force, hint = feed_url)
194 stream = dl.tempfile
196 @tasks.named_async("fetch_feed " + url)
197 def fetch_feed():
198 yield dl.downloaded
199 tasks.check(dl.downloaded)
201 pending = PendingFeed(feed_url, stream)
203 if use_mirror:
204 # If we got the feed from a mirror, get the key from there too
205 key_mirror = self.feed_mirror + '/keys/'
206 else:
207 key_mirror = None
209 keys_downloaded = tasks.Task(pending.download_keys(self.handler, feed_hint = feed_url, key_mirror = key_mirror), _("download keys for %s") % feed_url)
210 yield keys_downloaded.finished
211 tasks.check(keys_downloaded.finished)
213 iface = iface_cache.get_interface(pending.url)
214 if not iface_cache.update_interface_if_trusted(iface, pending.sigs, pending.new_xml):
215 blocker = self.handler.confirm_trust_keys(iface, pending.sigs, pending.new_xml)
216 if blocker:
217 yield blocker
218 tasks.check(blocker)
219 if not iface_cache.update_interface_if_trusted(iface, pending.sigs, pending.new_xml):
220 raise NoTrustedKeys(_("No signing keys trusted; not importing"))
222 task = fetch_feed()
223 task.dl = dl
224 return task
226 def download_impl(self, impl, retrieval_method, stores, force = False):
227 """Download an implementation.
228 @param impl: the selected implementation
229 @type impl: L{model.ZeroInstallImplementation}
230 @param retrieval_method: a way of getting the implementation (e.g. an Archive or a Recipe)
231 @type retrieval_method: L{model.RetrievalMethod}
232 @param stores: where to store the downloaded implementation
233 @type stores: L{zerostore.Stores}
234 @param force: whether to abort and restart an existing download
235 @rtype: L{tasks.Blocker}"""
236 assert impl
237 assert retrieval_method
239 from zeroinstall.zerostore import manifest
240 alg = impl.id.split('=', 1)[0]
241 if alg not in manifest.algorithms:
242 raise SafeException(_("Unknown digest algorithm '%(algorithm)s' for '%(implementation)s' version %(version)s") %
243 {'algorithm': alg, 'implementation': impl.feed.get_name(), 'version': impl.get_version()})
245 @tasks.async
246 def download_impl():
247 if isinstance(retrieval_method, DownloadSource):
248 blocker, stream = self.download_archive(retrieval_method, force = force, impl_hint = impl)
249 yield blocker
250 tasks.check(blocker)
252 stream.seek(0)
253 self._add_to_cache(stores, retrieval_method, stream)
254 elif isinstance(retrieval_method, Recipe):
255 blocker = self.cook(impl.id, retrieval_method, stores, force, impl_hint = impl)
256 yield blocker
257 tasks.check(blocker)
258 else:
259 raise Exception(_("Unknown download type for '%s'") % retrieval_method)
261 self.handler.impl_added_to_store(impl)
262 return download_impl()
264 def _add_to_cache(self, stores, retrieval_method, stream):
265 assert isinstance(retrieval_method, DownloadSource)
266 required_digest = retrieval_method.implementation.id
267 url = retrieval_method.url
268 stores.add_archive_to_cache(required_digest, stream, retrieval_method.url, retrieval_method.extract,
269 type = retrieval_method.type, start_offset = retrieval_method.start_offset or 0)
271 def download_archive(self, download_source, force = False, impl_hint = None):
272 """Fetch an archive. You should normally call L{download_impl}
273 instead, since it handles other kinds of retrieval method too."""
274 from zeroinstall.zerostore import unpack
276 url = download_source.url
277 if not (url.startswith('http:') or url.startswith('https:') or url.startswith('ftp:')):
278 raise SafeException(_("Unknown scheme in download URL '%s'") % url)
280 mime_type = download_source.type
281 if not mime_type:
282 mime_type = unpack.type_from_url(download_source.url)
283 if not mime_type:
284 raise SafeException(_("No 'type' attribute on archive, and I can't guess from the name (%s)") % download_source.url)
285 unpack.check_type_ok(mime_type)
286 dl = self.handler.get_download(download_source.url, force = force, hint = impl_hint)
287 dl.expected_size = download_source.size + (download_source.start_offset or 0)
288 return (dl.downloaded, dl.tempfile)
290 def download_icon(self, interface, force = False, modification_time = None):
291 """Download an icon for this interface and add it to the
292 icon cache. If the interface has no icon or we are offline, do nothing.
293 @return: the task doing the import, or None
294 @rtype: L{tasks.Task}"""
295 debug(_("download_icon %(interface)s (force = %(force)d)"), {'interface': interface, 'force': force})
297 # Find a suitable icon to download
298 for icon in interface.get_metadata(XMLNS_IFACE, 'icon'):
299 type = icon.getAttribute('type')
300 if type != 'image/png':
301 debug(_('Skipping non-PNG icon'))
302 continue
303 source = icon.getAttribute('href')
304 if source:
305 break
306 warn(_('Missing "href" attribute on <icon> in %s'), interface)
307 else:
308 info(_('No PNG icons found in %s'), interface)
309 return
311 try:
312 dl = self.handler.monitored_downloads[source]
313 if dl and force:
314 dl.abort()
315 raise KeyError
316 except KeyError:
317 dl = download.Download(source, hint = interface, modification_time = modification_time)
318 self.handler.monitor_download(dl)
320 @tasks.async
321 def download_and_add_icon():
322 stream = dl.tempfile
323 yield dl.downloaded
324 try:
325 tasks.check(dl.downloaded)
326 if dl.unmodified: return
327 stream.seek(0)
329 import shutil
330 icons_cache = basedir.save_cache_path(config_site, 'interface_icons')
331 icon_file = file(os.path.join(icons_cache, escape(interface.uri)), 'w')
332 shutil.copyfileobj(stream, icon_file)
333 except Exception, ex:
334 self.handler.report_error(ex)
336 return download_and_add_icon()
338 def download_impls(self, implementations, stores):
339 """Download the given implementations, choosing a suitable retrieval method for each."""
340 blockers = []
342 to_download = []
343 for impl in implementations:
344 debug(_("start_downloading_impls: for %(feed)s get %(implementation)s"), {'feed': impl.feed, 'implementation': impl})
345 source = self.get_best_source(impl)
346 if not source:
347 raise SafeException(_("Implementation %(implementation_id)s of interface %(interface)s"
348 " cannot be downloaded (no download locations given in "
349 "interface!)") % {'implementation_id': impl.id, 'interface': impl.feed.get_name()})
350 to_download.append((impl, source))
352 for impl, source in to_download:
353 blockers.append(self.download_impl(impl, source, stores))
355 if not blockers:
356 return None
358 @tasks.async
359 def download_impls(blockers):
360 # Record the first error log the rest
361 error = []
362 def dl_error(ex, tb = None):
363 if error:
364 self.handler.report_error(ex)
365 else:
366 error.append(ex)
367 while blockers:
368 yield blockers
369 tasks.check(blockers, dl_error)
371 blockers = [b for b in blockers if not b.happened]
372 if error:
373 raise error[0]
375 return download_impls(blockers)
377 def get_best_source(self, impl):
378 """Return the best download source for this implementation.
379 @rtype: L{model.RetrievalMethod}"""
380 if impl.download_sources:
381 return impl.download_sources[0]
382 return None