Cancelling a download only logs at info level, not warning.
[zeroinstall.git] / zeroinstall / injector / download.py
blobc9af92352b0c399db48f9e403bb8ec66097ea2c1
1 """
2 Handles URL downloads.
4 This is the low-level interface for downloading interfaces, implementations, icons, etc.
6 @see: L{policy.Policy.begin_iface_download}
7 @see: L{policy.Policy.begin_archive_download}
8 @see: L{policy.Policy.begin_icon_download}
9 """
11 # Copyright (C) 2006, Thomas Leonard
12 # See the README file for details, or visit http://0install.net.
14 import tempfile, os, sys
15 from zeroinstall import SafeException
16 import traceback
17 from logging import warn
19 download_starting = "starting" # Waiting for UI to start it
20 download_fetching = "fetching" # In progress
21 download_checking = "checking" # Checking GPG sig (possibly interactive)
22 download_complete = "complete" # Downloaded and cached OK
23 download_failed = "failed"
25 class DownloadError(SafeException):
26 pass
28 class Download(object):
29 __slots__ = ['url', 'tempfile', 'status', 'errors', 'expected_size',
30 'expected_size', 'child_pid', 'child_stderr', 'on_success']
32 def __init__(self, url):
33 "Initial status is starting."
34 self.url = url
35 self.status = download_starting
36 self.on_success = []
38 self.tempfile = None # Stream for result
39 self.errors = None
41 self.expected_size = None # Final size (excluding skipped bytes)
43 self.child_pid = None
44 self.child_stderr = None
46 def start(self):
47 """Returns stderr stream from child. Call error_stream_closed() when
48 it returns EOF."""
49 assert self.status == download_starting
50 self.tempfile = tempfile.TemporaryFile(prefix = 'injector-dl-data-')
52 error_r, error_w = os.pipe()
53 self.errors = ''
55 self.child_pid = os.fork()
56 if self.child_pid == 0:
57 # We are the child
58 try:
59 os.close(error_r)
60 os.dup2(error_w, 2)
61 os.close(error_w)
62 self.download_as_child()
63 finally:
64 os._exit(1)
66 # We are the parent
67 os.close(error_w)
68 self.status = download_fetching
69 return os.fdopen(error_r, 'r')
71 def download_as_child(self):
72 from urllib2 import urlopen, HTTPError, URLError
73 try:
74 import shutil
75 #print "Child downloading", self.url
76 if self.url.startswith('/'):
77 if not os.path.isfile(self.url):
78 print >>sys.stderr, "File '%s' does not " \
79 "exist!" % self.url
80 return
81 src = file(self.url)
82 elif self.url.startswith('http:') or self.url.startswith('ftp:'):
83 src = urlopen(self.url)
84 else:
85 raise Exception('Unsupported URL protocol in: ' + self.url)
87 shutil.copyfileobj(src, self.tempfile)
88 self.tempfile.flush()
90 os._exit(0)
91 except (HTTPError, URLError), ex:
92 print >>sys.stderr, "Error downloading '" + self.url + "': " + str(ex)
93 except:
94 traceback.print_exc()
96 def error_stream_data(self, data):
97 """Passed with result of os.read(error_stream, n). Can be
98 called multiple times, once for each read."""
99 assert data
100 assert self.status is download_fetching
101 self.errors += data
103 def error_stream_closed(self):
104 """Ends a download. Status changes from fetching to checking.
105 Calls the on_success callbacks with the rewound data stream on success,
106 or throws DownloadError on failure."""
107 assert self.status is download_fetching
108 assert self.tempfile is not None
109 assert self.child_pid is not None
111 pid, status = os.waitpid(self.child_pid, 0)
112 assert pid == self.child_pid
113 self.child_pid = None
115 errors = self.errors
116 self.errors = None
118 if status and not errors:
119 errors = 'Download process exited with error status ' \
120 'code ' + hex(status)
122 stream = self.tempfile
123 self.tempfile = None
125 if errors:
126 error = DownloadError(errors)
127 else:
128 error = None
130 # Check that the download has the correct size, if we know what it should be.
131 if self.expected_size is not None and not error:
132 size = os.fstat(stream.fileno()).st_size
133 if size != self.expected_size:
134 error = SafeException('Downloaded archive has incorrect size.\n'
135 'URL: %s\n'
136 'Expected: %d bytes\n'
137 'Received: %d bytes' % (self.url, self.expected_size, size))
139 if error:
140 self.status = download_failed
141 self.on_success = [] # Break GC cycles
142 raise error
143 else:
144 self.status = download_checking
146 for x in self.on_success:
147 stream.seek(0)
148 x(stream)
150 def abort(self):
151 if self.child_pid is not None:
152 info("Killing download process %s", self.child_pid)
153 import signal
154 os.kill(self.child_pid, signal.SIGTERM)
155 else:
156 self.status = download_failed
158 def get_current_fraction(self):
159 """Returns the current fraction of this download that has been fetched (from 0 to 1),
160 or None if the total size isn't known."""
161 if self.status is download_starting:
162 return 0
163 if self.tempfile is None:
164 return 1
165 if self.expected_size is None:
166 return None # Unknown
167 current_size = os.fstat(self.tempfile.fileno()).st_size
168 return float(current_size) / self.expected_size