PyInterpreter

PyInterpreter

PyInterpreter is a full featured Python interpreter in a NSTextView.

See: http://pyobjc.sourceforge.net/
http://wiki.python.org/moin/MacPython/PyInterpreter

Source for both the pyobjc module and PyInterpreter are available via the pyobjc repository.

The source of this application demonstrates - Advanced usage of NSTextView - Manual event dispatching - Text completion

Bob Ippolito bob@redivi.com

Sources

PyInterpreter.py

from __future__ import print_function
import sys
import traceback
import keyword
import time
from functools import partial
from code import InteractiveConsole
from objc import YES, NO, selector, IBAction, IBOutlet, super
from Cocoa import NSBundle, NSObject, NSTextView, NSForegroundColorAttributeName
from Cocoa import NSFont, NSColor, NSFontAttributeName, NSApplication
from Cocoa import NSUIntegerMax, NSDate, NSDefaultRunLoopMode, NSKeyDown
from Cocoa import NSAttributedString
from PyObjCTools import AppHelper

if sys.version_info[0] == 2:
    exec('''
def exec_code(code, globals):
    exec code in locals
    '''
    )
else:
    unicode=str
    import builtins
    exec_code = getattr(builtins, 'exec')

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(code, self.locals)
        except SystemExit:
            raise
        except:
            self.showtraceback()
        else:
            if sys.stdout.softspace:
                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 = list(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 = list(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 = IBOutlet()
    #
    #  Outlets - for documentation only
    #

    _NIBOutlets_ = (
        (NSTextView,    'textView',         'The interpreter'),
    )

    #
    #  NSApplicationDelegate methods
    #

    def applicationDidFinishLaunching_(self, aNotification):
        self.textView.setFont_(self.font())
        self.textView.setContinuousSpellCheckingEnabled_(False)
        self.textView.setRichText_(False)
        self._executeWithRedirectedIO_args_kwds_(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 = partial(next, self._console.asyncinteract(
            write=self.writeCode_,
        ))
        self._autoscroll = True

    #
    #  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_(
                NSUIntegerMax,
                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_args_kwds_(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_args_kwds_(self._executeLine_, (line,), {})
        self._history = list(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_(
                NSUIntegerMax,
                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, range, index):
        (begin, length) = range
        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_


if __name__ == '__main__':
    import objc
    objc.setVerbose(1)
    AppHelper.runEventLoop()

setup.py

"""
Script for building the example.

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

plist = dict(NSMainNibFile="PyInterpreter")

setup(
    name="PyInterpreter",
    app=["PyInterpreter.py"],
    data_files=["PyInterpreter.nib"],
    options=dict(py2app=dict(plist=plist)),
    setup_requires=[
        "py2app",
        "pyobjc-framework-Cocoa",
    ]
)

Resources