PackageManager

Cocoa Package Manager

NOTE:

The Package Manager infrastructure is currently unmaintained. This example is not intended for actual use, but remains to show how such an application could be built.

This application is a Cocoa version of the Package Manager application that is included with MacPython.

This is a first version of the application, using an unmodified pimp module, it does not implement the ideas described on the NewPackageManager page on the MacPython wiki.

Features w.r.t. the official Package Manager:

  • You can have a list of favorite databases
  • The scroll-list doesn’t scroll back automatically ;-)

Building

Run python setup.py py2app to create the application.

Sources

packman.py

"""
Cocoa GUI for the Package Manager

This is a first generation of the Cocoa GUI, it inherits some of the nasty
features of the current Carbon version:

1. GUI blocks during some operations, such as downloading or installing

2. Checking on GUI packages may crash the application

The first item can only be solved by rewriting parts of the pimp module, the
second part will be solved by running at least some pimp related code in a
seperate process.

TODO:
- Make sure 'File -> Open...' actually works

XXX:
- save preferences in the favorites db (for databases that are in in there)?
"""

from Cocoa import *
import objc
import threading

from PyObjCTools import AppHelper

import sys
import pimp
import webbrowser

# File type for packman databases
DB_FILE_TYPE="Python Package Database"

# Extract class information from the NIB files
# - MainMenu: Global application stuff
# - OpenPanel: The 'Open URL...' window
# - PackageDatabase: Document window

def setString(field, value):
    """
    Set an NSTextField to the specified value. Clears the field if 'value'
    is None.
    """
    if value is None:
        field.setStringValue_("")
    else:
        field.setStringValue_(value)


##
# We break the abstraction of some of the objects in the pimp module. That
# is necessary because we cannot get at the required information using the
# public interfaces :-(
#
def DB_DESCRIPTION(pimpDB):
    return pimpDB._description

def DB_MAINTAINER(pimpDB):
    return pimpDB._maintainer

def DB_URL(pimpDB):
    return pimpDB._urllist[0]

def PKG_HIDDEN(package):
    """ Return True iff the package is a hidden package """
    return (package._dict.get('Download-URL', None) is None)




