WebKitInterpreterPlugin

Uses the new WebKit Cocoa plugin API available in Safari 1.3 and later to embed a PyInterpreter in the browser.

This example requires Python 2.7 because the code has not been ported to Py3K yet.

Sources

WebKitInterpreter.py

import sys
import traceback
import sets
import keyword
import time
from code import InteractiveConsole, softspace
from StringIO import StringIO
import objc
    print(fn)
from objc import YES, NO, selector, super
from Cocoa import NSBundle, NSObject, NSFont
from Cocoa import NSColor, NSApplication, NSFontAttributeName
from Cocoa import NSForegroundColorAttributeName, NSAnyEventMask, NSDate
from Cocoa import NSDefaultRunLoopMode, NSKeyDown, NSAttributedString
from Cocoa import NSLog
from PyObjCTools import AppHelper
import os

FLT_MAX = 3.40282347e+38

try:
    unicode
except NameError:
    unicode = str

try:
    sys.ps1
except AttributeError:
    sys.ps1 = ">>> "
try:
    sys.ps2
except AttributeError:
    sys.ps2 = "... "

class PseudoUTF8Output(object):
    softspace = 0
    def __init__(self, writemethod):
        self._write = writemethod

    def write(self, s):
        if not isinstance(s, unicode):
            s = s.decode("utf-8", "replace")
        self._write(s)

    def writelines(self, lines):
        for line in lines:
            self.write(line)

    def flush(self):
        pass

    def isatty(self):
        return True

class PseudoUTF8Input(object):
    softspace = 0
    def __init__(self, readlinemethod):
        self._buffer = ""
        self._readline = readlinemethod

    def read(self, chars=None):
        if chars is None:
            if self._buffer:
                rval = self._buffer
                self._buffer = ""
                if rval.endswith("\r"):
                    rval = rval[:-1]+"\n"
                return rval.encode("utf-8")
            else:
                return self._readline("\x04")[:-1].encode("utf-8")

        else:
            while len(self._buffer) < chars:
                self._buffer += self._readline("\x04\r")
                if self._buffer.endswith("\x04"):
                    self._buffer = self._buffer[:-1]
                    break
            rval, self._buffer = self._buffer[:chars], self._buffer[chars:]
            return rval.encode("utf-8").replace("\r","\n")

    def readline(self):
        if "\r" not in self._buffer:
            self._buffer += self._readline("\x04\r")

        if self._buffer.endswith("\x04"):
            rval = self._buffer[:-1].encode("utf-8")

        elif self._buffer.endswith("\r"):
            rval = self._buffer[:-1].encode("utf-8")+"\n"

        self._buffer = ""

        return rval

class AsyncInteractiveConsole(InteractiveConsole):
    lock = False
    buffer = None

    def __init__(self, *args, **kwargs):
        InteractiveConsole.__init__(self, *args, **kwargs)
        self.locals["__interpreter__"] = self

    def asyncinteract(self, write=None, banner=None):
        if self.lock:
            raise ValueError, "Can't nest"
        self.lock = True
        if write is None:
            write = self.write
        cprt = "Type \"help", \"copyright", \"credits" or \"license\" for more information."
        if banner is None:
            write("Python %s in %s\n%s\n" % (
                sys.version,
                NSBundle.mainBundle().objectForInfoDictionaryKey_("CFBundleName"),
                cprt,
            ))
        else:
            write(banner + "\n")

        more = 0
        _buff = []
        try:
            while True:
                if more:
                    prompt = sys.ps2
                else:
                    prompt = sys.ps1
                write(prompt)
                # yield the kind of prompt we have
                yield more
                # next input function
                yield _buff.append
                more = self.push(_buff.pop())
        except:
            self.lock = False
            raise
        self.lock = False

    def resetbuffer(self):
        self.lastbuffer = self.buffer
        InteractiveConsole.resetbuffer(self)

    def runcode(self, code):
        try:
            exec code in self.locals
        except SystemExit:
            raise
        except:
            self.showtraceback()
        else:
            if softspace(sys.stdout, 0):
                print


    def recommendCompletionsFor(self, word):
        parts = word.split(".")
        if len(parts) > 1:
            # has a . so it must be a module or class or something
            # using eval, which shouldn"t normally have side effects
            # unless there"s descriptors/metaclasses doing some nasty
            # get magic
            objname = ".".join(parts[:-1])
            try:
                obj = eval(objname, self.locals)
            except:
                return None, 0
            wordlower = parts[-1].lower()
            if wordlower == "":
                # they just punched in a dot, so list all attributes
                # that don"t look private or special
                prefix = ".".join(parts[-2:])
                check = [
                    (prefix+_method)
                    for _method
                    in dir(obj)
                    if _method[:1] != "_" and _method.lower().startswith(wordlower)
                ]
            else:
                # they started typing the method name
                check = filter(lambda s:s.lower().startswith(wordlower), dir(obj))
        else:
            # no dots, must be in the normal namespaces.. no eval necessary
            check = sets.Set(dir(__builtins__))
            check.update(keyword.kwlist)
            check.update(self.locals)
            wordlower = parts[-1].lower()
            check = filter(lambda s:s.lower().startswith(wordlower), check)
        check.sort()
        return check, 0

