PyInterpreter
=============

* :download:`Download example <PyObjCExample-PyInterpreter.zip>`

A PyObjC Example without documentation

.. rst-class:: tabber

Sources
-------

.. rst-class:: tabbertab

PyInterpreter.py
................

.. sourcecode:: python

    import keyword
    import sys
    import time
    from code import InteractiveConsole
    from functools import partial
    
    from Cocoa import (
        NSApplication,
        NSAttributedString,
        NSBundle,
        NSColor,
        NSControlKeyMask,
        NSDate,
        NSDefaultRunLoopMode,
        NSFont,
        NSFontAttributeName,
        NSForegroundColorAttributeName,
        NSKeyDown,
        NSObject,
        NSTextView,
        NSUIntegerMax,
    )
    from objc import NO, YES, IBOutlet, super  # noqa: A004
    from PyObjCTools import AppHelper
    import objc
    
    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, str):
                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 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:  # 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 = 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().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):
                    char = event.charactersIgnoringModifiers()
                    if char == "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, word_range, index
        ):
            (begin, length) = word_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__":
        objc.setVerbose(1)
        AppHelper.runEventLoop()

.. rst-class:: tabbertab

setup.py
........

.. sourcecode:: python

    """
    Script for building the example.
    
    Usage:
        python3 setup.py py2app
    """
    
    from setuptools import setup
    
    plist = {"NSMainNibFile": "PyInterpreter"}
    
    setup(
        name="PyInterpreter",
        app=["PyInterpreter.py"],
        data_files=["PyInterpreter.nib"],
        options={"py2app": {"plist": plist}},
        setup_requires=["py2app", "pyobjc-framework-Cocoa"],
    )

