Support pool of multithreaded downloaders
[zeroinstall.git] / zeroinstall / injector / download.py
blob1531ae511d665358d11493951277399c6426e770
1 """
2 Handles URL downloads.
4 This is the low-level interface for downloading interfaces, implementations, icons, etc.
6 @see: L{fetch} higher-level API for downloads that uses this module
7 """
9 # Copyright (C) 2009, Thomas Leonard
10 # See the README file for details, or visit http://0install.net.
12 import tempfile, os, sys, threading, gobject
14 from zeroinstall import SafeException
15 from zeroinstall.support import tasks
16 from zeroinstall.injector import wget
17 from logging import info, debug
18 from zeroinstall import _
20 download_starting = "starting" # Waiting for UI to start it (no longer used)
21 download_fetching = "fetching" # In progress
22 download_complete = "complete" # Downloaded and cached OK
23 download_failed = "failed"
25 # NB: duplicated in _download_child.py
26 RESULT_OK = 0
27 RESULT_FAILED = 1
28 RESULT_NOT_MODIFIED = 2
30 class DownloadError(SafeException):
31 """Download process failed."""
32 pass
34 class DownloadAborted(DownloadError):
35 """Download aborted because of a call to L{Download.abort}"""
36 def __init__(self, message = None):
37 SafeException.__init__(self, message or _("Download aborted at user's request"))
39 class Download(gobject.GObject):
40 """A download of a single resource to a temporary file.
41 @ivar url: the URL of the resource being fetched
42 @type url: str
43 @ivar tempfile: the file storing the downloaded data
44 @type tempfile: file
45 @ivar status: the status of the download
46 @type status: (download_fetching | download_failed | download_complete)
47 @ivar expected_size: the expected final size of the file
48 @type expected_size: int | None
49 @ivar downloaded: triggered when the download ends (on success or failure)
50 @type downloaded: L{tasks.Blocker}
51 @ivar hint: hint passed by and for caller
52 @type hint: object
53 @ivar child: the child process
54 @type child: subprocess.Popen
55 @ivar aborted_by_user: whether anyone has called L{abort}
56 @type aborted_by_user: bool
57 @ivar unmodified: whether the resource was not modified since the modification_time given at construction
58 @type unmodified: bool
59 """
60 __slots__ = ['url', 'tempfile', 'status', 'expected_size', 'downloaded',
61 'child',
62 'hint', '_final_total_size', 'aborted_by_user',
63 'modification_time', 'unmodified']
65 __gsignals__ = {
66 'done': (
67 gobject.SIGNAL_RUN_FIRST, gobject.TYPE_NONE,
68 [object, object, object]),
71 def __init__(self, url, hint = None, modification_time = None, expected_size = None):
72 """Create a new download object.
73 @param url: the resource to download
74 @param hint: object with which this download is associated (an optional hint for the GUI)
75 @param modification_time: string with HTTP date that indicates last modification time.
76 The resource will not be downloaded if it was not modified since that date.
77 @postcondition: L{status} == L{download_fetching}."""
78 gobject.GObject.__init__(self)
80 self.url = url
81 self.hint = hint
82 self.aborted_by_user = False
83 self.modification_time = modification_time
84 self.unmodified = False
85 self._done_hid = None
87 self.tempfile = None # Stream for result
88 self.downloaded = None
90 self.expected_size = expected_size # Final size (excluding skipped bytes)
91 self._final_total_size = None # Set when download is finished
93 self.status = download_fetching
94 self.tempfile = tempfile.TemporaryFile(prefix = 'injector-dl-data-')
95 self.downloaded = tasks.Blocker('download %s' % self.url)
97 self._done_hid = self.connect('done', self.__done_cb)
98 # Let the caller to read tempfile before closing the connection
99 # TODO eliminate such unreliable workflow
100 gobject.idle_add(wget.start, self.url, self.modification_time,
101 self.tempfile.fileno(), self)
103 def __done_cb(self, *args):
104 if self._done_hid is not None:
105 self.disconnect(self._done_hid)
106 self._done_hid = None
107 gobject.idle_add(self._done_cb, *args)
109 def _done_cb(self, sender, status, reason, exception):
110 try:
111 self._final_total_size = 0
112 if self.aborted_by_user:
113 raise DownloadAborted()
114 elif status == 304:
115 debug("No need to download not modified %s", self.url)
116 self.unmodified = True
117 elif status == 200:
118 self._final_total_size = self.get_bytes_downloaded_so_far()
119 # Check that the download has the correct size,
120 # if we know what it should be.
121 if self.expected_size is not None and \
122 self.expected_size != self._final_total_size:
123 raise SafeException(
124 _('Downloaded archive has incorrect size.\n'
125 'URL: %(url)s\n'
126 'Expected: %(expected_size)d bytes\n'
127 'Received: %(size)d bytes') % {
128 'url': self.url,
129 'expected_size': self.expected_size,
130 'size': self._final_total_size})
131 elif exception is None:
132 raise DownloadError(_('Download %s failed: %s') % \
133 (self.url, reason))
134 except Exception, error:
135 __, ex, tb = sys.exc_info()
136 exception = (ex, tb)
138 if exception is None:
139 self.status = download_complete
140 self.downloaded.trigger()
141 else:
142 self.status = download_failed
143 self.downloaded.trigger(exception=exception)
145 def abort(self):
146 """Signal the current download to stop.
147 @postcondition: L{aborted_by_user}"""
148 self.status = download_failed
150 if self.tempfile is not None:
151 info(_("Aborting download of %s"), self.url)
152 # TODO: we currently just close the output file; the thread will end when it tries to
153 # write to it. We should try harder to stop the thread immediately (e.g. by closing its
154 # socket when known), although we can never cover all cases (e.g. a stuck DNS lookup).
155 # In any case, we don't wait for the child to exit before notifying tasks that are waiting
156 # on us.
157 self.aborted_by_user = True
158 info(_("Killing download process %s"), self.url)
159 self.__done_cb(None, None, None, None)
160 wget.abort(self.url)
162 def get_current_fraction(self):
163 """Returns the current fraction of this download that has been fetched (from 0 to 1),
164 or None if the total size isn't known.
165 @return: fraction downloaded
166 @rtype: int | None"""
167 if self.tempfile is None:
168 return 1
169 if self.expected_size is None:
170 return None # Unknown
171 current_size = self.get_bytes_downloaded_so_far()
172 return float(current_size) / self.expected_size
174 def get_bytes_downloaded_so_far(self):
175 """Get the download progress. Will be zero if the download has not yet started.
176 @rtype: int"""
177 if self.status is download_fetching:
178 return os.fstat(self.tempfile.fileno()).st_size
179 else:
180 return self._final_total_size or 0
182 def __str__(self):
183 return _("<Download from %s>") % self.url