Use threads, not processes, for downloads
[zeroinstall.git] / zeroinstall / injector / download.py
blob59efbda5ad36760db3a441c610d155a74c15585a
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
14 from zeroinstall import SafeException
15 from zeroinstall.support import tasks
16 from logging import info, debug
17 from zeroinstall import _
19 download_starting = "starting" # Waiting for UI to start it
20 download_fetching = "fetching" # In progress
21 download_complete = "complete" # Downloaded and cached OK
22 download_failed = "failed"
24 # NB: duplicated in _download_child.py
25 RESULT_OK = 0
26 RESULT_FAILED = 1
27 RESULT_NOT_MODIFIED = 2
29 import gobject; gobject.threads_init()
31 class DownloadError(SafeException):
32 """Download process failed."""
33 pass
35 class DownloadAborted(DownloadError):
36 """Download aborted because of a call to L{Download.abort}"""
37 def __init__(self, message = None):
38 SafeException.__init__(self, message or _("Download aborted at user's request"))
40 class Download(object):
41 """A download of a single resource to a temporary file.
42 @ivar url: the URL of the resource being fetched
43 @type url: str
44 @ivar tempfile: the file storing the downloaded data
45 @type tempfile: file
46 @ivar status: the status of the download
47 @type status: (download_starting | download_fetching | download_failed | download_complete)
48 @ivar expected_size: the expected final size of the file
49 @type expected_size: int | None
50 @ivar downloaded: triggered when the download ends (on success or failure)
51 @type downloaded: L{tasks.Blocker}
52 @ivar hint: hint passed by and for caller
53 @type hint: object
54 @ivar aborted_by_user: whether anyone has called L{abort}
55 @type aborted_by_user: bool
56 @ivar unmodified: whether the resource was not modified since the modification_time given at construction
57 @type unmodified: bool
58 """
59 __slots__ = ['url', 'tempfile', 'status', 'expected_size', 'downloaded',
60 'hint', '_final_total_size', 'aborted_by_user',
61 'modification_time', 'unmodified']
63 def __init__(self, url, hint = None, modification_time = None):
64 """Create a new download object.
65 @param url: the resource to download
66 @param hint: object with which this download is associated (an optional hint for the GUI)
67 @param modification_time: string with HTTP date that indicates last modification time.
68 The resource will not be downloaded if it was not modified since that date.
69 @postcondition: L{status} == L{download_starting}."""
70 self.url = url
71 self.status = download_starting
72 self.hint = hint
73 self.aborted_by_user = False
74 self.modification_time = modification_time
75 self.unmodified = False
77 self.tempfile = None # Stream for result
78 self.downloaded = None
80 self.expected_size = None # Final size (excluding skipped bytes)
81 self._final_total_size = None # Set when download is finished
83 def start(self):
84 """Create a temporary file and begin the download.
85 @precondition: L{status} == L{download_starting}"""
86 assert self.status == download_starting
87 assert self.downloaded is None
89 self.status = download_fetching
90 self.tempfile = tempfile.TemporaryFile(prefix = 'injector-dl-data-')
92 task = tasks.Task(self._do_download(), "download " + self.url)
93 self.downloaded = task.finished
95 def _do_download(self):
96 """Will trigger L{downloaded} when done (on success or failure)."""
97 from ._download_child import download_in_thread
99 result = []
100 thread_blocker = tasks.Blocker("wait for thread " + self.url)
101 def notify_done(status, ex = None):
102 result.append(status)
103 def wake_up_main():
104 thread_blocker.trigger(ex)
105 return False
106 gobject.idle_add(wake_up_main)
107 child = threading.Thread(target = lambda: download_in_thread(self.url, self.tempfile, self.modification_time, notify_done))
108 child.daemon = True
109 child.start()
111 # Wait for child to complete download.
112 yield thread_blocker
114 # Download is complete...
115 child.join()
117 assert self.status is download_fetching
118 assert self.tempfile is not None
120 status, = result
122 if status == RESULT_NOT_MODIFIED:
123 debug("%s not modified", self.url)
124 self.tempfile = None
125 self.unmodified = True
126 self.status = download_complete
127 self._final_total_size = 0
128 self.downloaded.trigger()
129 return
131 self._final_total_size = self.get_bytes_downloaded_so_far()
133 self.tempfile = None
135 if self.aborted_by_user:
136 assert self.downloaded.happened
137 raise DownloadAborted()
139 try:
141 tasks.check(thread_blocker)
143 assert status == RESULT_OK
145 # Check that the download has the correct size, if we know what it should be.
146 if self.expected_size is not None:
147 if self._final_total_size != self.expected_size:
148 raise SafeException(_('Downloaded archive has incorrect size.\n'
149 'URL: %(url)s\n'
150 'Expected: %(expected_size)d bytes\n'
151 'Received: %(size)d bytes') % {'url': self.url, 'expected_size': self.expected_size, 'size': self._final_total_size})
152 except:
153 self.status = download_failed
154 _unused, ex, tb = sys.exc_info()
155 self.downloaded.trigger(exception = (ex, tb))
156 else:
157 self.status = download_complete
158 self.downloaded.trigger()
160 def abort(self):
161 """Signal the current download to stop.
162 @postcondition: L{aborted_by_user}"""
163 self.status = download_failed
165 if self.tempfile is not None:
166 info(_("Aborting download of %s"), self.url)
167 # TODO: we currently just close the output file; the thread will end when it tries to
168 # write to it. We should try harder to stop the thread immediately (e.g. by closing its
169 # socket when known), although we can never cover all cases (e.g. a stuck DNS lookup).
170 # In any case, we don't wait for the child to exit before notifying tasks that are waiting
171 # on us.
172 self.aborted_by_user = True
173 self.tempfile.close()
174 self.tempfile = None
175 self.downloaded.trigger((DownloadAborted(), None))
177 def get_current_fraction(self):
178 """Returns the current fraction of this download that has been fetched (from 0 to 1),
179 or None if the total size isn't known.
180 @return: fraction downloaded
181 @rtype: int | None"""
182 if self.status is download_starting:
183 return 0
184 if self.tempfile is None:
185 return 1
186 if self.expected_size is None:
187 return None # Unknown
188 current_size = self.get_bytes_downloaded_so_far()
189 return float(current_size) / self.expected_size
191 def get_bytes_downloaded_so_far(self):
192 """Get the download progress. Will be zero if the download has not yet started.
193 @rtype: int"""
194 if self.status is download_starting:
195 return 0
196 elif self.status is download_fetching:
197 return os.fstat(self.tempfile.fileno()).st_size
198 else:
199 return self._final_total_size or 0
201 def __str__(self):
202 return _("<Download from %s>") % self.url