class PackageDatabase (NSDocument):
    """
    The document class for a package database
    """
    databaseMaintainer = objc.IBOutlet()
    databaseName = objc.IBOutlet()
    installButton = objc.IBOutlet()
    installDependencies = objc.IBOutlet()
    installationLocation = objc.IBOutlet()
    installationLog = objc.IBOutlet()
    installationPanel = objc.IBOutlet()
    installationProgress = objc.IBOutlet()
    installationTitle = objc.IBOutlet()
    itemDescription = objc.IBOutlet()
    itemHome = objc.IBOutlet()
    itemInstalled = objc.IBOutlet()
    itemStatus = objc.IBOutlet()
    overwrite = objc.IBOutlet()
    packageTable = objc.IBOutlet()
    prerequisitesTable = objc.IBOutlet()
    progressOK = objc.IBOutlet()
    showHidden = objc.IBOutlet()
    verbose = objc.IBOutlet()


    def init(self):
        """
        Initialize the document without a database
        """

        self = super(PackageDatabase, self).init()
        if self is None: return None
        self.pimp =  None
        self._packages = []
        return self


    def initWithContentsOfFile_ofType_(self, path, type):
        """
        Open a local database.
        """
        self = self.init()
        if self is None: return self

        url = NSURL.fileURLWithPath_(path)

        self.openDB(url.absoluteString())
        return self

    def __del__(self):
        """ Clean up after ourselves """
        if hasattr(self, 'timer'):
            self.timer.invalidate()
            del self.timer

    def close(self):
        if hasattr(self, 'timer'):
            self.timer.invalidate()
            del self.timer
        super(PackageDatabase, self).close()

    def setDB(self, pimpURL, pimpDB):
        self.pimp = pimpDB
        self._packages = pimpDB.list()
        self._prerequisites = []
        if self.databaseName is not None:
            self.databaseName.setStringValue_(DB_DESCRIPTION(self.pimp))
            self.databaseMaintainer.setStringValue_(DB_MAINTAINER(self.pimp))

        if self.packageTable is not None:
            self.packageTable.reloadData()
            self.tableViewSelectionDidChange_(None)

        self.setFileName_(pimpURL)
        self.pimpURL = pimpURL

        if hasattr(self, 'timer'):
            self.timer.invalidate()

        self.timer = NSTimer.scheduledTimerWithTimeInterval_target_selector_userInfo_repeats_(
                10.0,
                self,
                self.checkUpdates_,
                None,
                True)


    def openDB(self, dbUrl=None):
        """
        Open a database at the specified URL
        """
        prefs = pimp.PimpPreferences()
        if dbUrl is not None:
            prefs.pimpDatabase = dbUrl
        else:
            prefs.pimpDatabase = pimp.DEFAULT_PIMPDATABASE

        db = pimp.PimpDatabase(prefs)
        db.appendURL(prefs.pimpDatabase)
        self.setDB(dbUrl, db)

    def checkUpdates_(self, sender):
        """
        Refresh the package information, the user may have installed or
        removed a package. This method is called once in a while using a timer.
        """
        if self.packageTable is None: return

        self.sortPackages()
        self.packageTable.reloadData()

    def windowNibName(self):
        """ Return the name of the document NIB """
        return 'PackageDatabase'

    def displayName(self):
        """ Return the document name for inside the window title """
        if self.pimp is None:
            return "Untitled"

        return DB_URL(self.pimp)

    def awakeFromNib(self):
        """
        Initialize the GUI now that the NIB has been loaded.
        """
        if self.pimp is not None:
            self.databaseName.setStringValue_(DB_DESCRIPTION(self.pimp))
            self.databaseMaintainer.setStringValue_(DB_MAINTAINER(self.pimp))

        else:
            self.databaseName.setStringValue_("")
            self.databaseMaintainer.setStringValue_("")


        self.setBoolFromDefaults(self.verbose, 'verbose')
        self.setBoolFromDefaults(
                self.installDependencies, 'installDependencies')
        self.setBoolFromDefaults(self.showHidden, 'showHidden')
        self.setBoolFromDefaults(self.overwrite, 'forceInstallation')

        b = NSUserDefaults.standardUserDefaults(
                ).boolForKey_('installSystemWide')
        if b:
            self.installationLocation.setState_atRow_column_(NSOnState, 0, 0)
        else:
            self.installationLocation.setState_atRow_column_(NSOnState, 1, 0)

        self.sortPackages()

    def setBoolFromDefaults(self, field, name):
        defaults = NSUserDefaults.standardUserDefaults()
        b = defaults.boolForKey_(name)
        if b:
            field.setState_(NSOnState)
        else:
            field.setState_(NSOffState)

    def saveBoolToDefaults(self, field, name):
        defaults = NSUserDefaults.standardUserDefaults()
        defaults.setBool_forKey_(field.state() == NSOnState, name)
        defaults.synchronize()

    @objc.IBAction
    def savePreferences_(self, sender):
        self.saveBoolToDefaults(self.verbose, 'verbose')
        self.saveBoolToDefaults(self.installDependencies, 'installDependencies')
        self.saveBoolToDefaults(self.showHidden, 'showHidden')
        self.saveBoolToDefaults(self.overwrite, 'forceInstallation')
        self.saveBoolToDefaults(
                self.installationLocation.cellAtRow_column_(0, 0),
                'installSystemWide')

    def packages(self):
        return self._packages

    def selectedPackage(self):
        row = self.packageTable.selectedRow()
        if row == -1: return None

        return self._packages[row]


    def tableViewSelectionDidChange_(self, obj):
        """
        Update the detail view
        """

        package = self.selectedPackage()

        if package is None:
            # No selected package, clear the detail view
            setString(self.itemHome, None)
            setString(self.itemStatus, None)
            setString(self.itemInstalled, None)
            self.itemDescription.setString_("")
            self.installButton.setEnabled_(False)
            self._prerequisites = []
            self.prerequisitesTable.reloadData()

        else:
            # Update the detail view

            setString(self.itemHome, package.homepage())

            # XXX: Could we use ReST for the the description?
            # Recognizing and 'activating' URL's would be fairly easy.
            self.itemDescription.setString_(
                    package.description()
            )

            status, msg = package.installed()
            setString(self.itemInstalled, status)
            setString(self.itemStatus, msg)
            self.installButton.setEnabled_(True)
            self._prerequisites = package.prerequisites()

            # XXX: Add the closure of all dependencies

            self.prerequisitesTable.reloadData()

    @objc.IBAction
    def addToFavorites_(self, sender):
        appdel = NSApplication.sharedApplication().delegate()
        appdel.addFavorite(self.pimp._description, self.pimp._urllist[0])

    #
    # NSTableDataSource implementation, for the package list
    #

    def numberOfRowsInTableView_(self, view):

        if not hasattr(self, 'pimp') or self.pimp is None:
            return 0

        if view is self.packageTable:
            return len(self._packages)
        else:
            return len(self._prerequisites)


    def tableView_objectValueForTableColumn_row_(self, view, col, row):

        colname = col.identifier()

        if view is self.packageTable:
            package = self._packages[row]
            shortdescription = None
        else:
            package, shortdescription = self._prerequisites[row]

        if colname == 'installed':
            # XXX: Nicer formatting
            return getattr(package, colname)()[0]

        return getattr(package, colname)()

    def tableView_sortDescriptorsDidChange_(self, view, oldDescriptors):
        if view is self.packageTable:
            self.sortPackages()

    def sortPackages(self):
        """
        Sort the package list in the order wished for by the user.
        """
        if self.pimp is None:
            return

        if self.packageTable is None:
            return

        sortInfo = [
            (item.key(), item.ascending(), item.selector())
                for item in self.packageTable.sortDescriptors()
        ]

        if self.showHidden.state() == NSOnState:
            self._packages = self.pimp.list()[:]
        else:
            self._packages = [ pkg
                for pkg in self.pimp.list() if not PKG_HIDDEN(pkg) ]

        if not sortInfo:
            self.packageTable.reloadData()
            self.tableViewSelectionDidChange_(None)
            return

        def cmpBySortInfo(l, r):
            for key, ascending, meth in sortInfo:
                if key == 'installed':
                    l_val = getattr(l, key)()[0]
                    r_val = getattr(r, key)()[0]
                else:
                    l_val = getattr(l, key)()
                    r_val = getattr(r, key)()
                if meth == 'compare:':
                    res = cmp(l_val, r_val)
                else:
                    if isinstance(l_val, objc.pyobjc_unicode):
                        l_val = l_val.nsstring()
                    elif isinstance(l_val, (unicode, str)):
                        l_val = NSString.stringWithString_(l_val).nsstring()
                    res = getattr(l_val, meth)(r_val)

                if not ascending:
                    res = -res
                if res != 0:
                    return res

            return 0

        self._packages.sort(cmpBySortInfo)
        self.packageTable.reloadData()

    @objc.IBAction
    def filterPackages_(self, sender):
        """
        GUI action that is triggered when one of the view options
        changes
        """
        self.sortPackages()

    @objc.IBAction
    def visitHome_(self, sender):
        """
        Open the homepage of the currently selected package in the
        default webbrowser.
        """
        package = self.selectedPackage()
        if package is None:
            return

        home = package.homepage()
        if home is None:
            return

        try:
            webbrowser.open(home)
        except Exception, msg:
            NSBeginAlertSheet(
                    'Opening homepage failed',
                    'OK', None, None, self.windowForSheet(), None, None, None,
                    0, 'Could not open homepage: %s'%(msg,))


    @objc.IBAction
    def installPackage_(self, sender):
        """
        Install the currently selected package
        """
        package = self.selectedPackage()
        if package is None: return

        force = self.overwrite.state() == NSOnState
        recursive = self.installDependencies.state() == NSOnState

        pimpInstaller = pimp.PimpInstaller(self.pimp)
        lst, messages = pimpInstaller.prepareInstall(package, force, recursive)

        if messages:
            NSBeginAlertSheet(
                    'Cannot install packages',
                    'OK', None, None,
                    self.windowForSheet(), None, None, None, 0,
                    '\n'.join(messages))
            return

        app = NSApplication.sharedApplication()
        self.installationTitle.setStringValue_(
                'Installing: %s ...'%(package.shortdescription(),))
        self.installationProgress.setHidden_(False)
        self.installationProgress.startAnimation_(self)
        self.progressOK.setEnabled_(False)
        ts = self.installationLog.textStorage()
        ts.deleteCharactersInRange_((0, ts.length()))
        app.beginSheet_modalForWindow_modalDelegate_didEndSelector_contextInfo_(
                self.installationPanel,
                self.windowForSheet(),
                None, None, 0)

        # I'm not sure if this accidental or not, but prepareInstall() returns
        # a list of package in the order that they should be installed in,
        # and install() installs them in the reverse order :-(
        # XXX: This seems to be a bug in pimp.
        self.runner = InstallerThread(
                            self,
                            pimpInstaller,
                            lst[::-1],
                            self.verbose.state() == NSOnState,
                            self.installationLog.textStorage()
                        )

        self.runner.start()

    @objc.IBAction
    def closeProgress_(self, sender):
        """
        Close the installation progress sheet
        """
        self.installationPanel.close()
        NSApplication.sharedApplication().endSheet_(self.installationPanel)

    @objc.IBAction
    def installationDone_(self, sender):
        """
        The installer thread is ready, close the sheet.
        """
        self.progressOK.setEnabled_(True)
        self.installationProgress.setHidden_(False)
        self.installationProgress.stopAnimation_(self)

        messages = self.runner.result
        if messages:
            ts = self.installationLog.textStorage()
            ts.appendAttributedString_(
                NSAttributedString.alloc().initWithString_attributes_(
                    '\n\nCannot install packages\n\n',
                    {
                        NSFontAttributeName: NSFont.boldSystemFontOfSize_(12),
                    }
                ))

            ts.appendAttributedString_(
                NSAttributedString.alloc().initWithString_(
                    '\n'.join(messages) + '\n'))

        self.packageTable.reloadData()
        self.tableViewSelectionDidChange_(None)


