WebKitInterpreterPlugin

A PyObjC Example without documentation

Sources

WebKitInterpreter.py

import keyword
import sys
import time
import traceback
from code import InteractiveConsole

import objc
from Cocoa import (
    NSZeroRect,
    NSControlKeyMask,
    NSViewWidthSizable,
    NSAnyEventMask,
    NSApplication,
    NSAttributedString,
    NSBundle,
    NSColor,
    NSDate,
    NSDefaultRunLoopMode,
    NSFont,
    NSFontAttributeName,
    NSForegroundColorAttributeName,
    NSKeyDown,
    NSLog,
    NSObject,
    NSView,
    NSScrollView,
    NSViewHeightSizable,
    NSTextView,
)
from objc import NO, YES, super  # noqa: A004

try:
    from code import softspace
except ImportError:
    softspace = None

FLT_MAX = 3.402_823_47e38

try:
    unicode
except NameError:
    unicode = str

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


class PseudoUTF8Output:
    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:
    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:  # noqa: E722, B001
            self.lock = False
            raise
        self.lock = False

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

    def runcode(self, code):
        try:
            exec(code, self.locals)
        except SystemExit:
            raise
        except:  # noqa: E722, B001
            self.showtraceback()
        else:
            if softspace is not None and 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:  # noqa: E722, B001
                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 = 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().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().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
    #

    @objc.python_method
    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):
                chars = event.charactersIgnoringModifiers()
                if chars == "c" and (event.modifierFlags() & NSControlKeyMask):
                    raise KeyboardInterrupt

            app.sendEvent_(event)

    def codeString_(self, s):
        return self._formatString_forOutput_(s, "code")

    def stderrString_(self, s):
        return self._formatString_forOutput_(s, "stderr")

    def stdoutString_(self, s):
        return self._formatString_forOutput_(s, "stdout")

    def writeCode_(self, s):
        return self._writeString_forOutput_(s, "code")

    def writeStderr_(self, s):
        return self._writeString_forOutput_(s, "stderr")

    def writeStdout_(self, s):
        return 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 setCodeColor_(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
    ):
        begin, length = begin_length
        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().alloc().initWithFrame_(NSZeroRect)
        NSLog("pluginViewWithArguments:")
        NSLog(arguments)
        self.arguments = arguments
        return self

    pluginViewWithArguments_ = classmethod(pluginViewWithArguments_)

    def pluginStart(self):
        NSLog("pluginStart")
        try:
            self.doPluginStart()
        except:  # noqa: E722, B001
            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 = {
    "NSPrincipalClass": "WebKitInterpreter",
    "WebPluginName": "WebKit PyInterpreter Plug-In",
    "WebPluginDescription": "PyObjC demo that embeds a Python interpreter",
    "CFBundlePackageType": "WBPL",
    "WebPluginMIMETypes": {
        "MIME": {
            "WebPluginExtensions": ["webkitinterpreter"],
            "WebPluginTypeDescription": "WebKit PyInterpreter",
        }
    },
}

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