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
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"
29 RESULT_NOT_MODIFIED
= 2
31 class DownloadError(SafeException
):
32 """Download process failed."""
35 class DownloadAborted(DownloadError
):
36 """Download aborted because of a call to L{Download.abort}"""
37 def __init__(self
, message
):
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
44 @ivar tempfile: the file storing the downloaded data
46 @ivar status: the status of the download
47 @type status: (download_starting | download_fetching | download_failed | download_complete)
48 @ivar errors: data received from the child's stderr
50 @ivar expected_size: the expected final size of the file
51 @type expected_size: int | None
52 @ivar downloaded: triggered when the download ends (on success or failure)
53 @type downloaded: L{tasks.Blocker}
54 @ivar hint: hint passed by and for caller
56 @ivar child: the child process
57 @type child: subprocess.Popen
58 @ivar aborted_by_user: whether anyone has called L{abort}
59 @type aborted_by_user: bool
60 @ivar unmodified: whether the resource was not modified since the modification_time given at construction
61 @type unmodified: bool
63 __slots__
= ['url', 'tempfile', 'status', 'errors', 'expected_size', 'downloaded',
64 'hint', 'child', '_final_total_size', 'aborted_by_user',
65 'modification_time', 'unmodified']
67 def __init__(self
, url
, hint
= None, modification_time
= None):
68 """Create a new download object.
69 @param url: the resource to download
70 @param hint: object with which this download is associated (an optional hint for the GUI)
71 @param modification_time: string with HTTP date that indicates last modification time.
72 The resource will not be downloaded if it was not modified since that date.
73 @postcondition: L{status} == L{download_starting}."""
75 self
.status
= download_starting
77 self
.aborted_by_user
= False
78 self
.modification_time
= modification_time
79 self
.unmodified
= False
81 self
.tempfile
= None # Stream for result
83 self
.downloaded
= None
85 self
.expected_size
= None # Final size (excluding skipped bytes)
86 self
._final
_total
_size
= None # Set when download is finished
91 """Create a temporary file and begin the download.
92 @precondition: L{status} == L{download_starting}"""
93 assert self
.status
== download_starting
94 assert self
.downloaded
is None
96 self
.tempfile
= tempfile
.TemporaryFile(prefix
= 'injector-dl-data-')
98 task
= tasks
.Task(self
._do
_download
(), "download " + self
.url
)
99 self
.downloaded
= task
.finished
101 def _do_download(self
):
102 """Will trigger L{downloaded} when done (on success or failure)."""
105 # Can't use fork here, because Windows doesn't have it
106 assert self
.child
is None, self
.child
107 child_args
= [sys
.executable
, '-u', __file__
, self
.url
]
108 if self
.modification_time
: child_args
.append(self
.modification_time
)
109 self
.child
= subprocess
.Popen(child_args
, stderr
= subprocess
.PIPE
, stdout
= self
.tempfile
)
111 self
.status
= download_fetching
113 # Wait for child to exit, collecting error output as we go
116 yield tasks
.InputBlocker(self
.child
.stderr
, "read data from " + self
.url
)
118 data
= os
.read(self
.child
.stderr
.fileno(), 100)
123 # Download is complete...
125 assert self
.status
is download_fetching
126 assert self
.tempfile
is not None
127 assert self
.child
is not None
129 status
= self
.child
.wait()
135 if status
== RESULT_NOT_MODIFIED
:
136 debug("%s not modified", self
.url
)
138 self
.unmodified
= True
139 self
.status
= download_complete
140 self
._final
_total
_size
= 0
141 self
.downloaded
.trigger()
144 if status
and not self
.aborted_by_user
and not errors
:
145 errors
= _('Download process exited with error status '
146 'code %s') % hex(status
)
148 self
._final
_total
_size
= self
.get_bytes_downloaded_so_far()
150 stream
= self
.tempfile
154 if self
.aborted_by_user
:
155 raise DownloadAborted(errors
)
158 raise DownloadError(errors
.strip())
160 # Check that the download has the correct size, if we know what it should be.
161 if self
.expected_size
is not None:
162 size
= os
.fstat(stream
.fileno()).st_size
163 if size
!= self
.expected_size
:
164 raise SafeException(_('Downloaded archive has incorrect size.\n'
166 'Expected: %(expected_size)d bytes\n'
167 'Received: %(size)d bytes') % {'url': self
.url
, 'expected_size': self
.expected_size
, 'size': size
})
169 self
.status
= download_failed
170 _unused
, ex
, tb
= sys
.exc_info()
171 self
.downloaded
.trigger(exception
= (ex
, tb
))
173 self
.status
= download_complete
174 self
.downloaded
.trigger()
177 """Signal the current download to stop.
178 @postcondition: L{aborted_by_user}"""
179 if self
.child
is not None:
180 info(_("Killing download process %s"), self
.child
.pid
)
182 os
.kill(self
.child
.pid
, signal
.SIGTERM
)
183 self
.aborted_by_user
= True
185 self
.status
= download_failed
187 def get_current_fraction(self
):
188 """Returns the current fraction of this download that has been fetched (from 0 to 1),
189 or None if the total size isn't known.
190 @return: fraction downloaded
191 @rtype: int | None"""
192 if self
.status
is download_starting
:
194 if self
.tempfile
is None:
196 if self
.expected_size
is None:
197 return None # Unknown
198 current_size
= self
.get_bytes_downloaded_so_far()
199 return float(current_size
) / self
.expected_size
201 def get_bytes_downloaded_so_far(self
):
202 """Get the download progress. Will be zero if the download has not yet started.
204 if self
.status
is download_starting
:
206 elif self
.status
is download_fetching
:
207 return os
.fstat(self
.tempfile
.fileno()).st_size
209 return self
._final
_total
_size
212 return _("<Download from %s>") % self
.url
214 if __name__
== '__main__':
215 def _download_as_child(url
, if_modified_since
):
216 from httplib
import HTTPException
217 from urllib2
import urlopen
, Request
, HTTPError
, URLError
219 #print "Child downloading", url
220 if url
.startswith('http:') or url
.startswith('https:') or url
.startswith('ftp:'):
222 if url
.startswith('http:') and if_modified_since
:
223 req
.add_header('If-Modified-Since', if_modified_since
)
226 raise Exception(_('Unsupported URL protocol in: %s') % url
)
230 except AttributeError:
231 sock
= src
.fp
.fp
._sock
# Python 2.5 on FreeBSD
233 data
= sock
.recv(256)
238 except (HTTPError
, URLError
, HTTPException
), ex
:
239 if isinstance(ex
, HTTPError
) and ex
.code
== 304: # Not modified
240 sys
.exit(RESULT_NOT_MODIFIED
)
241 print >>sys
.stderr
, "Error downloading '" + url
+ "': " + (str(ex
) or str(ex
.__class
__.__name
__))
242 sys
.exit(RESULT_FAILED
)
243 assert (len(sys
.argv
) == 2) or (len(sys
.argv
) == 3), "Usage: download URL [If-Modified-Since-Date], not %s" % sys
.argv
244 if len(sys
.argv
) >= 3:
245 if_modified_since_date
= sys
.argv
[2]
247 if_modified_since_date
= None
248 _download_as_child(sys
.argv
[1], if_modified_since_date
)