class DownloadThread (threading.Thread):
    """
    Thread for downloading a PackageManager database.

    This is used by the application delegate to open databases.
    """
    daemon_thread = True

    def __init__(self, master, document, url):
        """
        Initialize the thread.

        master   - NSObject implementing dbReceived: and dbProblem:
        document - An PackageDatabase
        url      - The PackMan URL
        """
        threading.Thread.__init__(self)
        self.master = master
        self.document = document
        self.url = url

    def run(self):
        """
        Run the thread. This creates a new pimp.PimpDatabase, tells it to
        download our database and then forwards the database to the
        master. The last step is done on the main thread because of Cocoa
        threading issues.
        """
        pool = NSAutoreleasePool.alloc().init()

        try:
            prefs = pimp.PimpPreferences()
            if self.url is not None:
                prefs.pimpDatabase = self.url
            else:
                prefs.pimpDatabase = pimp.DEFAULT_PIMPDATABASE

            db = pimp.PimpDatabase(prefs)
            db.appendURL(prefs.pimpDatabase)

            self.master.performSelectorOnMainThread_withObject_waitUntilDone_(
                'dbReceived:', (self.document, self.url, db), False)

        except:
            self.master.performSelectorOnMainThread_withObject_waitUntilDone_(
                'dbProblem:', (self.document, self.url, sys.exc_info()), False)

        del pool




