Don't create .desktop filenames with spaces; xdg-desktop-menu gets confused
[zeroinstall/zeroinstall-mseaborn.git] / zeroinstall / injector / download.py
blob7a85ebb5381a315a69de420904498c47954caab4
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) 2006, Thomas Leonard
10 # See the README file for details, or visit http://0install.net.
12 import tempfile, os, sys
13 from zeroinstall import SafeException
14 from zeroinstall.support import tasks
15 import traceback
16 from logging import info
18 download_starting = "starting" # Waiting for UI to start it
19 download_fetching = "fetching" # In progress
20 download_complete = "complete" # Downloaded and cached OK
21 download_failed = "failed"
23 class DownloadError(SafeException):
24 """Download process failed."""
25 pass
27 class DownloadAborted(DownloadError):
28 """Download aborted because of a call to L{Download.abort}"""
29 def __init__(self, message):
30 SafeException.__init__(self, message or "Download aborted at user's request")
32 class Download(object):
33 """A download of a single resource to a temporary file.
34 @ivar url: the URL of the resource being fetched
35 @type url: str
36 @ivar tempfile: the file storing the downloaded data
37 @type tempfile: file
38 @ivar status: the status of the download
39 @type status: (download_starting | download_fetching | download_failed | download_complete)
40 @ivar errors: data received from the child's stderr
41 @type errors: str
42 @ivar expected_size: the expected final size of the file
43 @type expected_size: int | None
44 @ivar downloaded: triggered when the download ends (on success or failure)
45 @type downloaded: L{tasks.Blocker}
46 @ivar hint: hint passed by and for caller
47 @type hint: object
48 @ivar child_pid: the child process's PID
49 @type child_pid: int
50 @ivar aborted_by_user: whether anyone has called L{abort}
51 @type aborted_by_user: bool
52 """
53 __slots__ = ['url', 'tempfile', 'status', 'errors', 'expected_size', 'downloaded',
54 'hint', 'child_pid', '_final_total_size', 'aborted_by_user']
56 def __init__(self, url, hint = None):
57 """Create a new download object.
58 @param url: the resource to download
59 @param hint: object with which this download is associated (an optional hint for the GUI)
60 @postcondition: L{status} == L{download_starting}."""
61 self.url = url
62 self.status = download_starting
63 self.hint = hint
64 self.aborted_by_user = False
66 self.tempfile = None # Stream for result
67 self.errors = None
68 self.downloaded = None
70 self.expected_size = None # Final size (excluding skipped bytes)
71 self._final_total_size = None # Set when download is finished
73 self.child_pid = None
75 def start(self):
76 """Create a temporary file and begin the download.
77 @precondition: L{status} == L{download_starting}"""
78 assert self.status == download_starting
79 assert self.downloaded is None
81 self.tempfile = tempfile.TemporaryFile(prefix = 'injector-dl-data-')
83 task = tasks.Task(self._do_download(), "download " + self.url)
84 self.downloaded = task.finished
86 def _do_download(self):
87 """Will trigger L{downloaded} when done (on success or failure)."""
88 error_r, error_w = os.pipe()
89 self.errors = ''
91 self.child_pid = os.fork()
92 if self.child_pid == 0:
93 # We are the child
94 try:
95 os.close(error_r)
96 os.dup2(error_w, 2)
97 os.close(error_w)
98 self._download_as_child()
99 finally:
100 os._exit(1)
102 # We are the parent
103 os.close(error_w)
104 self.status = download_fetching
106 # Wait for child to exit, collecting error output as we go
108 while True:
109 yield tasks.InputBlocker(error_r, "read data from " + self.url)
111 data = os.read(error_r, 100)
112 if not data:
113 break
114 self.errors += data
116 # Download is complete...
118 assert self.status is download_fetching
119 assert self.tempfile is not None
120 assert self.child_pid is not None
122 pid, status = os.waitpid(self.child_pid, 0)
123 assert pid == self.child_pid
124 self.child_pid = None
126 errors = self.errors
127 self.errors = None
129 if status and not self.aborted_by_user and not errors:
130 errors = 'Download process exited with error status ' \
131 'code ' + hex(status)
133 self._final_total_size = self.get_bytes_downloaded_so_far()
135 stream = self.tempfile
136 self.tempfile = None
138 try:
139 if self.aborted_by_user:
140 raise DownloadAborted(errors)
142 if errors:
143 raise DownloadError(errors.strip())
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 size = os.fstat(stream.fileno()).st_size
148 if size != self.expected_size:
149 raise SafeException('Downloaded archive has incorrect size.\n'
150 'URL: %s\n'
151 'Expected: %d bytes\n'
152 'Received: %d bytes' % (self.url, self.expected_size, size))
153 except:
154 self.status = download_failed
155 _, ex, tb = sys.exc_info()
156 self.downloaded.trigger(exception = (ex, tb))
157 else:
158 self.status = download_complete
159 self.downloaded.trigger()
161 def _download_as_child(self):
162 try:
163 from httplib import HTTPException
164 from urllib2 import urlopen, HTTPError, URLError
165 import shutil
166 try:
167 #print "Child downloading", self.url
168 if self.url.startswith('/'):
169 if not os.path.isfile(self.url):
170 print >>sys.stderr, "File '%s' does not " \
171 "exist!" % self.url
172 return
173 src = file(self.url)
174 elif self.url.startswith('http:') or self.url.startswith('ftp:'):
175 src = urlopen(self.url)
176 else:
177 raise Exception('Unsupported URL protocol in: ' + self.url)
179 shutil.copyfileobj(src, self.tempfile, length=1)
180 self.tempfile.flush()
182 os._exit(0)
183 except (HTTPError, URLError, HTTPException), ex:
184 print >>sys.stderr, "Error downloading '" + self.url + "': " + (str(ex) or str(ex.__class__.__name__))
185 except:
186 traceback.print_exc()
188 def abort(self):
189 """Signal the current download to stop.
190 @postcondition: L{aborted_by_user}"""
191 if self.child_pid is not None:
192 info("Killing download process %s", self.child_pid)
193 import signal
194 os.kill(self.child_pid, signal.SIGTERM)
195 self.aborted_by_user = True
196 else:
197 self.status = download_failed
199 def get_current_fraction(self):
200 """Returns the current fraction of this download that has been fetched (from 0 to 1),
201 or None if the total size isn't known.
202 @return: fraction downloaded
203 @rtype: int | None"""
204 if self.status is download_starting:
205 return 0
206 if self.tempfile is None:
207 return 1
208 if self.expected_size is None:
209 return None # Unknown
210 current_size = self.get_bytes_downloaded_so_far()
211 return float(current_size) / self.expected_size
213 def get_bytes_downloaded_so_far(self):
214 """Get the download progress. Will be zero if the download has not yet started.
215 @rtype: int"""
216 if self.status is download_starting:
217 return 0
218 elif self.status is download_fetching:
219 return os.fstat(self.tempfile.fileno()).st_size
220 else:
221 return self._final_total_size
223 def __str__(self):
224 return "<Download from %s>" % self.url