Fixed public URI.
[deb2zero.git] / pkg2zero
bloba0427e02de96cd0b1b5c08cfa7649583e8856424
1 #!/usr/bin/env python
2 # Copyright (C) 2009, Thomas Leonard
3 # Copyright (C) 2008, Anders F Bjorklund
4 # See the COPYING file for details, or visit http://0install.net.
6 import sys, time
7 from optparse import OptionParser
8 import tempfile, shutil, os
9 from xml.dom import minidom
10 import gzip
11 try:
12 import xml.etree.cElementTree as ET # Python 2.5
13 except ImportError:
14 try:
15 import xml.etree.ElementTree as ET
16 except ImportError:
17 try:
18 import cElementTree as ET # http://effbot.org
19 except ImportError:
20 import elementtree.ElementTree as ET
22 import subprocess
23 try:
24 from subprocess import check_call
25 except ImportError:
26 def check_call(*popenargs, **kwargs):
27 rc = subprocess.call(*popenargs, **kwargs)
28 if rc != 0: raise OSError, rc
30 from zeroinstall.injector import model, qdom, distro
32 from support import read_child, add_node, Mappings
34 manifest_algorithm = 'sha1new'
36 deb_category_to_freedesktop = {
37 'devel' : 'Development',
38 'web' : 'Network',
39 'graphics' : 'Graphics',
40 'games' : 'Game',
43 rpm_group_to_freedesktop = {
44 'Development/Libraries' : 'Development',
47 valid_categories = [
48 'AudioVideo',
49 'Audio',
50 'Video',
51 'Development',
52 'Education',
53 'Game',
54 'Graphics',
55 'Network',
56 'Office',
57 'Settings',
58 'System',
59 'Utility',
62 # Parse command-line arguments
64 parser = OptionParser('usage: %prog [options] http://.../package.deb [target-feed.xml]\n'
65 ' %prog [options] http://.../package.rpm [target-feed.xml]\n'
66 ' %prog [options] package-name [target-feed.xml]\n'
67 'Publish a Debian or RPM package in a Zero Install feed.\n'
68 "target-feed.xml is created if it doesn't already exist.")
69 parser.add_option("-r", "--repomd-file", help="repository metadata file")
70 parser.add_option("", "--path", help="location of packages [5/os/i386]")
71 parser.add_option("-p", "--packages-file", help="Debian package index file")
72 parser.add_option("-m", "--mirror", help="location of packages [http://ftp.debian.org/debian] or [http://mirror.centos.org/centos]")
73 parser.add_option("-k", "--key", help="key to use for signing")
74 (options, args) = parser.parse_args()
76 if len(args) < 1 or len(args) > 2:
77 parser.print_help()
78 sys.exit(1)
80 # Load dependency mappings
81 mappings = Mappings()
83 class Package:
84 name = '(unknown)'
85 version = None
86 arch = None
87 category = None
88 homepage = None
89 buildtime = None
90 license = None
92 def __init__(self):
93 self.requires = []
95 class DebRepo:
96 def __init__(self, options):
97 self.packages_base_url = (options.mirror or 'http://ftp.debian.org/debian') + '/'
98 self.packages_file = options.packages_file or 'Packages'
100 def get_repo_metadata(self, pkg_name):
101 if not os.path.isfile(self.packages_file):
102 print >>sys.stderr, ("File '%s' not found (use -p to give its location).\n"
103 "Either download one (e.g. ftp://ftp.debian.org/debian/dists/stable/main/binary-amd64/Packages.bz2),\n"
104 "or specify the full URL of the .deb package to use.") % self.packages_file
105 sys.exit(1)
106 if self.packages_file.endswith('.bz2'):
107 import bz2
108 opener = bz2.BZ2File
109 else:
110 opener = file
111 pkg_data = "\n" + opener(self.packages_file).read()
112 try:
113 i = pkg_data.index('\nPackage: %s\n' % pkg_name)
114 except ValueError:
115 raise Exception("Package '%s' not found in Packages file '%s'." % (pkg_name, self.packages_file))
116 j = pkg_data.find('\n\n', i)
117 if j == -1:
118 pkg_info = pkg_data[i:]
119 else:
120 pkg_info = pkg_data[i:j]
121 filename = None
122 digest = {}
123 for line in pkg_info.split('\n'):
124 if ':' in line and not line.startswith(' '):
125 key, value = line.split(':', 1)
126 if key == 'Filename':
127 filename = value.strip()
128 elif key in ('SHA1', 'SHA256'):
129 digest[key.lower()] = value.strip()
130 if filename is None:
131 raise Exception('Filename: field not found in package data:\n' + pkg_info)
132 pkg_url = self.packages_base_url + filename
134 return pkg_url, digest
136 def get_package_metadata(self, pkg_file):
137 package = Package()
139 details = read_child(['dpkg-deb', '--info', pkg_file])
141 description_and_summary = details.split('\n Description: ')[1].split('\n')
142 package.summary = description_and_summary[0]
143 description = ''
144 for x in description_and_summary[1:]:
145 if not x: continue
146 assert x[0] == ' '
147 x = x[1:]
148 if x[0] != ' ':
149 break
150 if x == ' .':
151 description += '\n'
152 else:
153 description += x[1:].replace('. ', '. ') + '\n'
154 package.description = description.strip()
156 for line in details.split('\n'):
157 if not line: continue
158 assert line.startswith(' ')
159 line = line[1:]
160 if ':' in line:
161 key, value = line.split(':', 1)
162 value = value.strip()
163 if key == 'Section':
164 package.category = deb_category_to_freedesktop.get(value)
165 if not package.category:
166 if value != 'libs':
167 print >>sys.stderr, "Warning: no mapping for Debian category '%s'" % value
168 elif key == 'Package':
169 package.name = value
170 elif key == 'Version':
171 value = value.replace('cvs', '')
172 value = value.replace('svn', '')
173 package.version = distro.try_cleanup_distro_version(value)
174 elif key == 'Architecture':
175 if '-' in value:
176 arch, value = value.split('-', 1)
177 else:
178 arch = 'linux'
179 if value == 'amd64':
180 value = 'x86_64'
181 elif value == 'all':
182 value = '*'
183 package.arch = arch.capitalize() + '-' + value
184 elif key == 'Depends':
185 for x in value.split(','):
186 req = mappings.process(x)
187 if req:
188 package.requires.append(req)
189 return package
191 def unpack(self, pkg_file, tmp):
192 return read_child(['dpkg-deb', '-X', pkg_file, tmp])
194 class RPMRepo:
195 def __init__(self, options):
196 self.packages_base_url = (options.mirror or 'http://mirror.centos.org/centos') + '/'
197 self.packages_base_dir = (options.path or '5/os/i386') + '/'
198 self.repomd_file = options.repomd_file or 'repodata/repomd.xml'
199 if not os.path.isfile(self.repomd_file):
200 print >>sys.stderr, ("File '%s' not found (use -r to give its location).\n"
201 "Either download one (e.g. http://mirror.centos.org/centos/5/os/i386/repodata/repomd.xml),\n"
202 "or specify the full URL of the .rpm package to use.") % self.repomd_file
203 sys.exit(1)
205 def get_repo_metadata(self, pkg_name):
206 primary_file = None
207 repomd = minidom.parse(self.repomd_file)
208 for data in repomd.getElementsByTagName("data"):
209 if data.attributes["type"].nodeValue == "primary":
210 for node in data.getElementsByTagName("location"):
211 primary_file = node.attributes["href"].nodeValue
212 location = None
213 primary = ET.parse(gzip.open(primary_file))
214 NS = "http://linux.duke.edu/metadata/common"
215 metadata = primary.getroot()
216 pkg_data = None
217 for package in metadata.findall("{%s}package" % NS):
218 if package.find("{%s}name" % NS).text == pkg_name:
219 pkg_data = package
220 location = pkg_data.find("{%s}location" % NS).get("href")
221 break
222 if pkg_data is None:
223 raise Exception("Package '%s' not found in repodata." % pkg_name)
224 checksum = pkg_data.find("{%s}checksum" % NS)
225 digest = {}
226 if checksum.get("type") == "sha":
227 digest["sha1"] = checksum.text
228 if checksum.get("type") == "sha256":
229 digest["sha256"] = checksum.text
230 if location is None:
231 raise Exception('location tag not found in primary metadata:\n' + primary_file)
232 pkg_url = self.packages_base_url + self.packages_base_dir + location
234 return pkg_url, digest
236 def get_package_metadata(self, pkg_file):
237 package = Package()
239 query_format = '%{SUMMARY}\\a%{DESCRIPTION}\\a%{NAME}\\a%{VERSION}\\a%{OS}\\a%{ARCH}\\a%{URL}\\a%{GROUP}\\a%{LICENSE}\\a%{BUILDTIME}\\a[%{REQUIRES}\\n]'
240 headers = read_child(['rpm', '--qf', query_format, '-qp', pkg_file]).split('\a')
242 package.summary = headers[0].strip()
243 package.description = headers[1].strip()
245 package.name = headers[2]
246 value = headers[3]
247 value = value.replace('cvs', '')
248 value = value.replace('svn', '')
249 value = distro.try_cleanup_distro_version(value)
250 package.version = value
251 value = headers[4]
252 package.arch = value.capitalize()
253 value = headers[5]
254 if value == 'amd64':
255 value = 'x86_64'
256 if value == 'noarch':
257 value = '*'
258 package.arch += '-' + value
259 value = headers[6].strip()
260 package.page = value
261 category = None
262 value = headers[7].strip()
263 package.category = rpm_group_to_freedesktop.get(value)
264 if not category:
265 print >>sys.stderr, "Warning: no mapping for RPM group '%s'" % value
267 value = headers[8].strip()
268 package.license = value
269 value = headers[9].strip()
270 package.buildtime = long(value)
271 value = headers[10].strip()
272 for x in value.split('\n'):
273 if x.startswith('rpmlib'):
274 continue
275 req = mappings.process(x)
276 if req:
277 package.requires.append(req)
278 return package
280 def unpack(self, pkg_file, tmp):
281 files = read_child(['rpm', '-qlp', pkg_file])
282 p1 = subprocess.Popen(['rpm2cpio', pkg_file], stdout = subprocess.PIPE)
283 IGNORE = open('/dev/null', 'w')
284 p2 = subprocess.Popen(['cpio', '-dim'], stdin = p1.stdout, stderr = IGNORE, cwd = tmp)
285 p2.communicate()
286 return files
289 if args[0].endswith('.deb') or options.packages_file:
290 repo = DebRepo(options)
291 elif args[0].endswith('.rpm') or options.repomd_file:
292 repo = RPMRepo(options)
293 else:
294 print >>sys.stderr, "Use --packages-file for Debian, or --repomd-file for RPM"
295 sys.exit(1)
297 pkg_data = None
299 scheme = args[0].split(':', 1)[0]
300 if scheme in ('http', 'https', 'ftp'):
301 pkg_url = args[0]
302 digest = {}
303 else:
304 pkg_url, digest = repo.get_repo_metadata(args[0])
306 # Download package, if required
308 pkg_file = os.path.abspath(pkg_url.rsplit('/', 1)[1])
309 if not os.path.exists(pkg_file):
310 print >>sys.stderr, "File '%s' not found, so downloading from %s..." % (pkg_file, pkg_url)
311 check_call(['wget', pkg_url])
313 # Check digest, if known
315 if "sha256" in digest:
316 import hashlib
317 m = hashlib.new('sha256')
318 expected_digest = digest["sha256"]
319 elif "sha1" in digest:
320 try:
321 import hashlib
322 m = hashlib.new('sha1')
323 except ImportError:
324 import sha
325 m = sha.new()
326 expected_digest = digest["sha1"]
327 else:
328 m = None
330 if m:
331 m.update(file(pkg_file).read())
332 actual = m.hexdigest()
333 if actual != expected_digest:
334 raise Exception("Incorrect digest on package file! Was " + actual + ", but expected " + expected_digest)
335 else:
336 print "Package's digest matches value in reposistory metadata (" + actual + "). Good."
337 else:
338 print >>sys.stderr, "Note: no SHA-1 or SHA-256 digest known for this package, so not checking..."
340 # Extract meta-data from package
342 pkg_metadata = repo.get_package_metadata(pkg_file)
344 # Unpack package, find binaries and .desktop files, and add to cache
346 possible_mains = []
347 icondata = None
348 tmp = tempfile.mkdtemp(prefix = 'pkg2zero-')
349 try:
350 files = repo.unpack(pkg_file, tmp)
352 icon = None
353 images = {}
354 for f in files.split('\n'):
355 if f.startswith('./'):
356 f = f[2:]
357 elif f.startswith('/'):
358 f = f[1:]
359 assert not f.startswith('/')
360 full = os.path.join(tmp, f)
361 if f.endswith('.desktop'):
362 for line in file(full):
363 if line.startswith('Categories'):
364 for cat in line.split('=', 1)[1].split(';'):
365 cat = cat.strip()
366 if cat in valid_categories:
367 category = cat
368 break
369 elif line.startswith('Icon'):
370 icon = line.split('=', 1)[1].strip()
371 elif f.startswith('bin/') or f.startswith('usr/bin/') or f.startswith('usr/games/'):
372 if os.path.isfile(full):
373 possible_mains.append(f)
374 elif f.endswith('.png'):
375 images[f] = full
376 images[os.path.basename(f)] = full
377 # make sure to also map basename without the extension
378 images[os.path.splitext(os.path.basename(f))[0]] = full
380 icondata = None
381 if icon in images:
382 print "Using %s for icon" % os.path.basename(images[icon])
383 icondata = file(images[icon]).read()
385 manifest = read_child(['0store', 'manifest', tmp, manifest_algorithm])
386 digest = manifest.rsplit('\n', 2)[1]
387 check_call(['0store', 'add', digest, tmp])
388 finally:
389 shutil.rmtree(tmp)
391 if possible_mains:
392 possible_mains = sorted(possible_mains, key = len)
393 pkg_main = possible_mains[0]
394 if len(possible_mains) > 1:
395 print "Warning: several possible main binaries found:"
396 print "- " + pkg_main + " (I chose this one)"
397 for x in possible_mains[1:]:
398 print "- " + x
399 else:
400 pkg_main = None
402 # Make sure we haven't added this version already...
404 if len(args) > 1:
405 target_feed_file = args[1]
406 target_icon_file = args[1].replace('.xml', '.png')
407 else:
408 target_feed_file = pkg_metadata.name + '.xml'
409 target_icon_file = pkg_metadata.name + '.png'
411 feed_uri = None
412 icon_uri = None
413 if os.path.isfile(target_feed_file):
414 dummy_dist = distro.Distribution()
415 dom = qdom.parse(file(target_feed_file))
416 old_target_feed = model.ZeroInstallFeed(dom, local_path = target_feed_file, distro = dummy_dist)
417 existing_impl = old_target_feed.implementations.get(digest)
418 if existing_impl:
419 print >>sys.stderr, ("Feed '%s' already contains an implementation with this digest!\n%s" % (target_feed_file, existing_impl))
420 sys.exit(1)
421 else:
422 # No target, so need to pick a URI
423 feed_uri = mappings.lookup(pkg_metadata.name)
424 if feed_uri is None:
425 suggestion = mappings.get_suggestion(pkg_metadata.name)
426 uri = raw_input('Enter the URI for this feed [%s]: ' % suggestion).strip()
427 if not uri:
428 uri = suggestion
429 assert uri.startswith('http://') or uri.startswith('https://') or uri.startswith('ftp://'), uri
430 feed_uri = uri
431 mappings.add_mapping(pkg_metadata.name, uri)
433 if icondata and not os.path.isfile(target_icon_file):
434 file = open(target_icon_file, 'wb')
435 file.write(icondata)
436 file.close()
437 if icon_uri is None:
438 suggestion = 'http://0install.net/feed_icons/' + target_icon_file
439 uri = raw_input('Enter the URI for this icon [%s]: ' % suggestion).strip()
440 if not uri:
441 uri = suggestion
442 assert uri.startswith('http://') or uri.startswith('https://') or uri.startswith('ftp://'), uri
443 icon_uri = uri
445 # Create a local feed with just the new version...
447 template = '''<interface xmlns="http://zero-install.sourceforge.net/2004/injector/interface">
448 </interface>'''
449 doc = minidom.parseString(template)
450 root = doc.documentElement
452 add_node(root, 'name', pkg_metadata.name)
453 add_node(root, 'summary', pkg_metadata.summary)
454 add_node(root, 'description', pkg_metadata.description)
455 feed_for = add_node(root, 'feed-for', '')
456 if feed_uri:
457 feed_for.setAttribute('interface', feed_uri)
458 if icon_uri:
459 icon = add_node(root, 'icon')
460 icon.setAttribute('href', icon_uri)
461 icon.setAttribute('type', 'image/png')
462 if pkg_metadata.homepage:
463 add_node(root, 'homepage', pkg_metadata.homepage)
464 if pkg_metadata.category:
465 add_node(root, 'category', pkg_metadata.category)
467 package = add_node(root, 'package-implementation', '')
468 package.setAttribute('package', pkg_metadata.name)
470 group = add_node(root, 'group', '')
471 if pkg_metadata.arch:
472 group.setAttribute('arch', pkg_metadata.arch)
473 else:
474 print >>sys.stderr, "No Architecture: field in package"
475 if pkg_metadata.license:
476 group.setAttribute('license', pkg_metadata.license)
478 for req in pkg_metadata.requires:
479 req_element = add_node(group, 'requires', before = '\n ', after = '')
480 req_element.setAttribute('interface', req.interface)
481 binding = add_node(req_element, 'environment', before = '\n ', after = '\n ')
482 binding.setAttribute('name', 'LD_LIBRARY_PATH')
483 binding.setAttribute('insert', 'usr/lib')
485 if pkg_main:
486 group.setAttribute('main', pkg_main)
487 package.setAttribute('main', '/' + pkg_main)
489 impl = add_node(group, 'implementation', before = '\n ', after = '\n ')
490 impl.setAttribute('id', digest)
491 assert pkg_metadata.version
492 impl.setAttribute('version', pkg_metadata.version)
494 if pkg_metadata.buildtime:
495 impl.setAttribute('released', time.strftime('%Y-%m-%d', time.localtime(pkg_metadata.buildtime)))
496 else:
497 impl.setAttribute('released', time.strftime('%Y-%m-%d'))
499 archive = add_node(impl, 'archive', before = '\n ', after = '\n ')
500 archive.setAttribute('href', pkg_url)
501 archive.setAttribute('size', str(os.path.getsize(pkg_file)))
503 # Add our new version to the main feed...
505 output_stream = tempfile.NamedTemporaryFile(prefix = 'pkg2zero-')
506 try:
507 output_stream.write("<?xml version='1.0'?>\n")
508 root.writexml(output_stream)
509 output_stream.write('\n')
510 output_stream.flush()
512 publishing_options = []
513 if options.key:
514 # Note: 0publish < 0.16 requires the --xmlsign option too
515 publishing_options += ['--xmlsign', '--key', options.key]
516 check_call([os.environ['PUBLISH_COMMAND']] + publishing_options + ['--local', output_stream.name, target_feed_file])
517 print "Added version %s to %s" % (pkg_metadata.version, target_feed_file)
518 finally:
519 output_stream.close()