class InstallerThread (threading.Thread):
    """
    A thread for installing packages.

    Like downloading a database, installing (and downloading!) packages is
    a time-consuming task that is better done on a seperate thread.
    """
    daemon_thread = True

    def __init__(self, document, installer, packages, verbose, textStorage):
        threading.Thread.__init__(self)
        self.document = document
        self.installer = installer
        self.packages = packages
        self.verbose = verbose
        self.textStorage = textStorage
        self.result = None

    def write(self, data):
        self.textStorage.performSelectorOnMainThread_withObject_waitUntilDone_(
                'appendAttributedString:',
                NSAttributedString.alloc().initWithString_(data),
                False)

    def run(self):
        pool = NSAutoreleasePool.alloc().init()

        if self.verbose:
            result = self.installer.install(self.packages, self)
        else:
            result = self.installer.install(self.packages, None)

        self.write('\nDone.\n')

        self.document.performSelectorOnMainThread_withObject_waitUntilDone_(
                'installationDone:', None, False)

        del pool

class URLOpener (NSObject):
    """
    Model/controller for the 'File/Open URL...' panel
    """
    okButton = objc.IBOutlet
    urlField = objc.IBOutlet()

    def __del__(self):
        # XXX: I'm doing something wrong, this function is never called!
        print "del URLOpener %#x"%(id(self),)


    def awakeFromNib(self):
        self.urlField.window().makeKeyAndOrderFront_(None)

    @objc.IBAction
    def doOpenURL_(self, sender):
        url = self.urlField.stringValue()
        if not url:
            return

        # Ask the application delegate to open the selected database
        NSApplication.sharedApplication().delegate().openDatabase(url)

    @objc.IBAction
    def controlTextDidChange_(self, sender):
        """
        The value of the URL input field changed, enable the OK button
        if there is input, disable it otherwise.
        """
        if self.urlField.stringValue() != "":
            self.okButton.setEnabled_(True)
        else:
            self.okButton.setEnabled_(False)



