pimp.py 42.3 KB
Newer Older
Jack Jansen's avatar
Jack Jansen committed
1 2
"""Package Install Manager for Python.

3
This is currently a MacOSX-only strawman implementation.
4
Despite other rumours the name stands for "Packman IMPlementation".
Jack Jansen's avatar
Jack Jansen committed
5 6 7 8 9 10 11 12 13 14

Tools to allow easy installation of packages. The idea is that there is
an online XML database per (platform, python-version) containing packages
known to work with that combination. This module contains tools for getting
and parsing the database, testing whether packages are installed, computing
dependencies and installing packages.

There is a minimal main program that works as a command line tool, but the
intention is that the end user will use this through a GUI.
"""
15 16
import sys
import os
17
import subprocess
18
import urllib
19
import urllib2
20 21 22
import urlparse
import plistlib
import distutils.util
23
import distutils.sysconfig
24
import hashlib
25 26 27
import tarfile
import tempfile
import shutil
28
import time
29

30
__all__ = ["PimpPreferences", "PimpDatabase", "PimpPackage", "main",
31
    "getDefaultDatabase", "PIMP_VERSION", "main"]
Jack Jansen's avatar
Jack Jansen committed
32

33 34 35 36 37 38
_scriptExc_NotInstalled = "pimp._scriptExc_NotInstalled"
_scriptExc_OldInstalled = "pimp._scriptExc_OldInstalled"
_scriptExc_BadInstalled = "pimp._scriptExc_BadInstalled"

NO_EXECUTE=0

39
PIMP_VERSION="0.5"
40

41 42 43
# Flavors:
# source: setup-based package
# binary: tar (or other) archive created with setup.py bdist.
44 45
# installer: something that can be opened
DEFAULT_FLAVORORDER=['source', 'binary', 'installer']
46 47
DEFAULT_DOWNLOADDIR='/tmp'
DEFAULT_BUILDDIR='/tmp'
48
DEFAULT_INSTALLDIR=distutils.sysconfig.get_python_lib()
49
DEFAULT_PIMPDATABASE_FMT="http://www.python.org/packman/version-%s/%s-%s-%s-%s-%s.plist"
50

51
def getDefaultDatabase(experimental=False):
52 53 54 55
    if experimental:
        status = "exp"
    else:
        status = "prod"
56

57 58
    major, minor, micro, state, extra = sys.version_info
    pyvers = '%d.%d' % (major, minor)
59
    if micro == 0 and state != 'final':
60
        pyvers = pyvers + '%s%d' % (state, extra)
61

62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94
    longplatform = distutils.util.get_platform()
    osname, release, machine = longplatform.split('-')
    # For some platforms we may want to differentiate between
    # installation types
    if osname == 'darwin':
        if sys.prefix.startswith('/System/Library/Frameworks/Python.framework'):
            osname = 'darwin_apple'
        elif sys.prefix.startswith('/Library/Frameworks/Python.framework'):
            osname = 'darwin_macpython'
        # Otherwise we don't know...
    # Now we try various URLs by playing with the release string.
    # We remove numbers off the end until we find a match.
    rel = release
    while True:
        url = DEFAULT_PIMPDATABASE_FMT % (PIMP_VERSION, status, pyvers, osname, rel, machine)
        try:
            urllib2.urlopen(url)
        except urllib2.HTTPError, arg:
            pass
        else:
            break
        if not rel:
            # We're out of version numbers to try. Use the
            # full release number, this will give a reasonable
            # error message later
            url = DEFAULT_PIMPDATABASE_FMT % (PIMP_VERSION, status, pyvers, osname, release, machine)
            break
        idx = rel.rfind('.')
        if idx < 0:
            rel = ''
        else:
            rel = rel[:idx]
    return url
95

96 97
def _cmd(output, dir, *cmditems):
    """Internal routine to run a shell command in a given directory."""
98

99 100 101 102 103
    cmd = ("cd \"%s\"; " % dir) + " ".join(cmditems)
    if output:
        output.write("+ %s\n" % cmd)
    if NO_EXECUTE:
        return 0
