Update year to 2009 in various places
[zeroinstall/zeroinstall-rsl.git] / zeroinstall / injector / download.py
blob5fb31b1fde48753e05b6bcb561d4d66856af70a4
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, subprocess
13 from zeroinstall import SafeException
14 from zeroinstall.support import tasks
15 from logging import info
17 download_starting = "starting" # Waiting for UI to start it
18 download_fetching = "fetching" # In progress
19 download_complete = "complete" # Downloaded and cached OK
20 download_failed = "failed"
22 class DownloadError(SafeException):
23 """Download process failed."""
24 pass
26 class DownloadAborted(DownloadError):
27 """Download aborted because of a call to L{Download.abort}"""
28 def __init__(self, message):
29 SafeException.__init__(self, message or "Download aborted at user's request")
31 class Download(object):
32 """A download of a single resource to a temporary file.
33 @ivar url: the URL of the resource being fetched
34 @type url: str
35 @ivar tempfile: the file storing the downloaded data
36 @type tempfile: file
37 @ivar status: the status of the download
38 @type status: (download_starting | download_fetching | download_failed | download_complete)
39 @ivar errors: data received from the child's stderr
40 @type errors: str
41 @ivar expected_size: the expected final size of the file
42 @type expected_size: int | None
43 @ivar downloaded: triggered when the download ends (on success or failure)
44 @type downloaded: L{tasks.Blocker}
45 @ivar hint: hint passed by and for caller
46 @type hint: object
47 @ivar child: the child process
48 @type child: L{subprocess.Popen}
49 @ivar aborted_by_user: whether anyone has called L{abort}
50 @type aborted_by_user: bool
51 """
52 __slots__ = ['url', 'tempfile', 'status', 'errors', 'expected_size', 'downloaded',
53 'hint', 'child', '_final_total_size', 'aborted_by_user']
55 def __init__(self, url, hint = None):
56 """Create a new download object.
57 @param url: the resource to download
58 @param hint: object with which this download is associated (an optional hint for the GUI)
59 @postcondition: L{status} == L{download_starting}."""
60 self.url = url
61 self.status = download_starting
62 self.hint = hint
63 self.aborted_by_user = False
65 self.tempfile = None # Stream for result
66 self.errors = None
67 self.downloaded = None
69 self.expected_size = None # Final size (excluding skipped bytes)
70 self._final_total_size = None # Set when download is finished
72 self.child = None
74 def start(self):
75 """Create a temporary file and begin the download.
76 @precondition: L{status} == L{download_starting}"""
77 assert self.status == download_starting
78 assert self.downloaded is None
80 self.tempfile = tempfile.TemporaryFile(prefix = 'injector-dl-data-')
82 task = tasks.Task(self._do_download(), "download " + self.url)
83 self.downloaded = task.finished
85 def _do_download(self):
86 """Will trigger L{downloaded} when done (on success or failure)."""
87 self.errors = ''
89 # Can't use fork here, because Windows doesn't have it
90 assert self.child is None, self.child
91 self.child = subprocess.Popen([sys.executable, '-u', __file__, self.url], stderr = subprocess.PIPE, stdout = self.tempfile)
93 self.status = download_fetching
95 # Wait for child to exit, collecting error output as we go
97 while True:
98 yield tasks.InputBlocker(self.child.stderr, "read data from " + self.url)
100 data = os.read(self.child.stderr.fileno(), 100)
101 if not data:
102 break
103 self.errors += data
105 # Download is complete...
107 assert self.status is download_fetching
108 assert self.tempfile is not None
109 assert self.child is not None
111 status = self.child.wait()
112 self.child = None
114 errors = self.errors
115 self.errors = None
117 if status and not self.aborted_by_user and not errors:
118 errors = 'Download process exited with error status ' \
119 'code ' + hex(status)
121 self._final_total_size = self.get_bytes_downloaded_so_far()
123 stream = self.tempfile
124 self.tempfile = None
126 try:
127 if self.aborted_by_user:
128 raise DownloadAborted(errors)
130 if errors:
131 raise DownloadError(errors.strip())
133 # Check that the download has the correct size, if we know what it should be.
134 if self.expected_size is not None:
135 size = os.fstat(stream.fileno()).st_size
136 if size != self.expected_size:
137 raise SafeException('Downloaded archive has incorrect size.\n'
138 'URL: %s\n'
139 'Expected: %d bytes\n'
140 'Received: %d bytes' % (self.url, self.expected_size, size))
141 except:
142 self.status = download_failed
143 _, ex, tb = sys.exc_info()
144 self.downloaded.trigger(exception = (ex, tb))
145 else:
146 self.status = download_complete
147 self.downloaded.trigger()
149 def abort(self):
150 """Signal the current download to stop.
151 @postcondition: L{aborted_by_user}"""
152 if self.child is not None:
153 info("Killing download process %s", self.child.pid)
154 import signal
155 os.kill(self.child.pid, signal.SIGTERM)
156 self.aborted_by_user = True
157 else:
158 self.status = download_failed
160 def get_current_fraction(self):
161 """Returns the current fraction of this download that has been fetched (from 0 to 1),
162 or None if the total size isn't known.
163 @return: fraction downloaded
164 @rtype: int | None"""
165 if self.status is download_starting:
166 return 0
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_starting:
178 return 0
179 elif self.status is download_fetching:
180 return os.fstat(self.tempfile.fileno()).st_size
181 else:
182 return self._final_total_size
184 def __str__(self):
185 return "<Download from %s>" % self.url
187 if __name__ == '__main__':
188 def _download_as_child(url):
189 from httplib import HTTPException
190 from urllib2 import urlopen, HTTPError, URLError
191 try:
192 #print "Child downloading", url
193 if url.startswith('/'):
194 if not os.path.isfile(url):
195 print >>sys.stderr, "File '%s' does not " \
196 "exist!" % url
197 return
198 src = file(url)
199 elif url.startswith('http:') or url.startswith('ftp:'):
200 src = urlopen(url)
201 else:
202 raise Exception('Unsupported URL protocol in: ' + url)
204 while True:
205 data = src.fp._sock.recv(256)
206 if not data: break
207 os.write(1, data)
209 sys.exit(0)
210 except (HTTPError, URLError, HTTPException), ex:
211 print >>sys.stderr, "Error downloading '" + url + "': " + (str(ex) or str(ex.__class__.__name__))
212 sys.exit(1)
213 assert len(sys.argv) == 2, "Usage: download URL, not %s" % sys.argv
214 _download_as_child(sys.argv[1])