class PackageManager (NSObject):
    """
    Application controller: application-level callbacks and actions
    """
    favoritesPanel = objc.IBOutlet()
    favoritesTable = objc.IBOutlet()
    favoritesTitle = objc.IBOutlet()
    favoritesURL   = objc.IBOutlet()

    #
    # Standard actions
    #

    def awakeFromNib(self):
        """
        We've been restored from the NIB
        """
        self.loadFavorites()

    #
    # Working with favorites
    #
    # The favorites are stored in the user defaults for the application.

    def loadFavorites(self):
        """
        Load our favorite database
        """
        self.favorites = NSUserDefaults.standardUserDefaults().arrayForKey_(
                    'favorites')
        if self.favorites is None:
            self.favorites = []
        else:
            self.favorites = list(self.favorites)

    def saveFavorites(self):
        """
        Save the favorites database, must be called whenever self.favorites
        is changed.
        """
        defaults = NSUserDefaults.standardUserDefaults()
        defaults.setObject_forKey_(
                self.favorites,
                'favorites')
        defaults.synchronize()

    def addFavorite(self, title, url):
        """
        Add a new favorite, and save the database
        """
        self.favorites.append({'title':title, 'URL':url})
        self.favoritesTable.reloadData()
        self.saveFavorites()

    def menuNeedsUpdate_(self, menu):
        """
        We're the delegate for the Favorites menu

        Update the menu: it should list the entries in the favorites database.
        """
        menuLen = menu.numberOfItems()

        # Remove old items
        for i in range(menuLen-1, 2, -1):
            menu.removeItemAtIndex_(i)

        # Insert new ones
        for item in self.favorites:
            title = item['title']
            url = item['URL']

            mi = NSMenuItem.alloc().initWithTitle_action_keyEquivalent_(
                    title, self.openFavorite_, "")
            mi.setTarget_(self)
            mi.setRepresentedObject_(item)
            menu.addItem_(mi)


    def tableViewSelectionDidChange_(self, obj):
        """
        We're the delegate (and datasource) for the favorites list in the
        edit pane for the favorites.

        Update the input fields to show the current item.
        """

        row = self.favoritesTable.selectedRow()
        if row == -1:
            self.favoritesTitle.setStringValue_('')
            self.favoritesURL.setStringValue_('')
            self.favoritesTitle.setEnabled_(False)
            self.favoritesURL.setEnabled_(False)
        else:
            self.favoritesTitle.setStringValue_(self.favorites[row]['title'])
            self.favoritesURL.setStringValue_(self.favorites[row]['URL'])
            self.favoritesTitle.setEnabled_(True)
            self.favoritesURL.setEnabled_(True)


    def numberOfRowsInTableView_(self, view):
        """
        We're the datasource for the favorites list in the Favorites panel
        """
        if not hasattr(self, 'favorites'):
            return 0

        return len(self.favorites)

    def tableView_objectValueForTableColumn_row_(self, view, col, row):
        """
        We're the datasource for the favorites list in the Favorites panel
        """
        return self.favorites[row]['title']

    @objc.IBAction
    def changeFavoritesTitle_(self, sender):
        """
        Update the title of the currently selected favorite item
        """
        row = self.favoritesTable.selectedRow()
        if row == -1:
            return

        self.favorites[row]['title'] = self.favoritesTitle.stringValue()
        self.saveFavorites()

        self.favoritesTable.reloadData()


    @objc.IBAction
    def changeFavoritesUrl_(self, sender):
        """
        Update the URL of the currently selected favorite item
        """
        row = self.favoritesTable.selectedRow()
        if row == -1:
            return

        self.favorites[row]['URL'] = self.favoritesURL.stringValue()
        self.saveFavorites()

        self.favoritesTable.reloadData()

    @objc.IBAction
    def openFavorite_(self, sender):
        """
        Open a favorite database (action for entries in the Favorites menu)
        """
        self.openDatabase(sender.representedObject()['URL'])


    #
    # Global actions/callbacks
    #

    def openDatabase(self, url):
        """
        Create a new NSDocument for the database at the specified URL.
        """
        doc = NSDocumentController.sharedDocumentController(
                ).openUntitledDocumentOfType_display_(DB_FILE_TYPE, False)
        try:
            downloader = DownloadThread(self, doc, url)
            downloader.start()
        except:
            doc.close()
            raise

    def dbReceived_(self, (doc, url, db)):
        doc.setDB(url, db)
        doc.showWindows()

    def dbProblem_(self, (doc, url, exc_info)):
        NSRunAlertPanel(
                "Cannot open database",
                "Opening database at %s failed: %s"%(url, exc_info[1]),
                "OK", None, None)
        doc.close()



    @objc.IBAction
    def openURL_(self, sender):
        """
        The user wants to open a package URL, show the user-interface.
        """
        res = NSBundle.loadNibNamed_owner_('OpenPanel', self)

    @objc.IBAction
    def openStandardDatabase_(self, sender):
        """
        Open the standard database.
        """
        self.openDatabase(pimp.DEFAULT_PIMPDATABASE)

    def applicationShouldOpenUntitledFile_(self, app):
        """
        The default window is not an untitled window, but the default
        database
        """
        return False

    def applicationDidFinishLaunching_(self, app):
        """
        The application finished launching, show the default database.
        """
        # XXX: We shouldn't open the standard database if the user explicitly
        # opened another one!
        self.openStandardDatabase_(None)