104 105 106
    child = subprocess.Popen(cmd, shell=True, stdin=subprocess.PIPE,
                             stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
    child.stdin.close()
107
    while 1:
108
        line = child.stdout.readline()
109 110 111 112 113 114
        if not line:
            break
        if output:
            output.write(line)
    return child.wait()

115 116
class PimpDownloader:
    """Abstract base class - Downloader for archives"""
117

118 119 120 121 122 123
    def __init__(self, argument,
            dir="",
            watcher=None):
        self.argument = argument
        self._dir = dir
        self._watcher = watcher
124

125 126
    def download(self, url, filename, output=None):
        return None
127

128 129 130 131
    def update(self, str):
        if self._watcher:
            return self._watcher.update(str)
        return True
132

133 134 135 136 137 138 139 140 141 142
class PimpCurlDownloader(PimpDownloader):

    def download(self, url, filename, output=None):
        self.update("Downloading %s..." % url)
        exitstatus = _cmd(output, self._dir,
                    "curl",
                    "--output", filename,
                    url)
        self.update("Downloading %s: finished" % url)
        return (not exitstatus)
143

144 145 146 147 148 149 150 151 152 153 154
class PimpUrllibDownloader(PimpDownloader):

    def download(self, url, filename, output=None):
        output = open(filename, 'wb')
        self.update("Downloading %s: opening connection" % url)
        keepgoing = True
        download = urllib2.urlopen(url)
        if download.headers.has_key("content-length"):
            length = long(download.headers['content-length'])
        else:
            length = -1
155

156 157 158 159 160
        data = download.read(4096) #read 4K at a time
        dlsize = 0
        lasttime = 0
        while keepgoing:
            dlsize = dlsize + len(data)
161
            if len(data) == 0:
162 163 164 165 166 167 168 169 170 171 172 173 174 175
                #this is our exit condition
                break
            output.write(data)
            if int(time.time()) != lasttime:
                # Update at most once per second
                lasttime = int(time.time())
                if length == -1:
                    keepgoing = self.update("Downloading %s: %d bytes..." % (url, dlsize))
                else:
                    keepgoing = self.update("Downloading %s: %d%% (%d bytes)..." % (url, int(100.0*dlsize/length), dlsize))
            data = download.read(4096)
        if keepgoing:
            self.update("Downloading %s: finished" % url)
        return keepgoing
176

177 178
class PimpUnpacker:
    """Abstract base class - Unpacker for archives"""
179

180
    _can_rename = False
181

182 183
    def __init__(self, argument,
            dir="",
184 185
            renames=[],
            watcher=None):
186 187 188 189 190
        self.argument = argument
        if renames and not self._can_rename:
            raise RuntimeError, "This unpacker cannot rename files"
        self._dir = dir
        self._renames = renames
191
        self._watcher = watcher
192

193
    def unpack(self, archive, output=None, package=None):
194
        return None
195

196 197 198 199
    def update(self, str):
        if self._watcher:
            return self._watcher.update(str)
        return True
200

201 202
class PimpCommandUnpacker(PimpUnpacker):
    """Unpack archives by calling a Unix utility"""
203

204
    _can_rename = False
205

206
    def unpack(self, archive, output=None, package=None):
207 208 209
        cmd = self.argument % archive
        if _cmd(output, self._dir, cmd):
            return "unpack command failed"
210

211 212
class PimpTarUnpacker(PimpUnpacker):
    """Unpack tarfiles using the builtin tarfile module"""
213

214
    _can_rename = True
215

216
    def unpack(self, archive, output=None, package=None):
217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247
        tf = tarfile.open(archive, "r")
        members = tf.getmembers()
        skip = []
        if self._renames:
            for member in members:
                for oldprefix, newprefix in self._renames:
                    if oldprefix[:len(self._dir)] == self._dir:
                        oldprefix2 = oldprefix[len(self._dir):]
                    else:
                        oldprefix2 = None
                    if member.name[:len(oldprefix)] == oldprefix:
                        if newprefix is None:
                            skip.append(member)
                            #print 'SKIP', member.name
                        else:
                            member.name = newprefix + member.name[len(oldprefix):]
                            print '    ', member.name
                        break
                    elif oldprefix2 and member.name[:len(oldprefix2)] == oldprefix2:
                        if newprefix is None:
                            skip.append(member)
                            #print 'SKIP', member.name
                        else:
                            member.name = newprefix + member.name[len(oldprefix2):]
                            #print '    ', member.name
                        break
                else:
                    skip.append(member)
                    #print '????', member.name
        for member in members:
            if member in skip:
248
                self.update("Skipping %s" % member.name)
249
                continue
250
            self.update("Extracting %s" % member.name)
251 252 253
            tf.extract(member, self._dir)
        if skip:
            names = [member.name for member in skip if member.name[-1] != '/']
254 255
            if package:
                names = package.filterExpectedSkips(names)
256
            if names:
257
                return "Not all files were unpacked: %s" % " ".join(names)
258

259
ARCHIVE_FORMATS = [
260 261 262 263 264 265
    (".tar.Z", PimpTarUnpacker, None),
    (".taz", PimpTarUnpacker, None),
    (".tar.gz", PimpTarUnpacker, None),
    (".tgz", PimpTarUnpacker, None),
    (".tar.bz", PimpTarUnpacker, None),
    (".zip", PimpCommandUnpacker, "unzip \"%s\""),
266 267 268
]

class PimpPreferences:
Jack Jansen's avatar
Jack Jansen committed
269 270
    """Container for per-user preferences, such as the database to use
    and where to install packages."""
271 272

    def __init__(self,
Jack Jansen's avatar
Jack Jansen committed
273 274 275 276 277 278 279 280 281 282 283 284
            flavorOrder=None,
            downloadDir=None,
            buildDir=None,
            installDir=None,
            pimpDatabase=None):
        if not flavorOrder:
            flavorOrder = DEFAULT_FLAVORORDER
        if not downloadDir:
            downloadDir = DEFAULT_DOWNLOADDIR
        if not buildDir:
            buildDir = DEFAULT_BUILDDIR
        if not pimpDatabase:
285
            pimpDatabase = getDefaultDatabase()
286 287 288 289 290
        self.setInstallDir(installDir)
        self.flavorOrder = flavorOrder
        self.downloadDir = downloadDir
        self.buildDir = buildDir
        self.pimpDatabase = pimpDatabase
291
        self.watcher = None
292

293 294
    def setWatcher(self, watcher):
        self.watcher = watcher
295

296
    def setInstallDir(self, installDir=None):
297 298 299 300 301 302 303 304 305 306
        if installDir:
            # Installing to non-standard location.
            self.installLocations = [
                ('--install-lib', installDir),
                ('--install-headers', None),
                ('--install-scripts', None),
                ('--install-data', None)]
        else:
            installDir = DEFAULT_INSTALLDIR
            self.installLocations = []
Jack Jansen's avatar
Jack Jansen committed
307
        self.installDir = installDir
308

309 310
    def isUserInstall(self):
        return self.installDir != DEFAULT_INSTALLDIR
311

Jack Jansen's avatar
Jack Jansen committed
312 313 314
    def check(self):
        """Check that the preferences make sense: directories exist and are
        writable, the install directory is on sys.path, etc."""
315

Jack Jansen's avatar
Jack Jansen committed
316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340
        rv = ""
        RWX_OK = os.R_OK|os.W_OK|os.X_OK
        if not os.path.exists(self.downloadDir):
            rv += "Warning: Download directory \"%s\" does not exist\n" % self.downloadDir
        elif not os.access(self.downloadDir, RWX_OK):
            rv += "Warning: Download directory \"%s\" is not writable or not readable\n" % self.downloadDir
        if not os.path.exists(self.buildDir):
            rv += "Warning: Build directory \"%s\" does not exist\n" % self.buildDir
        elif not os.access(self.buildDir, RWX_OK):
            rv += "Warning: Build directory \"%s\" is not writable or not readable\n" % self.buildDir
        if not os.path.exists(self.installDir):
            rv += "Warning: Install directory \"%s\" does not exist\n" % self.installDir
        elif not os.access(self.installDir, RWX_OK):
            rv += "Warning: Install directory \"%s\" is not writable or not readable\n" % self.installDir
        else:
            installDir = os.path.realpath(self.installDir)
            for p in sys.path:
                try:
                    realpath = os.path.realpath(p)
                except:
                    pass
                if installDir == realpath:
                    break
            else:
                rv += "Warning: Install directory \"%s\" is not on sys.path\n" % self.installDir
341
        return rv
342

Jack Jansen's avatar
Jack Jansen committed
343 344 345 346 347 348 349 350 351 352
    def compareFlavors(self, left, right):
        """Compare two flavor strings. This is part of your preferences
        because whether the user prefers installing from source or binary is."""
        if left in self.flavorOrder:
            if right in self.flavorOrder:
                return cmp(self.flavorOrder.index(left), self.flavorOrder.index(right))
            return -1
        if right in self.flavorOrder:
            return 1
        return cmp(left, right)
353

354
class PimpDatabase:
Jack Jansen's avatar
Jack Jansen committed
355 356 357 358
    """Class representing a pimp database. It can actually contain
    information from multiple databases through inclusion, but the
    toplevel database is considered the master, as its maintainer is
    "responsible" for the contents."""
359

Jack Jansen's avatar
Jack Jansen committed
360 361 362
    def __init__(self, prefs):
        self._packages = []
        self.preferences = prefs
363
        self._url = ""
Jack Jansen's avatar
Jack Jansen committed
364 365 366 367
        self._urllist = []
        self._version = ""
        self._maintainer = ""
        self._description = ""
368

369 370 371 372 373
    # Accessor functions
    def url(self): return self._url
    def version(self): return self._version
    def maintainer(self): return self._maintainer
    def description(self): return self._description
374

Jack Jansen's avatar
Jack Jansen committed
375 376 377 378
    def close(self):
        """Clean up"""
        self._packages = []
        self.preferences = None
379

Jack Jansen's avatar
Jack Jansen committed
380 381 382 383
    def appendURL(self, url, included=0):
        """Append packages from the database with the given URL.
        Only the first database should specify included=0, so the
        global information (maintainer, description) get stored."""
384

Jack Jansen's avatar
Jack Jansen committed
385 386 387 388
        if url in self._urllist:
            return
        self._urllist.append(url)
        fp = urllib2.urlopen(url).fp
389
        plistdata = plistlib.Plist.fromFile(fp)
Jack Jansen's avatar
Jack Jansen committed
390
        # Test here for Pimp version, etc
391
        if included:
392
            version = plistdata.get('Version')
393 394 395 396
            if version and version > self._version:
                sys.stderr.write("Warning: included database %s is for pimp version %s\n" %
                    (url, version))
        else:
397
            self._version = plistdata.get('Version')
398 399 400
            if not self._version:
                sys.stderr.write("Warning: database has no Version information\n")
            elif self._version > PIMP_VERSION:
401
                sys.stderr.write("Warning: database version %s newer than pimp version %s\n"
Jack Jansen's avatar
Jack Jansen committed
402
                    % (self._version, PIMP_VERSION))
403 404
            self._maintainer = plistdata.get('Maintainer', '')
            self._description = plistdata.get('Description', '').strip()
405
            self._url = url
406
        self._appendPackages(plistdata['Packages'], url)
407
        others = plistdata.get('Include', [])
408 409 410
        for o in others:
            o = urllib.basejoin(url, o)
            self.appendURL(o, included=1)
411

412
    def _appendPackages(self, packages, url):
Jack Jansen's avatar
Jack Jansen committed
413 414 415
        """Given a list of dictionaries containing package
        descriptions create the PimpPackage objects and append them
        to our internal storage."""
416

Jack Jansen's avatar
Jack Jansen committed
417 418
        for p in packages:
            p = dict(p)
419 420
            if p.has_key('Download-URL'):
                p['Download-URL'] = urllib.basejoin(url, p['Download-URL'])
Jack Jansen's avatar
Jack Jansen committed
421 422 423 424 425
            flavor = p.get('Flavor')
            if flavor == 'source':
                pkg = PimpPackage_source(self, p)
            elif flavor == 'binary':
                pkg = PimpPackage_binary(self, p)
426 427 428 429
            elif flavor == 'installer':
                pkg = PimpPackage_installer(self, p)
            elif flavor == 'hidden':
                pkg = PimpPackage_installer(self, p)
Jack Jansen's avatar
Jack Jansen committed
430 431 432
            else:
                pkg = PimpPackage(self, dict(p))
            self._packages.append(pkg)
433

Jack Jansen's avatar
Jack Jansen committed
434 435
    def list(self):
        """Return a list of all PimpPackage objects in the database."""
436

Jack Jansen's avatar
Jack Jansen committed
437
        return self._packages
438

Jack Jansen's avatar
Jack Jansen committed
439 440
    def listnames(self):
        """Return a list of names of all packages in the database."""
441

Jack Jansen's avatar
Jack Jansen committed
442 443 444 445 446
        rv = []
        for pkg in self._packages:
            rv.append(pkg.fullname())
        rv.sort()
        return rv
447

Jack Jansen's avatar
Jack Jansen committed
448 449
    def dump(self, pathOrFile):
        """Dump the contents of the database to an XML .plist file.
450

Jack Jansen's avatar
Jack Jansen committed
451 452
        The file can be passed as either a file object or a pathname.
        All data, including included databases, is dumped."""
453

Jack Jansen's avatar
Jack Jansen committed
454 455 456
        packages = []
        for pkg in self._packages:
            packages.append(pkg.dump())
457
        plistdata = {
Jack Jansen's avatar
Jack Jansen committed
458 459 460 461 462
            'Version': self._version,
            'Maintainer': self._maintainer,
            'Description': self._description,
            'Packages': packages
            }
463
        plist = plistlib.Plist(**plistdata)
Jack Jansen's avatar
Jack Jansen committed
464
        plist.write(pathOrFile)
465

Jack Jansen's avatar
Jack Jansen committed
466 467 468
    def find(self, ident):
        """Find a package. The package can be specified by name
        or as a dictionary with name, version and flavor entries.
469

Jack Jansen's avatar
Jack Jansen committed
470 471 472
        Only name is obligatory. If there are multiple matches the
        best one (higher version number, flavors ordered according to
        users' preference) is returned."""
473

Jack Jansen's avatar
Jack Jansen committed
474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502
        if type(ident) == str:
            # Remove ( and ) for pseudo-packages
            if ident[0] == '(' and ident[-1] == ')':
                ident = ident[1:-1]
            # Split into name-version-flavor
            fields = ident.split('-')
            if len(fields) < 1 or len(fields) > 3:
                return None
            name = fields[0]
            if len(fields) > 1:
                version = fields[1]
            else:
                version = None
            if len(fields) > 2:
                flavor = fields[2]
            else:
                flavor = None
        else:
            name = ident['Name']
            version = ident.get('Version')
            flavor = ident.get('Flavor')
        found = None
        for p in self._packages:
            if name == p.name() and \
                    (not version or version == p.version()) and \
                    (not flavor or flavor == p.flavor()):
                if not found or found < p:
                    found = p
        return found
503

504
ALLOWED_KEYS = [
Jack Jansen's avatar
Jack Jansen committed
505 506 507 508 509 510 511 512 513 514 515
    "Name",
    "Version",
    "Flavor",
    "Description",
    "Home-page",
    "Download-URL",
    "Install-test",
    "Install-command",
    "Pre-install-command",
    "Post-install-command",
    "Prerequisites",
516 517 518
    "MD5Sum",
    "User-install-skips",
    "Systemwide-only",
519 520
]

521
class PimpPackage:
Jack Jansen's avatar
Jack Jansen committed
522
    """Class representing a single package."""
523

524
    def __init__(self, db, plistdata):
Jack Jansen's avatar
Jack Jansen committed
525
        self._db = db
526 527
        name = plistdata["Name"]
        for k in plistdata.keys():
Jack Jansen's avatar
Jack Jansen committed
528 529
            if not k in ALLOWED_KEYS:
                sys.stderr.write("Warning: %s: unknown key %s\n" % (name, k))
530
        self._dict = plistdata
531

Jack Jansen's avatar
Jack Jansen committed
532 533
    def __getitem__(self, key):
        return self._dict[key]
534

Jack Jansen's avatar
Jack Jansen committed
535
    def name(self): return self._dict['Name']
536 537
    def version(self): return self._dict.get('Version')
    def flavor(self): return self._dict.get('Flavor')
538
    def description(self): return self._dict['Description'].strip()
539
    def shortdescription(self): return self.description().splitlines()[0]
Jack Jansen's avatar
Jack Jansen committed
540
    def homepage(self): return self._dict.get('Home-page')
541
    def downloadURL(self): return self._dict.get('Download-URL')
542
    def systemwideOnly(self): return self._dict.get('Systemwide-only')
543

Jack Jansen's avatar
Jack Jansen committed
544 545
    def fullname(self):
        """Return the full name "name-version-flavor" of a package.
546

Jack Jansen's avatar
Jack Jansen committed
547 548
        If the package is a pseudo-package, something that cannot be
        installed through pimp, return the name in (parentheses)."""
549

Jack Jansen's avatar
Jack Jansen committed
550 551 552 553 554
        rv = self._dict['Name']
        if self._dict.has_key('Version'):
            rv = rv + '-%s' % self._dict['Version']
        if self._dict.has_key('Flavor'):
            rv = rv + '-%s' % self._dict['Flavor']
555
        if self._dict.get('Flavor') == 'hidden':
Jack Jansen's avatar
Jack Jansen committed
556 557 558
            # Pseudo-package, show in parentheses
            rv = '(%s)' % rv
        return rv
559

Jack Jansen's avatar
Jack Jansen committed
560 561 562
    def dump(self):
        """Return a dict object containing the information on the package."""
        return self._dict
563

Jack Jansen's avatar
Jack Jansen committed
564 565
    def __cmp__(self, other):
        """Compare two packages, where the "better" package sorts lower."""
566

Jack Jansen's avatar
Jack Jansen committed
567 568 569 570 571 572 573
        if not isinstance(other, PimpPackage):
            return cmp(id(self), id(other))
        if self.name() != other.name():
            return cmp(self.name(), other.name())
        if self.version() != other.version():
            return -cmp(self.version(), other.version())
        return self._db.preferences.compareFlavors(self.flavor(), other.flavor())
574

Jack Jansen's avatar
Jack Jansen committed
575 576
    def installed(self):
        """Test wheter the package is installed.
577

Jack Jansen's avatar
Jack Jansen committed
578 579 580 581
        Returns two values: a status indicator which is one of
        "yes", "no", "old" (an older version is installed) or "bad"
        (something went wrong during the install test) and a human
        readable string which may contain more details."""
582

Jack Jansen's avatar
Jack Jansen committed
583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613
        namespace = {
            "NotInstalled": _scriptExc_NotInstalled,
            "OldInstalled": _scriptExc_OldInstalled,
            "BadInstalled": _scriptExc_BadInstalled,
            "os": os,
            "sys": sys,
            }
        installTest = self._dict['Install-test'].strip() + '\n'
        try:
            exec installTest in namespace
        except ImportError, arg:
            return "no", str(arg)
        except _scriptExc_NotInstalled, arg:
            return "no", str(arg)
        except _scriptExc_OldInstalled, arg:
            return "old", str(arg)
        except _scriptExc_BadInstalled, arg:
            return "bad", str(arg)
        except:
            sys.stderr.write("-------------------------------------\n")
            sys.stderr.write("---- %s: install test got exception\n" % self.fullname())
            sys.stderr.write("---- source:\n")
            sys.stderr.write(installTest)
            sys.stderr.write("---- exception:\n")
            import traceback
            traceback.print_exc(file=sys.stderr)
            if self._db._maintainer:
                sys.stderr.write("---- Please copy this and mail to %s\n" % self._db._maintainer)
            sys.stderr.write("-------------------------------------\n")
            return "bad", "Package install test got exception"
        return "yes", ""
614

Jack Jansen's avatar
Jack Jansen committed
615 616
    def prerequisites(self):
        """Return a list of prerequisites for this package.
617

Jack Jansen's avatar
Jack Jansen committed
618 619 620 621 622
        The list contains 2-tuples, of which the first item is either
        a PimpPackage object or None, and the second is a descriptive
        string. The first item can be None if this package depends on
        something that isn't pimp-installable, in which case the descriptive
        string should tell the user what to do."""
623

Jack Jansen's avatar
Jack Jansen committed
624 625
        rv = []
        if not self._dict.get('Download-URL'):
626 627 628 629 630
            # For pseudo-packages that are already installed we don't
            # return an error message
            status, _  = self.installed()
            if status == "yes":
                return []
631
            return [(None,
632
                "Package %s cannot be installed automatically, see the description" %
Jack Jansen's avatar
Jack Jansen committed
633
                    self.fullname())]
634 635
        if self.systemwideOnly() and self._db.preferences.isUserInstall():
            return [(None,
636
                "Package %s can only be installed system-wide" %
637
                    self.fullname())]
Jack Jansen's avatar
Jack Jansen committed
638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653
        if not self._dict.get('Prerequisites'):
            return []
        for item in self._dict['Prerequisites']:
            if type(item) == str:
                pkg = None
                descr = str(item)
            else:
                name = item['Name']
                if item.has_key('Version'):
                    name = name + '-' + item['Version']
                if item.has_key('Flavor'):
                    name = name + '-' + item['Flavor']
                pkg = self._db.find(name)
                if not pkg:
                    descr = "Requires unknown %s"%name
                else:
654
                    descr = pkg.shortdescription()
Jack Jansen's avatar
Jack Jansen committed
655 656
            rv.append((pkg, descr))
        return rv
657 658


Jack Jansen's avatar
Jack Jansen committed
659 660
    def downloadPackageOnly(self, output=None):
        """Download a single package, if needed.
661

Jack Jansen's avatar
Jack Jansen committed
662 663 664 665
        An MD5 signature is used to determine whether download is needed,
        and to test that we actually downloaded what we expected.
        If output is given it is a file-like object that will receive a log
        of what happens.
666

Jack Jansen's avatar
Jack Jansen committed
667 668 669
        If anything unforeseen happened the method returns an error message
        string.
        """
670

Jack Jansen's avatar
Jack Jansen committed
671 672 673
        scheme, loc, path, query, frag = urlparse.urlsplit(self._dict['Download-URL'])
        path = urllib.url2pathname(path)
        filename = os.path.split(path)[1]
674
        self.archiveFilename = os.path.join(self._db.preferences.downloadDir, filename)
Jack Jansen's avatar
Jack Jansen committed
675 676 677
        if not self._archiveOK():
            if scheme == 'manual':
                return "Please download package manually and save as %s" % self.archiveFilename
678
            downloader = PimpUrllibDownloader(None, self._db.preferences.downloadDir,
679 680 681
                watcher=self._db.preferences.watcher)
            if not downloader.download(self._dict['Download-URL'],
                    self.archiveFilename, output):
Jack Jansen's avatar
Jack Jansen committed
682 683 684 685 686
                return "download command failed"
        if not os.path.exists(self.archiveFilename) and not NO_EXECUTE:
            return "archive not found after download"
        if not self._archiveOK():
            return "archive does not have correct MD5 checksum"
687

Jack Jansen's avatar
Jack Jansen committed
688 689
    def _archiveOK(self):
        """Test an archive. It should exist and the MD5 checksum should be correct."""
690

Jack Jansen's avatar
Jack Jansen committed
691 692 693 694 695 696
        if not os.path.exists(self.archiveFilename):
            return 0
        if not self._dict.get('MD5Sum'):
            sys.stderr.write("Warning: no MD5Sum for %s\n" % self.fullname())
            return 1
        data = open(self.archiveFilename, 'rb').read()
697
        checksum = hashlib.md5(data).hexdigest()
Jack Jansen's avatar
Jack Jansen committed
698
        return checksum == self._dict['MD5Sum']
699

Jack Jansen's avatar
Jack Jansen committed
700 701
    def unpackPackageOnly(self, output=None):
        """Unpack a downloaded package archive."""
702

Jack Jansen's avatar
Jack Jansen committed
703
        filename = os.path.split(self.archiveFilename)[1]
704
        for ext, unpackerClass, arg in ARCHIVE_FORMATS:
Jack Jansen's avatar
Jack Jansen committed
705 706 707 708 709
            if filename[-len(ext):] == ext:
                break
        else:
            return "unknown extension for archive file: %s" % filename
        self.basename = filename[:-len(ext)]
710
        unpacker = unpackerClass(arg, dir=self._db.preferences.buildDir,
711
                watcher=self._db.preferences.watcher)
712 713 714
        rv = unpacker.unpack(self.archiveFilename, output=output)
        if rv:
            return rv
715

Jack Jansen's avatar
Jack Jansen committed
716 717 718 719
    def installPackageOnly(self, output=None):
        """Default install method, to be overridden by subclasses"""
        return "%s: This package needs to be installed manually (no support for flavor=\"%s\")" \
            % (self.fullname(), self._dict.get(flavor, ""))
720

Jack Jansen's avatar
Jack Jansen committed
721 722
    def installSinglePackage(self, output=None):
        """Download, unpack and install a single package.
723

Jack Jansen's avatar
Jack Jansen committed
724 725
        If output is given it should be a file-like object and it
        will receive a log of what happened."""
726

727 728
        if not self._dict.get('Download-URL'):
            return "%s: This package needs to be installed manually (no Download-URL field)" % self.fullname()
Jack Jansen's avatar
Jack Jansen committed
729 730 731
        msg = self.downloadPackageOnly(output)
        if msg:
            return "%s: download: %s" % (self.fullname(), msg)
732

Jack Jansen's avatar
Jack Jansen committed
733 734 735
        msg = self.unpackPackageOnly(output)
        if msg:
            return "%s: unpack: %s" % (self.fullname(), msg)
736

Jack Jansen's avatar
Jack Jansen committed
737
        return self.installPackageOnly(output)
738

Jack Jansen's avatar
Jack Jansen committed
739 740 741
    def beforeInstall(self):
        """Bookkeeping before installation: remember what we have in site-packages"""
        self._old_contents = os.listdir(self._db.preferences.installDir)
742

Jack Jansen's avatar
Jack Jansen committed
743 744 745
    def afterInstall(self):
        """Bookkeeping after installation: interpret any new .pth files that have
        appeared"""
746

Jack Jansen's avatar
Jack Jansen committed
747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768
        new_contents = os.listdir(self._db.preferences.installDir)
        for fn in new_contents:
            if fn in self._old_contents:
                continue
            if fn[-4:] != '.pth':
                continue
            fullname = os.path.join(self._db.preferences.installDir, fn)
            f = open(fullname)
            for line in f.readlines():
                if not line:
                    continue
                if line[0] == '#':
                    continue
                if line[:6] == 'import':
                    exec line
                    continue
                if line[-1] == '\n':
                    line = line[:-1]
                if not os.path.isabs(line):
                    line = os.path.join(self._db.preferences.installDir, line)
                line = os.path.realpath(line)
                if not line in sys.path:
769
                    sys.path.append(line)
770

771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786
    def filterExpectedSkips(self, names):
        """Return a list that contains only unpexpected skips"""
        if not self._db.preferences.isUserInstall():
            return names
        expected_skips = self._dict.get('User-install-skips')
        if not expected_skips:
            return names
        newnames = []
        for name in names:
            for skip in expected_skips:
                if name[:len(skip)] == skip:
                    break
            else:
                newnames.append(name)
        return newnames

787 788
class PimpPackage_binary(PimpPackage):

Jack Jansen's avatar
Jack Jansen committed
789 790 791
    def unpackPackageOnly(self, output=None):
        """We don't unpack binary packages until installing"""
        pass
792

Jack Jansen's avatar
Jack Jansen committed
793 794
    def installPackageOnly(self, output=None):
        """Install a single source package.
795

Jack Jansen's avatar
Jack Jansen committed
796 797
        If output is given it should be a file-like object and it
        will receive a log of what happened."""
798

Jack Jansen's avatar
Jack Jansen committed
799
        if self._dict.has_key('Install-command'):
800
            return "%s: Binary package cannot have Install-command" % self.fullname()
801

802
        if self._dict.has_key('Pre-install-command'):
803
            if _cmd(output, '/tmp', self._dict['Pre-install-command']):
804 805
                return "pre-install %s: running \"%s\" failed" % \
                    (self.fullname(), self._dict['Pre-install-command'])
806

Jack Jansen's avatar
Jack Jansen committed
807
        self.beforeInstall()
808

Jack Jansen's avatar
Jack Jansen committed
809 810
        # Install by unpacking
        filename = os.path.split(self.archiveFilename)[1]
811
        for ext, unpackerClass, arg in ARCHIVE_FORMATS:
Jack Jansen's avatar
Jack Jansen committed
812 813 814
            if filename[-len(ext):] == ext:
                break
        else:
815 816
            return "%s: unknown extension for archive file: %s" % (self.fullname(), filename)
        self.basename = filename[:-len(ext)]
817

818 819 820 821 822 823 824 825 826
        install_renames = []
        for k, newloc in self._db.preferences.installLocations:
            if not newloc:
                continue
            if k == "--install-lib":
                oldloc = DEFAULT_INSTALLDIR
            else:
                return "%s: Don't know installLocation %s" % (self.fullname(), k)
            install_renames.append((oldloc, newloc))
827

828
        unpacker = unpackerClass(arg, dir="/", renames=install_renames)
829
        rv = unpacker.unpack(self.archiveFilename, output=output, package=self)
830 831
        if rv:
            return rv
832

Jack Jansen's avatar
Jack Jansen committed
833
        self.afterInstall()
834

Jack Jansen's avatar
Jack Jansen committed
835
        if self._dict.has_key('Post-install-command'):
836
            if _cmd(output, '/tmp', self._dict['Post-install-command']):
837
                return "%s: post-install: running \"%s\" failed" % \
Jack Jansen's avatar
Jack Jansen committed
838
                    (self.fullname(), self._dict['Post-install-command'])
839

Jack Jansen's avatar
Jack Jansen committed
840
        return None
841 842


843 844
class PimpPackage_source(PimpPackage):

Jack Jansen's avatar
Jack Jansen committed
845 846 847 848 849 850 851 852
    def unpackPackageOnly(self, output=None):
        """Unpack a source package and check that setup.py exists"""
        PimpPackage.unpackPackageOnly(self, output)
        # Test that a setup script has been create
        self._buildDirname = os.path.join(self._db.preferences.buildDir, self.basename)
        setupname = os.path.join(self._buildDirname, "setup.py")
        if not os.path.exists(setupname) and not NO_EXECUTE:
            return "no setup.py found after unpack of archive"
853

Jack Jansen's avatar
Jack Jansen committed
854 855
    def installPackageOnly(self, output=None):
        """Install a single source package.
856

Jack Jansen's avatar
Jack Jansen committed
857 858
        If output is given it should be a file-like object and it
        will receive a log of what happened."""
859

Jack Jansen's avatar
Jack Jansen committed
860
        if self._dict.has_key('Pre-install-command'):
861
            if _cmd(output, self._buildDirname, self._dict['Pre-install-command']):
Jack Jansen's avatar
Jack Jansen committed
862 863
                return "pre-install %s: running \"%s\" failed" % \
                    (self.fullname(), self._dict['Pre-install-command'])
864

Jack Jansen's avatar
Jack Jansen committed
865 866
        self.beforeInstall()
        installcmd = self._dict.get('Install-command')
867 868 869 870 871
        if installcmd and self._install_renames:
            return "Package has install-command and can only be installed to standard location"
        # This is the "bit-bucket" for installations: everything we don't
        # want. After installation we check that it is actually empty
        unwanted_install_dir = None
Jack Jansen's avatar
Jack Jansen committed
872
        if not installcmd:
873 874 875 876 877 878 879 880 881 882 883
            extra_args = ""
            for k, v in self._db.preferences.installLocations:
                if not v:
                    # We don't want these files installed. Send them
                    # to the bit-bucket.
                    if not unwanted_install_dir:
                        unwanted_install_dir = tempfile.mkdtemp()
                    v = unwanted_install_dir
                extra_args = extra_args + " %s \"%s\"" % (k, v)
            installcmd = '"%s" setup.py install %s' % (sys.executable, extra_args)
        if _cmd(output, self._buildDirname, installcmd):
Jack Jansen's avatar
Jack Jansen committed
884 885
            return "install %s: running \"%s\" failed" % \
                (self.fullname(), installcmd)
886 887 888 889 890 891 892 893
        if unwanted_install_dir and os.path.exists(unwanted_install_dir):
            unwanted_files = os.listdir(unwanted_install_dir)
            if unwanted_files:
                rv = "Warning: some files were not installed: %s" % " ".join(unwanted_files)
            else:
                rv = None
            shutil.rmtree(unwanted_install_dir)
            return rv
894

Jack Jansen's avatar
Jack Jansen committed
895
        self.afterInstall()
896

Jack Jansen's avatar
Jack Jansen committed
897
        if self._dict.has_key('Post-install-command'):
898
            if _cmd(output, self._buildDirname, self._dict['Post-install-command']):
Jack Jansen's avatar
Jack Jansen committed
899 900 901
                return "post-install %s: running \"%s\" failed" % \
                    (self.fullname(), self._dict['Post-install-command'])
        return None
902

903 904 905 906 907 908 909 910
class PimpPackage_installer(PimpPackage):

    def unpackPackageOnly(self, output=None):
        """We don't unpack dmg packages until installing"""
        pass

    def installPackageOnly(self, output=None):
        """Install a single source package.
Tim Peters's avatar
Tim Peters committed
911

912 913
        If output is given it should be a file-like object and it
        will receive a log of what happened."""
Tim Peters's avatar
Tim Peters committed
914

915 916 917 918 919 920 921
        if self._dict.has_key('Post-install-command'):
            return "%s: Installer package cannot have Post-install-command" % self.fullname()

        if self._dict.has_key('Pre-install-command'):
            if _cmd(output, '/tmp', self._dict['Pre-install-command']):
                return "pre-install %s: running \"%s\" failed" % \
                    (self.fullname(), self._dict['Pre-install-command'])
Tim Peters's avatar
Tim Peters committed
922

923 924 925 926 927 928 929
        self.beforeInstall()

        installcmd = self._dict.get('Install-command')
        if installcmd:
            if '%' in installcmd:
                installcmd = installcmd % self.archiveFilename
        else:
Tim Peters's avatar
Tim Peters committed
930
            installcmd = 'open \"%s\"' % self.archiveFilename
931 932 933
        if _cmd(output, "/tmp", installcmd):
            return '%s: install command failed (use verbose for details)' % self.fullname()
        return '%s: downloaded and opened. Install manually and restart Package Manager' % self.archiveFilename
934

935
class PimpInstaller:
Jack Jansen's avatar
Jack Jansen committed
936 937
    """Installer engine: computes dependencies and installs
    packages in the right order."""
938

Jack Jansen's avatar
Jack Jansen committed
939 940 941 942 943
    def __init__(self, db):
        self._todo = []
        self._db = db
        self._curtodo = []
        self._curmessages = []
944

Jack Jansen's avatar
Jack Jansen committed
945 946
    def __contains__(self, package):
        return package in self._todo
947

Jack Jansen's avatar
Jack Jansen committed
948 949 950
    def _addPackages(self, packages):
        for package in packages:
            if not package in self._todo:
951
                self._todo.append(package)
952

Jack Jansen's avatar
Jack Jansen committed
953 954
    def _prepareInstall(self, package, force=0, recursive=1):
        """Internal routine, recursive engine for prepareInstall.
955

Jack Jansen's avatar
Jack Jansen committed
956 957 958
        Test whether the package is installed and (if not installed
        or if force==1) prepend it to the temporary todo list and
        call ourselves recursively on all prerequisites."""
959

Jack Jansen's avatar
Jack Jansen committed
960 961 962
        if not force:
            status, message = package.installed()
            if status == "yes":
963
                return
Jack Jansen's avatar
Jack Jansen committed
964 965 966 967 968 969 970 971
        if package in self._todo or package in self._curtodo:
            return
        self._curtodo.insert(0, package)
        if not recursive:
            return
        prereqs = package.prerequisites()
        for pkg, descr in prereqs:
            if pkg:
972
                self._prepareInstall(pkg, False, recursive)
Jack Jansen's avatar
Jack Jansen committed
973
            else:
974
                self._curmessages.append("Problem with dependency: %s" % descr)
975

Jack Jansen's avatar
Jack Jansen committed
976 977
    def prepareInstall(self, package, force=0, recursive=1):
        """Prepare installation of a package.
978

Jack Jansen's avatar
Jack Jansen committed
979 980
        If the package is already installed and force is false nothing
        is done. If recursive is true prerequisites are installed first.
981

Jack Jansen's avatar
Jack Jansen committed
982 983 984
        Returns a list of packages (to be passed to install) and a list
        of messages of any problems encountered.
        """
985

Jack Jansen's avatar
Jack Jansen committed
986 987 988 989 990 991 992
        self._curtodo = []
        self._curmessages = []
        self._prepareInstall(package, force, recursive)
        rv = self._curtodo, self._curmessages
        self._curtodo = []
        self._curmessages = []
        return rv
993

Jack Jansen's avatar
Jack Jansen committed
994 995
    def install(self, packages, output):
        """Install a list of packages."""
996

Jack Jansen's avatar
Jack Jansen committed
997 998 999 1000 1001 1002 1003
        self._addPackages(packages)
        status = []
        for pkg in self._todo:
            msg = pkg.installSinglePackage(output)
            if msg:
                status.append(msg)
        return status
1004 1005 1006



1007
def _run(mode, verbose, force, args, prefargs, watcher):
Jack Jansen's avatar
Jack Jansen committed
1008
    """Engine for the main program"""
1009

1010
    prefs = PimpPreferences(**prefargs)
1011 1012
    if watcher:
        prefs.setWatcher(watcher)
1013 1014 1015
    rv = prefs.check()
    if rv:
        sys.stdout.write(rv)
Jack Jansen's avatar
Jack Jansen committed
1016 1017
    db = PimpDatabase(prefs)
    db.appendURL(prefs.pimpDatabase)
1018

Jack Jansen's avatar
Jack Jansen committed
1019 1020 1021 1022 1023 1024 1025 1026 1027 1028
    if mode == 'dump':
        db.dump(sys.stdout)
    elif mode =='list':
        if not args:
            args = db.listnames()
        print "%-20.20s\t%s" % ("Package", "Description")
        print
        for pkgname in args:
            pkg = db.find(pkgname)
            if pkg:
1029
                description = pkg.shortdescription()
Jack Jansen's avatar
Jack Jansen committed
1030 1031 1032 1033 1034 1035
                pkgname = pkg.fullname()
            else:
                description = 'Error: no such package'
            print "%-20.20s\t%s" % (pkgname, description)
            if verbose:
                print "\tHome page:\t", pkg.homepage()
1036 1037 1038 1039
                try:
                    print "\tDownload URL:\t", pkg.downloadURL()
                except KeyError:
                    pass
1040
                description = pkg.description()
1041
                description = '\n\t\t\t\t\t'.join(description.splitlines())
1042
                print "\tDescription:\t%s" % description
Jack Jansen's avatar
Jack Jansen committed
1043 1044 1045 1046 1047 1048 1049 1050 1051 1052 1053 1054 1055 1056 1057 1058 1059 1060 1061 1062 1063 1064 1065 1066 1067 1068 1069 1070 1071 1072 1073 1074 1075 1076 1077 1078 1079 1080 1081 1082 1083 1084 1085 1086 1087 1088 1089
    elif mode =='status':
        if not args:
            args = db.listnames()
            print "%-20.20s\t%s\t%s" % ("Package", "Installed", "Message")
            print
        for pkgname in args:
            pkg = db.find(pkgname)
            if pkg:
                status, msg = pkg.installed()
                pkgname = pkg.fullname()
            else:
                status = 'error'
                msg = 'No such package'
            print "%-20.20s\t%-9.9s\t%s" % (pkgname, status, msg)
            if verbose and status == "no":
                prereq = pkg.prerequisites()
                for pkg, msg in prereq:
                    if not pkg:
                        pkg = ''
                    else:
                        pkg = pkg.fullname()
                    print "%-20.20s\tRequirement: %s %s" % ("", pkg, msg)
    elif mode == 'install':
        if not args:
            print 'Please specify packages to install'
            sys.exit(1)
        inst = PimpInstaller(db)
        for pkgname in args:
            pkg = db.find(pkgname)
            if not pkg:
                print '%s: No such package' % pkgname
                continue
            list, messages = inst.prepareInstall(pkg, force)
            if messages and not force:
                print "%s: Not installed:" % pkgname
                for m in messages:
                    print "\t", m
            else:
                if verbose:
                    output = sys.stdout
                else:
                    output = None
                messages = inst.install(list, output)
                if messages:
                    print "%s: Not installed:" % pkgname
                    for m in messages:
                        print "\t", m
1090 1091

def main():
Jack Jansen's avatar
Jack Jansen committed
1092
    """Minimal commandline tool to drive pimp."""
1093

Jack Jansen's avatar
Jack Jansen committed
1094 1095
    import getopt
    def _help():
1096 1097 1098 1099
        print "Usage: pimp [options] -s [package ...]  List installed status"
        print "       pimp [options] -l [package ...]  Show package information"
        print "       pimp [options] -i package ...    Install packages"
        print "       pimp -d                          Dump database to stdout"
1100
        print "       pimp -V                          Print version number"
Jack Jansen's avatar
Jack Jansen committed
1101
        print "Options:"
1102 1103
        print "       -v     Verbose"
        print "       -f     Force installation"
1104 1105 1106
        print "       -D dir Set destination directory"
        print "              (default: %s)" % DEFAULT_INSTALLDIR
        print "       -u url URL for database"
Jack Jansen's avatar
Jack Jansen committed
1107
        sys.exit(1)
1108

1109 1110 1111 1112
    class _Watcher:
        def update(self, msg):
            sys.stderr.write(msg + '\r')
            return 1
1113

Jack Jansen's avatar
Jack Jansen committed
1114
    try:
1115 1116
        opts, args = getopt.getopt(sys.argv[1:], "slifvdD:Vu:")
    except getopt.GetoptError:
Jack Jansen's avatar
Jack Jansen committed
1117 1118 1119 1120 1121 1122
        _help()
    if not opts and not args:
        _help()
    mode = None
    force = 0
    verbose = 0
1123
    prefargs = {}
1124
    watcher = None
Jack Jansen's avatar
Jack Jansen committed
1125 1126 1127 1128 1129 1130 1131 1132 1133 1134 1135 1136 1137
    for o, a in opts:
        if o == '-s':
            if mode:
                _help()
            mode = 'status'
        if o == '-l':
            if mode:
                _help()
            mode = 'list'
        if o == '-d':
            if mode:
                _help()
            mode = 'dump'
1138 1139 1140 1141
        if o == '-V':
            if mode:
                _help()
            mode = 'version'
Jack Jansen's avatar
Jack Jansen committed
1142 1143 1144 1145 1146 1147
        if o == '-i':
            mode = 'install'
        if o == '-f':
            force = 1
        if o == '-v':
            verbose = 1
1148
            watcher = _Watcher()
1149 1150
        if o == '-D':
            prefargs['installDir'] = a
1151 1152
        if o == '-u':
            prefargs['pimpDatabase'] = a
Jack Jansen's avatar
Jack Jansen committed
1153 1154
    if not mode:
        _help()
1155 1156 1157
    if mode == 'version':
        print 'Pimp version %s; module name is %s' % (PIMP_VERSION, __name__)
    else:
1158
        _run(mode, verbose, force, args, prefargs, watcher)
1159 1160 1161 1162 1163 1164 1165 1166 1167 1168 1169 1170 1171 1172 1173 1174 1175

# Finally, try to update ourselves to a newer version.
# If the end-user updates pimp through pimp the new version
# will be called pimp_update and live in site-packages
# or somewhere similar
if __name__ != 'pimp_update':
    try:
        import pimp_update
    except ImportError:
        pass
    else:
        if pimp_update.PIMP_VERSION <= PIMP_VERSION:
            import warnings
            warnings.warn("pimp_update is version %s, not newer than pimp version %s" %
                (pimp_update.PIMP_VERSION, PIMP_VERSION))
        else:
            from pimp_update import *
1176

1177
if __name__ == '__main__':
Jack Jansen's avatar
Jack Jansen committed
1178
    main()