Interfacing with Skype from Python via PyObjC

Mar 28, 2007

Someone asked me, "How do you talk with Skype API on Mac OS X from Python via PyObjC"? Sounded like my kind of thing to take a look :)

PyObjC is, as it says, "The Python <-> Objective-C Bridge". So it lets you access the rich class API of Mac OS X from Python. You may want to do this for a variety of scripting purposes, as Python is gaining traction as a cool clean scripting language and comes bundled with OS X. One application could be that you want to do another bridge with this and have Python sitting, say, on a read/write socket to where you can connect with yet further apps to talk with the Skype API. Or you may want to collect/analyze/archive data you get from Skype or whatever.

There seem to be other fun ways of accessing ObjC classes these days. Apple's own page points out:

PyObjC is just one of many Cocoa bridges. Apple has offered its Java bridge since Mac OS X 10.0, and Mac OS X Tiger 10.4 rolled out a JavaScript bridge which allows Dashboard Widgets to communicate with Objective-C objects.

Maybe I'll one day take a look into this JavaScript one. As it says, it's cool for doing Dashboard widgets so you could imagine Dashboard widgets that do fun things around Skype, and perhaps if you're considering writing web/desktop apps with something like the newly released Apollo, then the JavaScript beidge might be helpful.

For now, though, let's stick with Python and PyObjC. There are really only two things here -- get PyObjC set up, and run the script.

Installing PyObjC

Just pull it from their download page. I have a modified Python installation, and the binary puked on this and said I need "system Python". I was preparing myself for major trouble but there was an easier way. Just pull the source and run the following that builds the custom binary installer for you and runs it right away.

python setup.py bdist_mpkg --open

Coding it up

After installation, it was time to code it up. I found the PyObjC examples useful. Some kind soul actually posted a snippet about Skype and PyObjC on Skype DevZone Code Snippets page. I was having major trouble with loading the Skype framework bundle and accessing the methods, but this helped me out.

It was only half of the work, though. The remaining half was setting up the delegate (callback handler) class. It was helpful that I had previously learned some Objective C and studied the Mac OS X Skype API example and Cocoa class reference. It can be a bit intimidating if you're just starting up and haven't done much in Cocoa before. But I kinda got it working.

I say "kinda" because I discovered some inconsistencies and undocumented features/bugs in the Skype API/connector behaviour. The first one is that the behaviour of "sendSkypeCommand" method differs across Skype/framework versions. I had an older framework laying around where it doesn't return anything -- instead, the response gets handled through the delegate and its "skypeNotificationReceived" method, just as any other communication from Skype. I had my application designed around this and the old framework. Then, I switched to new framework that is currently shipped with Skype, and suddenly things stopped working and responses didn't come through the delegate any more. I found that they are actually returned synchronously right at calling time where you need to capture and process them, instead of your delegate method. I have no idea when the behaviour changed, as it's not really documented anywhere. So perhaps the Mac developer docs need to improve a bit here and consider framework versioning.

Secondly, you won't see the "skypeAttachResponse" used in the code below. This method is useful because it reports you changes in Skype connection status, such as if you try to connect, was it successful or not. But when I tried to use it, it crashed. It works when using with one parameter, but the parameter seems to point to "self", i.e the delegate, and is not the response code. And when I add another parameter, hoping that it might actually be the response code, it crashes with "bus error" (what bus?). The method works fine directly in ObjC and Cocoa, so it must be something about the framework loading in PyObjC. Perhaps I need to load the method in a more explicit way (maybe with loadBundleMethods where you seem to be able to give it a more explicit method signature?). But for this proof of concept, I didn't really need it so I skipped it.

The script

Below is the actual script. When you have PyObjC installed and Skype running, it should work fine. Just start it up and enter Skype API commands (see API reference for ideas) and hit Ctrl-C to quit.

Note that this is proof-of-concept only. It assumes Skype is running when you start it and you approve the connection request. I didn't plan for situations where Skype is not available, you quit it halfway through, deny the request etc. You can add all this as a home exercise :)

A note about encoding. When you start it just by running the script, it assumes US-ASCII encoding everywhere. If any of your data is beyond ASCII, Skype will send it as UTF-8. And you will see some encoding errors on the console. To go around this, set the locale at the time of running the script. Run it something like this (assuming you saved it in pyobjc-skype.py):

LC_ALL=en_US.UTF-8 python ./pyobjc-skype.py

And here's the actual script. Save it in a .py file, give it execute rights and you should be good to go. (Most of it is actually PyObjC example code for handling stdin, the Skype part is really small :) )

#!/usr/bin/env python
# -*- coding: utf-8 -*-