#
# Set some sensible defaults
#
NSUserDefaults.standardUserDefaults().registerDefaults_(
        {
          'verbose': True,
          'installDependencies': True,
          'showHidden': False,
          'forceInstallation': False,
          'installSystemWide': True,
        })

#
# A nasty hack. For some reason sys.prefix is /usr/bin/../../System/..., while
# it is /System/... in Jack's PackageManager.app.  At least one package
# manager database relies on sys.prefix being /System/... (Bob's additional
# packages).
#
import os
sys.prefix = os.path.abspath(sys.prefix)

AppHelper.runEventLoop()

setup.py

"""
Script for building the example.

Usage:
    python setup.py py2app
"""
# This is very likely incorrect, as the application is non-existant at the
# moment.
from setuptools import setup

import os
import sys
import glob

DB_FILE_TYPE="Python Package Database"

plist = dict(
    CFBundleName="Package Manager",
    CFBundleIconFile="PackageManager.icns",
    CFBundleDocumentTypes=[
        dict(
            CFBundleTypeName=DB_FILE_TYPE,
            CFBundleTypeRole="Editor",
            NSDocumentClass="PackageDatabase",
            # CFBundleTypeIconFile="Package Database.icns",
            CFBundleTypeExtensions = ["packman", "plist" ],
            CFBundleTypeOSTypes=[],
        ),
    ],

    CFBundleGetInfoString="1.0, Copyright 2004 Ronald Oussoren",
    CFBundleIdentifier="net.sf.pyobjc.PackageManager",
    CFBundleShortVersionString="1.0",
    CFBundleVersion="1.0",

    # We need at least Panther, it may work on Jaguar but I"ve not yet
    # verified if it should work.
    LSMinimumSystemVersion="10.3.0",

    # We"re not apple-scriptable
    NSAppleScriptEnabled="No",
)

setup(
    app=["packman.py"],
    data_files=glob.glob("Resources/*"),
    options=dict(
        py2app=dict(
            plist=plist
        )
    ),
    setup_requires=[
        "py2app",
        "pyobjc-framework-Cocoa",
    ]
)

Resources

Table Of Contents

Resources

Support development