DEBUG_DELEGATE = 0
PASSTHROUGH = (
   "deleteBackward:",
   "complete:",
   "moveRight:",
   "moveLeft:",
)

class PyInterpreter(NSObject):
    """
    PyInterpreter is a delegate/controller for a NSTextView,
    turning it into a full featured interactive Python interpreter.
    """

    textView = objc.ivar("textView")

    def initWithTextView_(self, textView):
        self = super(PyInterpreter, self).init()
        self.textView = textView
        self.textView.setDelegate_(self)
        self.awakeFromNib()
        return self

    def interpreterLocals(self):
        return self._console.locals

    #
    #  NSApplicationDelegate methods
    #

    def applicationDidFinishLaunching_(self, aNotification):
        self.textView.setFont_(self.font())
        self.textView.setContinuousSpellCheckingEnabled_(False)
        self.textView.setRichText_(False)
        self._executeWithRedirectedIO(self._interp)

    #
    #  NIB loading protocol
    #

    def awakeFromNib(self):
        self = super(PyInterpreter, self).init()
        self._font = NSFont.userFixedPitchFontOfSize_(10)
        self._stderrColor = NSColor.redColor()
        self._stdoutColor = NSColor.blueColor()
        self._codeColor = NSColor.blackColor()
        self._historyLength = 50
        self._history = [""]
        self._historyView = 0
        self._characterIndexForInput = 0
        self._stdin = PseudoUTF8Input(self._nestedRunLoopReaderUntilEOLchars_)
        #self._stdin = PseudoUTF8Input(self.readStdin)
        self._stderr = PseudoUTF8Output(self.writeStderr_)
        self._stdout = PseudoUTF8Output(self.writeStdout_)
        self._isInteracting = False
        self._console = AsyncInteractiveConsole()
        self._interp = self._console.asyncinteract(
            write=self.writeCode_,
        ).next
        self._autoscroll = True
        self.applicationDidFinishLaunching_(None)

    #
    #  Modal input dialog support
    #

    def _nestedRunLoopReaderUntilEOLchars_(self, eolchars):
        """
        This makes the baby jesus cry.

        I want co-routines.
        """
        app = NSApplication.sharedApplication()
        window = self.textView.window()
        self.setCharacterIndexForInput_(self.lengthOfTextView())
        # change the color.. eh
        self.textView.setTypingAttributes_({
            NSFontAttributeName:self.font(),
            NSForegroundColorAttributeName:self.codeColor(),
        })
        while True:
            event = app.nextEventMatchingMask_untilDate_inMode_dequeue_(
                NSAnyEventMask,
                NSDate.distantFuture(),
                NSDefaultRunLoopMode,
                True)
            if (event.type() == NSKeyDown) and (event.window() == window):
                eol = event.characters()
                if eol in eolchars:
                    break
            app.sendEvent_(event)
        cl = self.currentLine()
        if eol == "\r":
            self.writeCode_("\n")
        return cl+eol

    #
    #  Interpreter functions
    #

    def _executeWithRedirectedIO(self, fn, *args, **kwargs):
        old = sys.stdin, sys.stdout, sys.stderr
        if self._stdin is not None:
            sys.stdin = self._stdin
        sys.stdout, sys.stderr = self._stdout, self._stderr
        try:
            rval = fn(*args, **kwargs)
        finally:
            sys.stdin, sys.stdout, sys.stderr = old
            self.setCharacterIndexForInput_(self.lengthOfTextView())
        return rval

    def executeLine_(self, line):
        self.addHistoryLine_(line)
        self._executeWithRedirectedIO(self._executeLine_, line)
        self._history = filter(None, self._history)
        self._history.append("")
        self._historyView = len(self._history) - 1

    def _executeLine_(self, line):
        self._interp()(line)
        self._more = self._interp()

    def executeInteractiveLine_(self, line):
        self.setIsInteracting(True)
        try:
            self.executeLine_(line)
        finally:
            self.setIsInteracting(False)

    def replaceLineWithCode_(self, s):
        idx = self.characterIndexForInput()
        ts = self.textView.textStorage()
        ts.replaceCharactersInRange_withAttributedString_(
            (idx, len(ts.mutableString())-idx), self.codeString_(s))

    #
    #  History functions
    #

    def historyLength(self):
        return self._historyLength

    def setHistoryLength_(self, length):
        self._historyLength = length

    def addHistoryLine_(self, line):
        line = line.rstrip("\n")
        if self._history[-1] == line:
            return False
        if not line:
            return False
        self._history.append(line)
        if len(self._history) > self.historyLength():
            self._history.pop(0)
        return True

    def historyDown_(self, sender):
        if self._historyView == (len(self._history) - 1):
            return
        self._history[self._historyView] = self.currentLine()
        self._historyView += 1
        self.replaceLineWithCode_(self._history[self._historyView])
        self.moveToEndOfLine_(self)

    def historyUp_(self, sender):
        if self._historyView == 0:
            return
        self._history[self._historyView] = self.currentLine()
        self._historyView -= 1
        self.replaceLineWithCode_(self._history[self._historyView])
        self.moveToEndOfLine_(self)

    #
    #  Convenience methods to create/write decorated text
    #

    def _formatString_forOutput_(self, s, name):
        return NSAttributedString.alloc().initWithString_attributes_(
            s,
            {
                NSFontAttributeName:self.font(),
                NSForegroundColorAttributeName:getattr(self, name+"Color")(),
            },
        )

    def _writeString_forOutput_(self, s, name):
        self.textView.textStorage().appendAttributedString_(getattr(self, name+"String_")(s))

        window = self.textView.window()
        app = NSApplication.sharedApplication()
        st = time.time()
        now = time.time

        if self._autoscroll:
            self.textView.scrollRangeToVisible_((self.lengthOfTextView(), 0))

        while app.isRunning() and now() - st < 0.01:
            event = app.nextEventMatchingMask_untilDate_inMode_dequeue_(
                NSAnyEventMask,
                NSDate.dateWithTimeIntervalSinceNow_(0.01),
                NSDefaultRunLoopMode,
                True)

            if event is None:
                continue

            if (event.type() == NSKeyDown) and (event.window() == window):
                chr = event.charactersIgnoringModifiers()
                if chr == "c" and (event.modifierFlags() & NSControlKeyMask):
                    raise KeyboardInterrupt

            app.sendEvent_(event)


    codeString_   = lambda self, s: self._formatString_forOutput_(s, "code")
    stderrString_ = lambda self, s: self._formatString_forOutput_(s, "stderr")
    stdoutString_ = lambda self, s: self._formatString_forOutput_(s, "stdout")
    writeCode_    = lambda self, s: self._writeString_forOutput_(s, "code")
    writeStderr_  = lambda self, s: self._writeString_forOutput_(s, "stderr")
    writeStdout_  = lambda self, s: self._writeString_forOutput_(s, "stdout")

    #
    #  Accessors
    #

    def more(self):
        return self._more

    def font(self):
        return self._font

    def setFont_(self, font):
        self._font = font

    def stderrColor(self):
        return self._stderrColor

    def setStderrColor_(self, color):
        self._stderrColor = color

    def stdoutColor(self):
        return self._stdoutColor

    def setStdoutColor_(self, color):
        self._stdoutColor = color

    def codeColor(self):
        return self._codeColor

    def setStdoutColor_(self, color):
        self._codeColor = color

    def isInteracting(self):
        return self._isInteracting

    def setIsInteracting(self, v):
        self._isInteracting = v

    def isAutoScroll(self):
        return self._autoScroll

    def setAutoScroll(self, v):
        self._autoScroll = v


    #
    #  Convenience methods for manipulating the NSTextView
    #

    def currentLine(self):
        return self.textView.textStorage().mutableString()[self.characterIndexForInput():]

    def moveAndScrollToIndex_(self, idx):
        self.textView.scrollRangeToVisible_((idx, 0))
        self.textView.setSelectedRange_((idx, 0))

    def characterIndexForInput(self):
        return self._characterIndexForInput

    def lengthOfTextView(self):
        return len(self.textView.textStorage().mutableString())

    def setCharacterIndexForInput_(self, idx):
        self._characterIndexForInput = idx
        self.moveAndScrollToIndex_(idx)

    #
    #  NSTextViewDelegate methods
    #

    def textView_completions_forPartialWordRange_indexOfSelectedItem_(self, aTextView, completions, (begin, length), index):
        txt = self.textView.textStorage().mutableString()
        end = begin+length
        while (begin>0) and (txt[begin].isalnum() or txt[begin] in "._"):
            begin -= 1
        while not txt[begin].isalnum():
            begin += 1
        return self._console.recommendCompletionsFor(txt[begin:end])

    def textView_shouldChangeTextInRange_replacementString_(self, aTextView, aRange, newString):
        begin, length = aRange
        lastLocation = self.characterIndexForInput()
        if begin < lastLocation:
            # no editing anywhere but the interactive line
            return NO
        newString = newString.replace("\r", "\n")
        if "\n" in newString:
            if begin != lastLocation:
                # no pasting multiline unless you're at the end
                # of the interactive line
                return NO
            # multiline paste support
            #self.clearLine()
            newString = self.currentLine() + newString
            for s in newString.strip().split("\n"):
                self.writeCode_(s+"\n")
                self.executeLine_(s)
            return NO
        return YES

    def textView_willChangeSelectionFromCharacterRange_toCharacterRange_(self, aTextView, fromRange, toRange):
        return toRange
        begin, length = toRange
        if length == 0 and begin < self.characterIndexForInput():
            # no cursor movement off the interactive line
            return fromRange
        return toRange

    def textView_doCommandBySelector_(self, aTextView, aSelector):
        # deleteForward: is ctrl-d
        if self.isInteracting():
            if aSelector == "insertNewline:":
                self.writeCode_("\n")
            return NO
        responder = getattr(self, aSelector.replace(":","_"), None)
        if responder is not None:
            responder(aTextView)
            return YES
        else:
            if DEBUG_DELEGATE and aSelector not in PASSTHROUGH:
                print aSelector
            return NO

    #
    #  doCommandBySelector "posers" on the textView
    #

    def insertTabIgnoringFieldEditor_(self, sender):
        # this isn"t terribly necessary, b/c F5 and opt-esc do completion
        # but why not
        sender.complete_(self)

    def moveToBeginningOfLine_(self, sender):
        self.moveAndScrollToIndex_(self.characterIndexForInput())

    def moveToEndOfLine_(self, sender):
        self.moveAndScrollToIndex_(self.lengthOfTextView())

    def moveToBeginningOfLineAndModifySelection_(self, sender):
        begin, length = self.textView.selectedRange()
        pos = self.characterIndexForInput()
        if begin+length > pos:
            self.textView.setSelectedRange_((pos, begin+length-pos))
        else:
            self.moveToBeginningOfLine_(sender)

    def moveToEndOfLineAndModifySelection_(self, sender):
        begin, length = self.textView.selectedRange()
        pos = max(self.characterIndexForInput(), begin)
        self.textView.setSelectedRange_((pos, self.lengthOfTextView()))

    def insertNewline_(self, sender):
        line = self.currentLine()
        self.writeCode_("\n")
        self.executeInteractiveLine_(line)

    moveToBeginningOfParagraph_ = moveToBeginningOfLine_
    moveToEndOfParagraph_ = moveToEndOfLine_
    insertNewlineIgnoringFieldEditor_ = insertNewline_
    moveDown_ = historyDown_
    moveUp_ = historyUp_

