Release 0.12
[0publish-gui.git] / archive.py
blobf421e5a6eb34d9e9e0a1aed094e09310bb46782d
1 from xml.dom import Node, minidom
2 import re
4 import rox, os, sys, urlparse, tempfile, shutil, time, urllib
5 from rox import g, tasks
6 import gtk.glade
8 from zeroinstall.support import ro_rmtree
9 from zeroinstall.injector import model
10 from zeroinstall.zerostore import unpack, manifest, NotStored
12 from logging import warn
13 import signing
14 from xmltools import *
15 import main
17 dotted_ints = '[0-9]+(.[0-9]+)*'
18 version_regexp = '[^a-zA-Z0-9](%s)(-(pre|rc|post|)%s)*' % (dotted_ints, dotted_ints)
20 def get_combo_value(combo):
21 i = combo.get_active()
22 m = combo.get_model()
23 return m[i][0]
25 watch = gtk.gdk.Cursor(gtk.gdk.WATCH)
27 def autopackage_get_details(package):
28 size = None
29 type = 'application/x-bzip-compressed-tar'
30 for line in file(package):
31 if line.startswith('export dataSize=') or line.startswith('export data_size='):
32 size = os.path.getsize(package) - int(line.split('"', 2)[1])
33 elif line.startswith('compression=') and 'lzma' in line:
34 type = 'application/x-lzma-compressed-tar'
35 if line.startswith('## END OF STUB'): break
36 if size is None:
37 raise Exception("Can't find payload in autopackage (missing 'dataSize')")
38 return size, type
40 def try_parse_version(version_str):
41 try:
42 return model.parse_version(version_str)
43 except model.SafeException, ex:
44 warn("Bad version number '%s'", ex)
45 return None
47 class AddArchiveBox:
48 def __init__(self, feed_editor, local_archive = None):
49 self.feed_editor = feed_editor
50 self.tmpdir = None
51 self.mime_type = self.start_offset = None
53 widgets = gtk.glade.XML(main.gladefile, 'add_archive')
55 tree = widgets.get_widget('extract_list')
56 model = g.TreeStore(str)
57 tree.set_model(model)
58 selection = tree.get_selection()
59 selection.set_mode(g.SELECTION_BROWSE)
61 cell = g.CellRendererText()
62 col = g.TreeViewColumn('Extract', cell)
63 col.add_attribute(cell, 'text', 0)
64 tree.append_column(col)
66 dialog = widgets.get_widget('add_archive')
68 mime_type = widgets.get_widget('mime_type')
69 mime_type.set_active(0)
71 def local_archive_changed(chooser):
72 model.clear()
73 path = chooser.get_filename()
74 widgets.get_widget('subdirectory_frame').set_sensitive(False)
75 self.destroy_tmp()
76 if not path: return
78 if mime_type.get_active() == 0:
79 type = None
80 else:
81 type = mime_type.get_active_text()
83 archive_url = widgets.get_widget('archive_url')
84 url = archive_url.get_text()
85 if not url:
86 url = 'http://SITE/' + os.path.basename(path)
87 archive_url.set_text(url)
89 start_offset = 0
90 if not type:
91 if url.endswith('.package'):
92 type = 'Autopackage'
93 else:
94 type = unpack.type_from_url(url)
96 if type == 'Autopackage':
97 # Autopackage isn't a real type. Examine the .package file
98 # and find out what it really is.
99 start_offset, type = autopackage_get_details(path)
101 self.tmpdir = tempfile.mkdtemp('-0publish-gui')
102 try:
103 # Must be readable to helper process running as 'zeroinst'...
104 old_umask = os.umask(0022)
105 try:
106 unpack_dir = os.path.join(self.tmpdir, 'unpacked')
107 os.mkdir(unpack_dir)
109 dialog.window.set_cursor(watch)
110 gtk.gdk.flush()
111 try:
112 unpack.unpack_archive(url, file(path), unpack_dir,
113 type = type, start_offset = start_offset)
114 manifest.fixup_permissions(unpack_dir)
115 finally:
116 dialog.window.set_cursor(None)
117 finally:
118 os.umask(old_umask)
119 except:
120 chooser.unselect_filename(path)
121 self.destroy_tmp()
122 raise
123 iter = model.append(None, ['Everything'])
124 items = os.listdir(unpack_dir)
125 for f in items:
126 model.append(iter, [f])
127 tree.expand_all()
128 # Choose a sensible default
129 iter = model.get_iter_root()
130 if len(items) == 1 and \
131 os.path.isdir(os.path.join(unpack_dir, items[0])) and \
132 items[0] not in ('usr', 'opt', 'bin', 'etc', 'sbin', 'doc', 'var'):
133 iter = model.iter_children(iter)
134 selection.select_iter(iter)
136 self.mime_type = type
137 self.start_offset = start_offset
138 widgets.get_widget('subdirectory_frame').set_sensitive(True)
140 local_archive_button = widgets.get_widget('local_archive')
141 local_archive_button.connect('selection-changed', local_archive_changed)
142 widgets.get_widget('subdirectory_frame').set_sensitive(False)
144 def download(button):
145 url = widgets.get_widget('archive_url').get_text()
146 if not url:
147 raise Exception("Enter a URL to download from!")
149 chooser = g.FileChooserDialog('Save archive as...', dialog, g.FILE_CHOOSER_ACTION_SAVE)
150 chooser.set_current_folder(os.getcwd()) # Needed with GTK 2.24 to avoid "Recently Used" thing
151 chooser.set_current_name(os.path.basename(url))
152 chooser.add_button(g.STOCK_CANCEL, g.RESPONSE_CANCEL)
153 chooser.add_button(g.STOCK_SAVE, g.RESPONSE_OK)
154 chooser.set_default_response(g.RESPONSE_OK)
155 resp = chooser.run()
156 filename = chooser.get_filename()
157 chooser.destroy()
158 if resp != g.RESPONSE_OK:
159 return
161 DownloadBox(url, filename, local_archive_button, dialog)
163 widgets.get_widget('download').connect('clicked', download)
165 def resp(dialog, r):
166 if r == g.RESPONSE_OK:
167 if not self.tmpdir:
168 rox.alert("Archive not downloaded yet!")
169 return
170 unpack_dir = os.path.join(self.tmpdir, 'unpacked')
172 url = widgets.get_widget('archive_url').get_text()
173 if urlparse.urlparse(url)[1] == '':
174 raise Exception('Missing host name in URL "%s"' % url)
175 if urlparse.urlparse(url)[2] == '':
176 raise Exception('Missing resource part in URL "%s"' % url)
177 local_archive = widgets.get_widget('local_archive').get_filename()
178 if not local_archive:
179 raise Exception('Please select a local file')
180 if selection.iter_is_selected(model.get_iter_root()):
181 root = unpack_dir
182 extract = None
183 else:
184 _, iter = selection.get_selected()
185 extract = model[iter][0]
186 root = os.path.join(unpack_dir, extract)
188 size = os.path.getsize(local_archive)
189 if self.start_offset:
190 size -= self.start_offset
191 self.create_archive_element(url, self.mime_type, root, extract, size,
192 self.start_offset)
193 self.destroy_tmp()
194 dialog.destroy()
196 dialog.connect('response', resp)
198 if local_archive:
199 local_archive_button.set_filename(local_archive)
200 initial_url = 'http://SITE/' + os.path.basename(local_archive)
201 widgets.get_widget('archive_url').set_text(initial_url)
203 def destroy_tmp(self):
204 if self.tmpdir:
205 ro_rmtree(self.tmpdir)
206 self.tmpdir = None
208 def create_archive_element(self, url, mime_type, root, extract, size, start_offset):
209 alg = manifest.get_algorithm('sha1new')
210 digest = alg.new_digest()
211 for line in alg.generate_manifest(root):
212 digest.update(line + '\n')
213 id = alg.getID(digest)
215 # Add it to the cache if missing
216 # Helps with setting 'main' attribute later
217 try:
218 main.stores.lookup(id)
219 except NotStored:
220 main.stores.add_dir_to_cache(id, root)
222 # Do we already have an implementation with this digest?
223 impl_element = self.feed_editor.find_implementation(id)
225 if impl_element is None:
226 # No. Create a new implementation. Guess the details...
228 leaf = url.split('/')[-1]
229 version = None
230 for m in re.finditer(version_regexp, leaf):
231 match = m.group()[1:]
232 if version is None or len(version) < len(match):
233 version = match
235 existing_versions = self.feed_editor.list_versions()
236 older_versions = []
237 if existing_versions and version:
238 parsed_version = try_parse_version(version)
239 if parsed_version:
240 older_versions = [(v, elem) for v, elem in existing_versions if v < parsed_version]
242 impl_element = self.feed_editor.doc.createElementNS(XMLNS_INTERFACE, 'implementation')
244 if older_versions:
245 # Try to add it just after the previous version's element in the XML document
246 insert_after(impl_element, max(older_versions)[1])
247 elif existing_versions:
248 # Else add it before the first
249 insert_before(impl_element, min(existing_versions)[1])
250 else:
251 # Put it in the root
252 insert_element(impl_element, self.feed_editor.doc.documentElement)
254 impl_element.setAttribute('id', id)
255 impl_element.setAttribute('released', time.strftime('%Y-%m-%d'))
256 if version: impl_element.setAttribute('version', version)
257 created_impl = True
258 else:
259 created_impl = False
261 archive_element = create_element(impl_element, 'archive')
262 archive_element.setAttribute('size', str(size))
263 archive_element.setAttribute('href', url)
264 if extract: archive_element.setAttribute('extract', extract)
265 if mime_type: archive_element.setAttribute('type', mime_type)
266 if start_offset: archive_element.setAttribute('start-offset', str(start_offset))
268 self.feed_editor.update_version_model()
270 if created_impl:
271 self.feed_editor.edit_properties(element = impl_element)
273 class DownloadBox:
274 def __init__(self, url, path, archive_button, parent):
275 widgets = gtk.glade.XML(main.gladefile, 'download')
276 gtk.gdk.flush()
278 output = file(path, 'w')
280 dialog = widgets.get_widget('download')
281 dialog.set_transient_for(parent)
282 progress = widgets.get_widget('progress')
284 cancelled = tasks.Blocker()
285 def resp(box, resp):
286 cancelled.trigger()
287 dialog.connect('response', resp)
289 def download():
290 stream = None
292 def cleanup():
293 dialog.destroy()
294 if output:
295 output.close()
296 if stream:
297 stream.close()
299 try:
300 # (urllib2 is buggy; no fileno)
301 stream = urllib.urlopen(url)
302 size = float(stream.info().get('Content-Length', None))
303 got = 0
305 while True:
306 yield signing.InputBlocker(stream), cancelled
307 if cancelled.happened:
308 raise Exception("Download cancelled at user's request")
309 data = os.read(stream.fileno(), 1024)
310 if not data: break
311 output.write(data)
312 got += len(data)
314 if size:
315 progress.set_fraction(got / size)
316 else:
317 progress.pulse()
318 except:
319 # No finally in python 2.4
320 cleanup()
321 raise
322 else:
323 cleanup()
324 archive_button.set_filename(path)
326 dialog.show()
327 tasks.Task(download())