Added support for fetching archives using ftp, not just http.
[zeroinstall.git] / zeroinstall / injector / download.py
blob1f339d3c878266047c003d60823b661fd24f5a56
1 import tempfile, os, sys
2 from model import Interface, DownloadSource, SafeException
3 import traceback
4 from urllib2 import urlopen, HTTPError, URLError
5 from logging import warn
7 download_starting = "starting" # Waiting for UI to start it
8 download_fetching = "fetching" # In progress
9 download_checking = "checking" # Checking GPG sig (possibly interactive)
10 download_complete = "complete" # Downloaded and cached OK
11 download_failed = "failed"
13 _downloads = {} # URL -> Download
15 class DownloadError(SafeException):
16 pass
18 class Download:
19 url = None
20 tempfile = None # Stream for result
21 status = None # download_*
22 errors = None
24 child_pid = None
25 child_stderr = None
27 def __init__(self, url):
28 "Initial status is starting."
29 self.url = url
30 self.status = download_starting
32 def start(self):
33 """Returns stderr stream from child. Call error_stream_closed() when
34 it returns EOF."""
35 assert self.status == download_starting
36 self.tempfile = tempfile.TemporaryFile(prefix = 'injector-dl-data-')
38 error_r, error_w = os.pipe()
39 self.errors = ''
41 self.child_pid = os.fork()
42 if self.child_pid == 0:
43 # We are the child
44 try:
45 os.close(error_r)
46 os.dup2(error_w, 2)
47 os.close(error_w)
48 self.download_as_child()
49 finally:
50 os._exit(1)
52 # We are the parent
53 os.close(error_w)
54 self.status = download_fetching
55 return os.fdopen(error_r, 'r')
57 def download_as_child(self):
58 try:
59 import shutil
60 #print "Child downloading", self.url
61 if self.url.startswith('/'):
62 if not os.path.isfile(self.url):
63 print >>sys.stderr, "File '%s' does not " \
64 "exist!" % self.url
65 return
66 src = file(self.url)
67 elif self.url.startswith('http:') or self.url.startswith('ftp:'):
68 src = urlopen(self.url)
69 else:
70 raise Exception('Unsupported URL protocol in: ' + self.url)
72 shutil.copyfileobj(src, self.tempfile)
73 self.tempfile.flush()
75 os._exit(0)
76 except (HTTPError, URLError), ex:
77 print >>sys.stderr, "Error downloading '" + self.url + "': " + str(ex)
78 except:
79 traceback.print_exc()
81 def error_stream_data(self, data):
82 """Passed with result of os.read(error_stream, n). Can be
83 called multiple times, once for each read."""
84 assert data
85 assert self.status is download_fetching
86 self.errors += data
88 def error_stream_closed(self):
89 """Ends a download. Status changes from fetching to checking.
90 Returns data stream."""
91 assert self.status is download_fetching
92 assert self.tempfile is not None
93 assert self.child_pid is not None
95 pid, status = os.waitpid(self.child_pid, 0)
96 assert pid == self.child_pid
97 self.child_pid = None
99 errors = self.errors
100 self.errors = None
102 if status and not errors:
103 errors = 'Download process exited with error status ' \
104 'code ' + hex(status)
106 stream = self.tempfile
107 self.tempfile = None
109 if errors:
110 self.status = download_failed
111 raise DownloadError(errors)
112 else:
113 self.status = download_checking
115 stream.seek(0)
116 return stream
118 def abort(self):
119 if self.child_pid is not None:
120 warn("Killing download process %s", self.child_pid)
121 import signal
122 os.kill(self.child_pid, signal.SIGTERM)
123 else:
124 self.status = download_failed
126 class InterfaceDownload(Download):
127 def __init__(self, interface, url = None):
128 assert isinstance(interface, Interface)
129 Download.__init__(self, url or interface.uri)
130 self.interface = interface
132 class ImplementationDownload(Download):
133 def __init__(self, source):
134 assert isinstance(source, DownloadSource)
135 Download.__init__(self, source.url)
136 self.source = source
138 def error_stream_closed(self):
139 stream = Download.error_stream_closed(self)
140 size = os.fstat(stream.fileno()).st_size
141 if size != self.source.size:
142 raise SafeException('Downloaded archive has incorrect size.\n'
143 'URL: %s\n'
144 'Expected: %d bytes\n'
145 'Received: %d bytes' % (self.url, self.source.size, size))
146 return stream
148 def get_current_fraction(self):
149 if self.status is download_starting:
150 return 0
151 if self.tempfile is None:
152 return 1
153 current_size = os.fstat(self.tempfile.fileno()).st_size
154 return float(current_size) / self.source.size
156 def begin_iface_download(interface, force):
157 """Start downloading interface.
158 If a Download object already exists (any state; in progress, failed or
159 completed) and force is False, does nothing and returns None.
160 If force is True, any existing download is destroyed and a new one created."""
161 return _begin_download(InterfaceDownload(interface), force)
163 path_dirs = os.environ.get('PATH', '/bin:/usr/bin').split(':')
164 def _available_in_path(command):
165 for x in path_dirs:
166 if os.path.isfile(os.path.join(x, command)):
167 return True
168 return False
170 def begin_impl_download(source, force = False):
171 #print "Need to downlaod", source.url
172 if source.url.endswith('.rpm'):
173 if not _available_in_path('rpm2cpio'):
174 raise SafeException("The URL '%s' looks like an RPM, but you don't have the rpm2cpio command "
175 "I need to extract it. Install the 'rpm' package first (this works even if "
176 "you're on a non-RPM-based distribution such as Debian)." % source.url)
177 return _begin_download(ImplementationDownload(source), force)
179 def _begin_download(new_dl, force):
180 dl = _downloads.get(new_dl.url, None)
181 if dl:
182 if force:
183 dl.abort()
184 del _downloads[new_dl.url]
185 else:
186 return None # Already downloading
188 _downloads[new_dl.url] = new_dl
190 assert new_dl.status == download_starting
191 return new_dl