2 Integration with native distribution package managers.
6 # Copyright (C) 2009, Thomas Leonard
7 # See the README file for details, or visit http://0install.net.
9 from zeroinstall
import _
10 import os
, re
, glob
, subprocess
, sys
11 from logging
import warn
, info
12 from zeroinstall
.injector
import namespaces
, model
, arch
13 from zeroinstall
.support
import basedir
15 _dotted_ints
= '[0-9]+(?:\.[0-9]+)*'
17 # This matches a version number that would be a valid Zero Install version without modification
18 _zeroinstall_regexp
= '(?:%s)(?:-(?:pre|rc|post|)(?:%s))*' % (_dotted_ints
, _dotted_ints
)
20 # This matches the interesting bits of distribution version numbers
21 _version_regexp
= '(%s)(-r%s)?' % (_zeroinstall_regexp
, _dotted_ints
)
23 # We try to do updates atomically without locking, but we don't worry too much about
24 # duplicate entries or being a little out of sync with the on-disk copy.
26 def __init__(self
, cache_leaf
, source
, format
):
27 """Maintain a cache file (e.g. ~/.cache/0install.net/injector/$name).
28 If the size or mtime of $source has changed, or the cache
29 format version if different, reset the cache first."""
30 self
.cache_leaf
= cache_leaf
33 self
.cache_dir
= basedir
.save_cache_path(namespaces
.config_site
,
34 namespaces
.config_prog
)
35 self
.cached_for
= {} # Attributes of source when cache was created
39 info(_("Failed to load cache (%s). Flushing..."), ex
)
45 info
= os
.stat(self
.source
)
46 mtime
= int(info
.st_mtime
)
49 warn("Failed to stat %s: %s", self
.source
, ex
)
53 tmp
, tmp_name
= tempfile
.mkstemp(dir = self
.cache_dir
)
54 data
= "mtime=%d\nsize=%d\nformat=%d\n\n" % (mtime
, size
, self
.format
)
56 wrote
= os
.write(tmp
, data
)
58 os
.rename(tmp_name
, os
.path
.join(self
.cache_dir
, self
.cache_leaf
))
62 # Populate self.cache from our saved cache file.
63 # Throws an exception if the cache doesn't exist or has the wrong format.
64 def _load_cache(self
):
65 self
.cache
= cache
= {}
66 stream
= file(os
.path
.join(self
.cache_dir
, self
.cache_leaf
))
74 key
, value
= line
.split('=', 1)
75 if key
in ('mtime', 'size', 'format'):
76 self
.cached_for
[key
] = int(value
)
81 key
, value
= line
.split('=', 1)
82 cache
[key
] = value
[:-1]
86 # Check the source file hasn't changed since we created the cache
87 def _check_valid(self
):
88 info
= os
.stat(self
.source
)
89 if self
.cached_for
['mtime'] != int(info
.st_mtime
):
90 raise Exception("Modification time of %s has changed" % self
.source
)
91 if self
.cached_for
['size'] != info
.st_size
:
92 raise Exception("Size of %s has changed" % self
.source
)
93 if self
.cached_for
.get('format', None) != self
.format
:
94 raise Exception("Format of cache has changed")
100 info(_("Cache needs to be refreshed: %s"), ex
)
104 return self
.cache
.get(key
, None)
106 def put(self
, key
, value
):
107 cache_path
= os
.path
.join(self
.cache_dir
, self
.cache_leaf
)
108 self
.cache
[key
] = value
110 stream
= file(cache_path
, 'a')
112 stream
.write('%s=%s\n' % (key
, value
))
115 except Exception, ex
:
116 warn("Failed to write to cache %s: %s=%s: %s", cache_path
, key
, value
, ex
)
118 def try_cleanup_distro_version(version
):
119 """Try to turn a distribution version string into one readable by Zero Install.
120 We do this by stripping off anything we can't parse.
121 @return: the part we understood, or None if we couldn't parse anything
123 match
= re
.match(_version_regexp
, version
)
125 version
, revision
= match
.groups()
129 return '%s-%s' % (version
, revision
[2:])
132 class Distribution(object):
133 """Represents a distribution with which we can integrate.
134 Sub-classes should specialise this to integrate with the package managers of
135 particular distributions. This base class ignores the native package manager.
139 def get_package_info(self
, package
, factory
):
140 """Get information about the given package.
141 Add zero or more implementations using the factory (typically at most two
142 will be added; the currently installed version and the latest available).
143 @param package: package name (e.g. "gimp")
145 @param factory: function for creating new DistributionImplementation objects from IDs
146 @type factory: str -> L{model.DistributionImplementation}
150 def get_score(self
, distribution
):
151 """Indicate how closely the host distribution matches this one.
152 The <package-implementation> with the highest score is passed
153 to L{Distribution.get_package_info}. If several elements get
154 the same score, get_package_info is called for all of them.
155 @param distribution: a distribution name
156 @type distribution: str
157 @return: an integer, or None if there is no match at all
162 def get_installed(self
, package_id
):
163 """Check whether 'package' is currently installed.
164 @param package_id: the Implementation ID used by get_package_info
165 @type package_id: str
166 @return: True iff the package is currently installed"""
169 class CachedDistribution(Distribution
):
170 """For distributions where querying the package database is slow (e.g. requires running
171 an external command), we cache the results.
173 @deprecated: use Cache instead
176 def __init__(self
, db_status_file
):
177 """@param db_status_file: update the cache when the timestamp of this file changes"""
178 self
._status
_details
= os
.stat(db_status_file
)
181 self
.cache_dir
= basedir
.save_cache_path(namespaces
.config_site
,
182 namespaces
.config_prog
)
186 except Exception, ex
:
187 info(_("Failed to load distribution database cache (%s). Regenerating..."), ex
)
189 self
.generate_cache()
191 except Exception, ex
:
192 warn(_("Failed to regenerate distribution database cache: %s"), ex
)
194 def _load_cache(self
):
195 """Load {cache_leaf} cache file into self.versions if it is available and up-to-date.
196 Throws an exception if the cache should be (re)created."""
197 stream
= file(os
.path
.join(self
.cache_dir
, self
.cache_leaf
))
203 name
, value
= line
.split(': ')
204 if name
== 'mtime' and int(value
) != int(self
._status
_details
.st_mtime
):
205 raise Exception(_("Modification time of package database file has changed"))
206 if name
== 'size' and int(value
) != self
._status
_details
.st_size
:
207 raise Exception(_("Size of package database file has changed"))
208 if name
== 'version':
209 cache_version
= int(value
)
211 raise Exception(_('Invalid cache format (bad header)'))
213 if cache_version
is None:
214 raise Exception(_('Old cache format'))
216 versions
= self
.versions
218 package
, version
, zi_arch
= line
[:-1].split('\t')
219 versionarch
= (version
, intern(zi_arch
))
220 if package
not in versions
:
221 versions
[package
] = [versionarch
]
223 versions
[package
].append(versionarch
)
225 def _write_cache(self
, cache
):
226 #cache.sort() # Might be useful later; currently we don't care
228 fd
, tmpname
= tempfile
.mkstemp(prefix
= 'zeroinstall-cache-tmp',
229 dir = self
.cache_dir
)
231 stream
= os
.fdopen(fd
, 'wb')
232 stream
.write('version: 2\n')
233 stream
.write('mtime: %d\n' % int(self
._status
_details
.st_mtime
))
234 stream
.write('size: %d\n' % self
._status
_details
.st_size
)
237 stream
.write(line
+ '\n')
241 os
.path
.join(self
.cache_dir
,
247 # Maps machine type names used in packages to their Zero Install versions
248 _canonical_machine
= {
262 host_machine
= arch
.canonicalize_machine(os
.uname()[-1])
263 def canonical_machine(package_machine
):
264 machine
= _canonical_machine
.get(package_machine
, None)
266 # Safe default if we can't understand the arch
270 class DebianDistribution(Distribution
):
271 """A dpkg-based distribution."""
273 cache_leaf
= 'dpkg-status.cache'
275 def __init__(self
, dpkg_status
, pkgcache
):
276 self
.dpkg_cache
= Cache('dpkg-status.cache', dpkg_status
, 2)
277 self
.apt_cache
= Cache('apt-cache-cache', pkgcache
, 3)
279 def _query_installed_package(self
, package
):
280 null
= os
.open('/dev/null', os
.O_WRONLY
)
281 child
= subprocess
.Popen(["dpkg-query", "-W", "--showformat=${Version}\t${Architecture}\t${Status}\n", "--", package
],
282 stdout
= subprocess
.PIPE
, stderr
= null
)
284 stdout
, stderr
= child
.communicate()
286 for line
in stdout
.split('\n'):
287 if not line
: continue
288 version
, debarch
, status
= line
.split('\t', 2)
289 if not status
.endswith(' installed'): continue
291 # Debian's 'epoch' system
292 version
= version
.split(':', 1)[1]
293 clean_version
= try_cleanup_distro_version(version
)
295 return '%s\t%s' % (clean_version
, canonical_machine(debarch
.strip()))
297 warn(_("Can't parse distribution version '%(version)s' for package '%(package)s'"), {'version': version
, 'package': package
})
301 def get_package_info(self
, package
, factory
):
302 installed_cached_info
= self
._get
_dpkg
_info
(package
)
304 if installed_cached_info
!= '-':
305 installed_version
, machine
= installed_cached_info
.split('\t')
306 impl
= factory('package:deb:%s:%s' % (package
, installed_version
))
307 impl
.version
= model
.parse_version(installed_version
)
309 impl
.machine
= machine
311 installed_version
= None
313 # Check to see whether we could get a newer version using apt-get
315 cached
= self
.apt_cache
.get(package
)
318 null
= os
.open('/dev/null', os
.O_WRONLY
)
319 child
= subprocess
.Popen(['apt-cache', 'show', '--no-all-versions', '--', package
], stdout
= subprocess
.PIPE
, stderr
= null
)
322 arch
= version
= size
= None
323 for line
in child
.stdout
:
325 if line
.startswith('Version: '):
328 # Debian's 'epoch' system
329 version
= version
.split(':', 1)[1]
330 version
= try_cleanup_distro_version(version
)
331 elif line
.startswith('Architecture: '):
332 arch
= canonical_machine(line
[14:].strip())
333 elif line
.startswith('Size: '):
334 size
= int(line
[6:].strip())
336 cached
= '%s\t%s\t%d' % (version
, arch
, size
)
340 except Exception, ex
:
341 warn("'apt-cache show %s' failed: %s", package
, ex
)
343 # (multi-arch support? can there be multiple candidates?)
344 self
.apt_cache
.put(package
, cached
)
347 candidate_version
, candidate_arch
, candidate_size
= cached
.split('\t')
348 if candidate_version
and candidate_version
!= installed_version
:
349 impl
= factory('package:deb:%s:%s' % (package
, candidate_version
))
350 impl
.version
= model
.parse_version(candidate_version
)
351 if candidate_arch
!= '*':
352 impl
.machine
= candidate_arch
353 impl
.download_sources
.append(model
.DistributionSource(package
, candidate_size
))
355 def get_score(self
, disto_name
):
356 return int(disto_name
== 'Debian')
358 def _get_dpkg_info(self
, package
):
359 installed_cached_info
= self
.dpkg_cache
.get(package
)
360 if installed_cached_info
== None:
361 installed_cached_info
= self
._query
_installed
_package
(package
)
362 self
.dpkg_cache
.put(package
, installed_cached_info
)
364 return installed_cached_info
366 def get_installed(self
, package_id
):
367 details
= package_id
.split(':', 3)
368 assert details
[0] == 'package'
370 info
= self
._get
_dpkg
_info
(package
)
371 if info
is '-': return False
372 installed_version
, machine
= info
.split('\t')
373 installed_id
= 'package:deb:%s:%s' % (package
, installed_version
)
374 return package_id
== installed_id
376 class RPMDistribution(CachedDistribution
):
377 """An RPM-based distribution."""
379 cache_leaf
= 'rpm-status.cache'
381 def generate_cache(self
):
384 for line
in os
.popen("rpm -qa --qf='%{NAME}\t%{VERSION}-%{RELEASE}\t%{ARCH}\n'"):
385 package
, version
, rpmarch
= line
.split('\t', 2)
386 if package
== 'gpg-pubkey':
388 zi_arch
= canonical_machine(rpmarch
.strip())
389 clean_version
= try_cleanup_distro_version(version
)
391 cache
.append('%s\t%s\t%s' % (package
, clean_version
, zi_arch
))
393 warn(_("Can't parse distribution version '%(version)s' for package '%(package)s'"), {'version': version
, 'package': package
})
395 self
._write
_cache
(cache
)
397 def get_package_info(self
, package
, factory
):
399 versions
= self
.versions
[package
]
403 for version
, machine
in versions
:
404 impl
= factory('package:rpm:%s:%s:%s' % (package
, version
, machine
))
405 impl
.version
= model
.parse_version(version
)
407 impl
.machine
= machine
409 def get_score(self
, disto_name
):
410 return int(disto_name
== 'RPM')
412 class SlackDistribution(Distribution
):
413 """A Slack-based distribution."""
415 def __init__(self
, packages_dir
):
416 self
._packages
_dir
= packages_dir
418 def get_package_info(self
, package
, factory
):
419 for entry
in os
.listdir(self
._packages
_dir
):
420 name
, version
, arch
, build
= entry
.rsplit('-', 3)
422 zi_arch
= canonical_machine(arch
)
423 clean_version
= try_cleanup_distro_version("%s-%s" % (version
, build
))
424 if not clean_version
:
425 warn(_("Can't parse distribution version '%(version)s' for package '%(package)s'"), {'version': version
, 'package': name
})
428 impl
= factory('package:slack:%s:%s:%s' % \
429 (package
, clean_version
, zi_arch
))
430 impl
.version
= model
.parse_version(clean_version
)
432 impl
.machine
= zi_arch
434 def get_score(self
, disto_name
):
435 return int(disto_name
== 'Slack')
437 class GentooDistribution(Distribution
):
439 def __init__(self
, pkgdir
):
440 self
._pkgdir
= pkgdir
442 def get_package_info(self
, package
, factory
):
443 _version_start_reqexp
= '-[0-9]'
445 if package
.count('/') != 1: return
447 category
, leafname
= package
.split('/')
448 category_dir
= os
.path
.join(self
._pkgdir
, category
)
449 match_prefix
= leafname
+ '-'
451 if not os
.path
.isdir(category_dir
): return
453 for filename
in os
.listdir(category_dir
):
454 if filename
.startswith(match_prefix
) and filename
[len(match_prefix
)].isdigit():
455 name
= file(os
.path
.join(category_dir
, filename
, 'PF')).readline().strip()
457 match
= re
.search(_version_start_reqexp
, name
)
459 warn(_('Cannot parse version from Gentoo package named "%(name)s"'), {'name': name
})
462 version
= try_cleanup_distro_version(name
[match
.start() + 1:])
464 if category
== 'app-emulation' and name
.startswith('emul-'):
465 __
, __
, machine
, __
= name
.split('-', 3)
467 machine
, __
= file(os
.path
.join(category_dir
, filename
, 'CHOST')).readline().split('-', 1)
468 machine
= arch
.canonicalize_machine(machine
)
470 impl
= factory('package:gentoo:%s:%s:%s' % \
471 (package
, version
, machine
))
472 impl
.version
= model
.parse_version(version
)
473 impl
.machine
= machine
475 def get_score(self
, disto_name
):
476 return int(disto_name
== 'Gentoo')
478 class PortsDistribution(Distribution
):
480 def __init__(self
, pkgdir
):
481 self
._pkgdir
= pkgdir
483 def get_package_info(self
, package
, factory
):
484 _version_start_reqexp
= '-[0-9]'
486 for pkgname
in os
.listdir(self
._pkgdir
):
487 pkgdir
= os
.path
.join(self
._pkgdir
, pkgname
)
488 if not os
.path
.isdir(pkgdir
): continue
490 #contents = file(os.path.join(pkgdir, '+CONTENTS')).readline().strip()
492 match
= re
.search(_version_start_reqexp
, pkgname
)
494 warn(_('Cannot parse version from Ports package named "%(pkgname)s"'), {'name': pkgname
})
497 name
= pkgname
[0:match
.start()]
498 version
= try_cleanup_distro_version(pkgname
[match
.start() + 1:])
500 machine
= host_machine
502 impl
= factory('package:ports:%s:%s:%s' % \
503 (package
, version
, machine
))
504 impl
.version
= model
.parse_version(version
)
505 impl
.machine
= machine
507 def get_score(self
, disto_name
):
508 return int(disto_name
== 'Ports')
510 _host_distribution
= None
511 def get_host_distribution():
512 """Get a Distribution suitable for the host operating system.
513 Calling this twice will return the same object.
514 @rtype: L{Distribution}"""
515 global _host_distribution
516 if not _host_distribution
:
517 dpkg_db_status
= '/var/lib/dpkg/status'
518 pkgcache
= '/var/cache/apt/pkgcache.bin'
519 _rpm_db
= '/var/lib/rpm/Packages'
520 _slack_db
= '/var/log/packages'
521 _pkg_db
= '/var/db/pkg'
523 if os
.path
.isdir(_pkg_db
):
524 if sys
.platform
.startswith("linux"):
525 _host_distribution
= GentooDistribution(_pkg_db
)
526 elif sys
.platform
.startswith("freebsd"):
527 _host_distribution
= PortsDistribution(_pkg_db
)
528 elif os
.access(dpkg_db_status
, os
.R_OK
):
529 _host_distribution
= DebianDistribution(dpkg_db_status
, pkgcache
)
530 elif os
.path
.isfile(_rpm_db
):
531 _host_distribution
= RPMDistribution(_rpm_db
)
532 elif os
.path
.isdir(_slack_db
):
533 _host_distribution
= SlackDistribution(_slack_db
)
535 _host_distribution
= Distribution()
537 return _host_distribution