Large-scale API cleanup
[zeroinstall.git] / zeroinstall / injector / download.py
blobc039bb957633bb892e6415e40709655384ee8299
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
14 if __name__ == '__main__':
15 sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(__file__))))
17 from zeroinstall import SafeException
18 from zeroinstall.support import tasks
19 from logging import info, debug
20 from zeroinstall import _
22 download_starting = "starting" # Waiting for UI to start it
23 download_fetching = "fetching" # In progress
24 download_complete = "complete" # Downloaded and cached OK
25 download_failed = "failed"
27 # NB: duplicated in _download_child.py
28 RESULT_OK = 0
29 RESULT_FAILED = 1
30 RESULT_NOT_MODIFIED = 2
32 class DownloadError(SafeException):
33 """Download process failed."""
34 pass
36 class DownloadAborted(DownloadError):
37 """Download aborted because of a call to L{Download.abort}"""
38 def __init__(self, message = None):
39 SafeException.__init__(self, message or _("Download aborted at user's request"))
41 class Download(object):
42 """A download of a single resource to a temporary file.
43 @ivar url: the URL of the resource being fetched
44 @type url: str
45 @ivar tempfile: the file storing the downloaded data
46 @type tempfile: file
47 @ivar status: the status of the download
48 @type status: (download_starting | download_fetching | download_failed | download_complete)
49 @ivar errors: data received from the child's stderr
50 @type errors: str
51 @ivar expected_size: the expected final size of the file
52 @type expected_size: int | None
53 @ivar downloaded: triggered when the download ends (on success or failure)
54 @type downloaded: L{tasks.Blocker}
55 @ivar hint: hint passed by and for caller
56 @type hint: object
57 @ivar child: the child process
58 @type child: subprocess.Popen
59 @ivar aborted_by_user: whether anyone has called L{abort}
60 @type aborted_by_user: bool
61 @ivar unmodified: whether the resource was not modified since the modification_time given at construction
62 @type unmodified: bool
63 """
64 __slots__ = ['url', 'tempfile', 'status', 'errors', 'expected_size', 'downloaded',
65 'hint', 'child', '_final_total_size', 'aborted_by_user',
66 'modification_time', 'unmodified']
68 def __init__(self, url, hint = None, modification_time = None):
69 """Create a new download object.
70 @param url: the resource to download
71 @param hint: object with which this download is associated (an optional hint for the GUI)
72 @param modification_time: string with HTTP date that indicates last modification time.
73 The resource will not be downloaded if it was not modified since that date.
74 @postcondition: L{status} == L{download_starting}."""
75 self.url = url
76 self.status = download_starting
77 self.hint = hint
78 self.aborted_by_user = False
79 self.modification_time = modification_time
80 self.unmodified = False
82 self.tempfile = None # Stream for result
83 self.errors = None
84 self.downloaded = None
86 self.expected_size = None # Final size (excluding skipped bytes)
87 self._final_total_size = None # Set when download is finished
89 self.child = None
91 def start(self):
92 """Create a temporary file and begin the download.
93 @precondition: L{status} == L{download_starting}"""
94 assert self.status == download_starting
95 assert self.downloaded is None
97 self.tempfile = tempfile.TemporaryFile(prefix = 'injector-dl-data-')
99 task = tasks.Task(self._do_download(), "download " + self.url)
100 self.downloaded = task.finished
102 def _do_download(self):
103 """Will trigger L{downloaded} when done (on success or failure)."""
104 self.errors = ''
106 # Can't use fork here, because Windows doesn't have it
107 assert self.child is None, self.child
108 my_dir = os.path.dirname(__file__)
109 child_args = [sys.executable, '-u', os.path.join(my_dir, '_download_child.py'), self.url]
110 if self.modification_time: child_args.append(self.modification_time)
111 self.child = subprocess.Popen(child_args, stderr = subprocess.PIPE, stdout = self.tempfile)
113 self.status = download_fetching
115 # Wait for child to exit, collecting error output as we go
117 while True:
118 yield tasks.InputBlocker(self.child.stderr, "read data from " + self.url)
120 data = os.read(self.child.stderr.fileno(), 100)
121 if not data:
122 break
123 self.errors += data
125 # Download is complete...
127 assert self.status is download_fetching
128 assert self.tempfile is not None
129 assert self.child is not None
131 status = self.child.wait()
132 self.child = None
134 errors = self.errors
135 self.errors = None
137 if status == RESULT_NOT_MODIFIED:
138 debug("%s not modified", self.url)
139 self.tempfile = None
140 self.unmodified = True
141 self.status = download_complete
142 self._final_total_size = 0
143 self.downloaded.trigger()
144 return
146 if status and not self.aborted_by_user and not errors:
147 errors = _('Download process exited with error status '
148 'code %s') % hex(status)
150 self._final_total_size = self.get_bytes_downloaded_so_far()
152 stream = self.tempfile
153 self.tempfile = None
155 try:
156 if self.aborted_by_user:
157 raise DownloadAborted(errors)
159 if errors:
160 raise DownloadError(errors.strip())
162 # Check that the download has the correct size, if we know what it should be.
163 if self.expected_size is not None:
164 size = os.fstat(stream.fileno()).st_size
165 if size != self.expected_size:
166 raise SafeException(_('Downloaded archive has incorrect size.\n'
167 'URL: %(url)s\n'
168 'Expected: %(expected_size)d bytes\n'
169 'Received: %(size)d bytes') % {'url': self.url, 'expected_size': self.expected_size, 'size': size})
170 except:
171 self.status = download_failed
172 _unused, ex, tb = sys.exc_info()
173 self.downloaded.trigger(exception = (ex, tb))
174 else:
175 self.status = download_complete
176 self.downloaded.trigger()
178 def abort(self):
179 """Signal the current download to stop.
180 @postcondition: L{aborted_by_user}"""
181 if self.child is not None:
182 info(_("Killing download process %s"), self.child.pid)
183 import signal
184 os.kill(self.child.pid, signal.SIGTERM)
185 self.aborted_by_user = True
186 else:
187 self.status = download_failed
189 def get_current_fraction(self):
190 """Returns the current fraction of this download that has been fetched (from 0 to 1),
191 or None if the total size isn't known.
192 @return: fraction downloaded
193 @rtype: int | None"""
194 if self.status is download_starting:
195 return 0
196 if self.tempfile is None:
197 return 1
198 if self.expected_size is None:
199 return None # Unknown
200 current_size = self.get_bytes_downloaded_so_far()
201 return float(current_size) / self.expected_size
203 def get_bytes_downloaded_so_far(self):
204 """Get the download progress. Will be zero if the download has not yet started.
205 @rtype: int"""
206 if self.status is download_starting:
207 return 0
208 elif self.status is download_fetching:
209 return os.fstat(self.tempfile.fileno()).st_size
210 else:
211 return self._final_total_size
213 def __str__(self):
214 return _("<Download from %s>") % self.url