import objc
from Foundation import *
from AppKit import *
from PyObjCTools import AppHelper
import time
import sys

class AppDelegate (NSObject):
    def applicationDidFinishLaunching_(self, aNotification):
        pass
        # you could instead have some code here to be invoked at startup

    def clientApplicationName(self):
        return "Skype-PyObjC test program" # what gets displayed to Skype UI
    def skypeNotificationReceived_(self, aString):
        print "< " + aString # just print whatever we got from Skype as message
    def skypeAttachResponse_(aNotification):
        pass
        # could have some meaningful code here to be notified of Skype availability
        # events... but this first one only points to myself (delegate), and when I
        # tried to have extra parameters, it crashed with "bus error" :(

### BEGIN - example code from stdinreader.py... kinda bloated just for reading stdin?
### but provides us nice asynchronous notifications

class FileObserver(NSObject):
    def initWithFileDescriptor_readCallback_errorCallback_(self,
            fileDescriptor, readCallback, errorCallback):
        self = self.init()
        self.readCallback = readCallback
        self.errorCallback = errorCallback
        self.fileHandle = NSFileHandle.alloc().initWithFileDescriptor_(
            fileDescriptor)
        self.nc = NSNotificationCenter.defaultCenter()
        self.nc.addObserver_selector_name_object_(
            self,
            'fileHandleReadCompleted:',
            NSFileHandleReadCompletionNotification,
            self.fileHandle)
        self.fileHandle.readInBackgroundAndNotify()
        return self

    def fileHandleReadCompleted_(self, aNotification):
        ui = aNotification.userInfo()
        newData = ui.objectForKey_(NSFileHandleNotificationDataItem)
        if newData is None:
            if self.errorCallback is not None:
                self.errorCallback(self, ui.objectForKey_(NSFileHandleError))
            self.close()
        else:
            self.fileHandle.readInBackgroundAndNotify()
            if self.readCallback is not None:
                self.readCallback(self, str(newData))

    def close(self):
        self.nc.removeObserver_(self)
        if self.fileHandle is not None:
            self.fileHandle.closeFile()
            self.fileHandle = None
        # break cycles in case these functions are closed over
        # an instance of us
        self.readCallback = None
        self.errorCallback = None

    def __del__(self):
        # Without this, if a notification fires after we are GC'ed
        # then the app will crash because NSNotificationCenter
        # doesn't retain observers.  In this example, it doesn't
        # matter, but it's worth pointing out.
        self.close()


def prompt():
    sys.stdout.write("write something: ")
    sys.stdout.flush()

# rewritten to handle Skype API
def gotLine(observer, aLine):
    if aLine:
        if SkypeAPI.isSkypeAvailable():
            print "> " + aLine.rstrip()
            print "< " + SkypeAPI.sendSkypeCommand_(aLine.rstrip())
        else:
            print "Cannot send command, Skype not available: " + aLine.rstrip()
    else:
        print ""
        AppHelper.stopEventLoop()

def gotError(observer, err):
    print "error:", err
    AppHelper.stopEventLoop()

### END - example code from stdinreader

def main():
    app = NSApplication.sharedApplication()

    # we must keep a reference to the delegate object ourselves,
    # NSApp.setDelegate_() doesn't retain it. A local variable is
    # enough here.
    delegate = AppDelegate.alloc().init()
    NSApp().setDelegate_(delegate)

    objc.loadBundle("SkypeAPI", globals(), bundle_path=objc.pathForFramework(
        u'/Applications/Skype.app/Contents/Frameworks/Skype.framework'))
    # I had an older Skype framework copied to Library. The old one sends async
    # responses to posted messages. The framework bundled with current Skypes,
    # that actually gets loaded, returns responses as synchronous.
    # objc.loadBundle("SkypeAPI", globals(), bundle_path=objc.pathForFramework(
    # u'/Library/Frameworks/Skype.framework'))
    SkypeAPI.setSkypeDelegate_(delegate)

    SkypeAPI.connect()
    AppHelper.installMachInterrupt() # install ctrl-c handler

    observer = FileObserver.alloc().initWithFileDescriptor_readCallback_errorCallback_(
        sys.stdin.fileno(), gotLine, gotError)

    print "Running Skype API client. Enter Skype API commands and watch amazed as they get executed. Press Ctrl-C to quit.\n"

    AppHelper.runEventLoop()
    # there are actually many ways to run the eventloop :) here are some more examples
    # that I got from various places. not sure which one is the most correct...
    # app.run()
    # AppHelper.runConsoleEventLoop(installInterrupt=True)

if __name__ == '__main__' : main()