class WebKitInterpreter(NSView):

    arguments = objc.ivar("arguments")
    pyInterpreter = objc.ivar("pyInterpreter")
    scrollView = objc.ivar("scrollView")
    textView = objc.ivar("textView")

    def container(self):
        return self.arguments.get("WebPluginContainer")

    def pluginViewWithArguments_(cls, arguments):
        self = super(WebKitInterpreter, cls).alloc().initWithFrame_(NSZeroRect)
        NSLog("pluginViewWithArguments:")
        NSLog(arguments)
        self.arguments = arguments
        return self
    pluginViewWithArguments_ = classmethod(pluginViewWithArguments_)

    def pluginStart(self):
        NSLog("pluginStart")
        try:
            self.doPluginStart()
        except:
            import traceback
            traceback.print_exc()

    def doPluginStart(self):
        dct = self.arguments["WebPluginAttributes"]
        w, h = [float(dct.get(k, 0)) for k in ("width", "height")]

        self.setFrame_(((0.0, 0.0), (w, h)))
        scrollView = NSScrollView.alloc().initWithFrame_(self.frame())
        scrollView.setHasVerticalScroller_(True)
        scrollView.setHasHorizontalScroller_(False)
        scrollView.setAutoresizingMask_(
            NSViewWidthSizable | NSViewHeightSizable)
        contentSize = scrollView.contentSize()
        textView = NSTextView.alloc().initWithFrame_(
            ((0, 0), scrollView.contentSize()))
        textView.setMinSize_(
            (0, contentSize.height))
        textView.setMaxSize_(
            (FLT_MAX, FLT_MAX))
        textView.setVerticallyResizable_(True)
        textView.setHorizontallyResizable_(False)
        textView.setAutoresizingMask_(NSViewWidthSizable)

        textView.textContainer().setContainerSize_(
            (contentSize.width, FLT_MAX))
        textView.textContainer().setWidthTracksTextView_(True)

        scrollView.setDocumentView_(textView)
        self.addSubview_(scrollView)

        self.pyInterpreter = PyInterpreter.alloc().initWithTextView_(
            textView)

        self.pyInterpreter.interpreterLocals()["container"] = self.container()

    def objectForWebScript(self):
        return self

NSLog("loaded WebKitInterpreter")

objc.removeAutoreleasePool()

setup.py

"""
Script for building the example.

Usage:
    python2 setup.py py2app
"""
from setuptools import setup

MIME = "application/x-pyobjc-demo-webkitinterpreter"
plist = dict(
    NSPrincipalClass="WebKitInterpreter",
    WebPluginName="WebKit PyInterpreter Plug-In",
    WebPluginDescription="PyObjC demo that embeds a Python interpreter",
    CFBundlePackageType="WBPL",
    WebPluginMIMETypes={
        MIME: dict(
            WebPluginExtensions=["webkitinterpreter"],
            WebPluginTypeDescription="WebKit PyInterpreter",
        ),
    },
)

setup(
    name="WebKitInterpreter",
    plugin = ["WebKitInterpreter.py"],
    options = dict(
        py2app=dict(
            plist=plist
        ),
    ),
    setup_requires=[
        "py2app",
        "pyobjc-framework-Cocoa",
        "pyobjc-framework-WebKit",
    ]
)

Resources