diff --git a/IPython/frontend/qt/console/ansi_code_processor.py b/IPython/frontend/qt/console/ansi_code_processor.py
new file mode 100644
index 0000000..6365d1c
--- /dev/null
+++ b/IPython/frontend/qt/console/ansi_code_processor.py
@@ -0,0 +1,131 @@
+# Standard library imports
+import re
+
+# System library imports
+from PyQt4 import QtCore, QtGui
+
+
+class AnsiCodeProcessor(object):
+ """ Translates ANSI escape codes into readable attributes.
+ """
+
+ # Protected class variables.
+ _ansi_commands = 'ABCDEFGHJKSTfmnsu'
+ _ansi_pattern = re.compile('\x01?\x1b\[(.*?)([%s])\x02?' % _ansi_commands)
+
+ def __init__(self):
+ self.reset()
+
+ def reset(self):
+ """ Reset attributs to their default values.
+ """
+ self.intensity = 0
+ self.italic = False
+ self.bold = False
+ self.underline = False
+ self.foreground_color = None
+ self.background_color = None
+
+ def split_string(self, string):
+ """ Yields substrings for which the same escape code applies.
+ """
+ start = 0
+
+ for match in self._ansi_pattern.finditer(string):
+ substring = string[start:match.start()]
+ if substring:
+ yield substring
+ start = match.end()
+
+ params = map(int, match.group(1).split(';'))
+ self.set_csi_code(match.group(2), params)
+
+ substring = string[start:]
+ if substring:
+ yield substring
+
+ def set_csi_code(self, command, params=[]):
+ """ Set attributes based on CSI (Control Sequence Introducer) code.
+
+ Parameters
+ ----------
+ command : str
+ The code identifier, i.e. the final character in the sequence.
+
+ params : sequence of integers, optional
+ The parameter codes for the command.
+ """
+ if command == 'm': # SGR - Select Graphic Rendition
+ for code in params:
+ self.set_sgr_code(code)
+
+ def set_sgr_code(self, code):
+ """ Set attributes based on SGR (Select Graphic Rendition) code.
+ """
+ if code == 0:
+ self.reset()
+ elif code == 1:
+ self.intensity = 1
+ self.bold = True
+ elif code == 2:
+ self.intensity = 0
+ elif code == 3:
+ self.italic = True
+ elif code == 4:
+ self.underline = True
+ elif code == 22:
+ self.intensity = 0
+ self.bold = False
+ elif code == 23:
+ self.italic = False
+ elif code == 24:
+ self.underline = False
+ elif code >= 30 and code <= 37:
+ self.foreground_color = code - 30
+ elif code == 39:
+ self.foreground_color = None
+ elif code >= 40 and code <= 47:
+ self.background_color = code - 40
+ elif code == 49:
+ self.background_color = None
+
+
+class QtAnsiCodeProcessor(AnsiCodeProcessor):
+ """ Translates ANSI escape codes into QTextCharFormats.
+ """
+
+ # A map from color codes to RGB colors.
+ ansi_colors = ( # Normal, Bright/Light
+ ('#000000', '#7f7f7f'), # 0: black
+ ('#cd0000', '#ff0000'), # 1: red
+ ('#00cd00', '#00ff00'), # 2: green
+ ('#cdcd00', '#ffff00'), # 3: yellow
+ ('#0000ee', '#0000ff'), # 4: blue
+ ('#cd00cd', '#ff00ff'), # 5: magenta
+ ('#00cdcd', '#00ffff'), # 6: cyan
+ ('#e5e5e5', '#ffffff')) # 7: white
+
+ def get_format(self):
+ """ Returns a QTextCharFormat that encodes the current style attributes.
+ """
+ format = QtGui.QTextCharFormat()
+
+ # Set foreground color
+ if self.foreground_color is not None:
+ color = self.ansi_colors[self.foreground_color][self.intensity]
+ format.setForeground(QtGui.QColor(color))
+
+ # Set background color
+ if self.background_color is not None:
+ color = self.ansi_colors[self.background_color][self.intensity]
+ format.setBackground(QtGui.QColor(color))
+
+ # Set font weight/style options
+ if self.bold:
+ format.setFontWeight(QtGui.QFont.Bold)
+ else:
+ format.setFontWeight(QtGui.QFont.Normal)
+ format.setFontItalic(self.italic)
+ format.setFontUnderline(self.underline)
+
+ return format
diff --git a/IPython/frontend/qt/console/completion_lexer.py b/IPython/frontend/qt/console/completion_lexer.py
index 55a9d19..abadf0c 100644
--- a/IPython/frontend/qt/console/completion_lexer.py
+++ b/IPython/frontend/qt/console/completion_lexer.py
@@ -31,7 +31,7 @@ class CompletionLexer(object):
not string.endswith('\n'):
reversed_tokens.pop(0)
- current_op = unicode()
+ current_op = ''
for token, text in reversed_tokens:
if is_token_subtype(token, Token.Name):
@@ -39,14 +39,14 @@ class CompletionLexer(object):
# Handle a trailing separator, e.g 'foo.bar.'
if current_op in self._name_separators:
if not context:
- context.insert(0, unicode())
+ context.insert(0, '')
# Handle non-separator operators and punction.
elif current_op:
break
context.insert(0, text)
- current_op = unicode()
+ current_op = ''
# Pygments doesn't understand that, e.g., '->' is a single operator
# in C++. This is why we have to build up an operator from
diff --git a/IPython/frontend/qt/console/completion_widget.py b/IPython/frontend/qt/console/completion_widget.py
index 5984daf..e2ef3c0 100644
--- a/IPython/frontend/qt/console/completion_widget.py
+++ b/IPython/frontend/qt/console/completion_widget.py
@@ -112,7 +112,7 @@ class CompletionWidget(QtGui.QListWidget):
def _update_current(self):
""" Updates the current item based on the current text.
"""
- prefix = self._current_text_cursor().selectedText()
+ prefix = self._current_text_cursor().selection().toPlainText()
if prefix:
items = self.findItems(prefix, (QtCore.Qt.MatchStartsWith |
QtCore.Qt.MatchCaseSensitive))
diff --git a/IPython/frontend/qt/console/console_widget.py b/IPython/frontend/qt/console/console_widget.py
index 12d5a62..aac9c09 100644
--- a/IPython/frontend/qt/console/console_widget.py
+++ b/IPython/frontend/qt/console/console_widget.py
@@ -1,99 +1,14 @@
# Standard library imports
-import re
import sys
# System library imports
from PyQt4 import QtCore, QtGui
# Local imports
+from ansi_code_processor import QtAnsiCodeProcessor
from completion_widget import CompletionWidget
-class AnsiCodeProcessor(object):
- """ Translates ANSI escape codes into readable attributes.
- """
-
- def __init__(self):
- self.ansi_colors = ( # Normal, Bright/Light
- ('#000000', '#7f7f7f'), # 0: black
- ('#cd0000', '#ff0000'), # 1: red
- ('#00cd00', '#00ff00'), # 2: green
- ('#cdcd00', '#ffff00'), # 3: yellow
- ('#0000ee', '#0000ff'), # 4: blue
- ('#cd00cd', '#ff00ff'), # 5: magenta
- ('#00cdcd', '#00ffff'), # 6: cyan
- ('#e5e5e5', '#ffffff')) # 7: white
- self.reset()
-
- def set_code(self, code):
- """ Set attributes based on code.
- """
- if code == 0:
- self.reset()
- elif code == 1:
- self.intensity = 1
- self.bold = True
- elif code == 3:
- self.italic = True
- elif code == 4:
- self.underline = True
- elif code == 22:
- self.intensity = 0
- self.bold = False
- elif code == 23:
- self.italic = False
- elif code == 24:
- self.underline = False
- elif code >= 30 and code <= 37:
- self.foreground_color = code - 30
- elif code == 39:
- self.foreground_color = None
- elif code >= 40 and code <= 47:
- self.background_color = code - 40
- elif code == 49:
- self.background_color = None
-
- def reset(self):
- """ Reset attributs to their default values.
- """
- self.intensity = 0
- self.italic = False
- self.bold = False
- self.underline = False
- self.foreground_color = None
- self.background_color = None
-
-
-class QtAnsiCodeProcessor(AnsiCodeProcessor):
- """ Translates ANSI escape codes into QTextCharFormats.
- """
-
- def get_format(self):
- """ Returns a QTextCharFormat that encodes the current style attributes.
- """
- format = QtGui.QTextCharFormat()
-
- # Set foreground color
- if self.foreground_color is not None:
- color = self.ansi_colors[self.foreground_color][self.intensity]
- format.setForeground(QtGui.QColor(color))
-
- # Set background color
- if self.background_color is not None:
- color = self.ansi_colors[self.background_color][self.intensity]
- format.setBackground(QtGui.QColor(color))
-
- # Set font weight/style options
- if self.bold:
- format.setFontWeight(QtGui.QFont.Bold)
- else:
- format.setFontWeight(QtGui.QFont.Normal)
- format.setFontItalic(self.italic)
- format.setFontUnderline(self.underline)
-
- return format
-
-
class ConsoleWidget(QtGui.QPlainTextEdit):
""" Base class for console-type widgets. This class is mainly concerned with
dealing with the prompt, keeping the cursor inside the editing line, and
@@ -115,7 +30,6 @@ class ConsoleWidget(QtGui.QPlainTextEdit):
override_shortcuts = False
# Protected class variables.
- _ansi_pattern = re.compile('\x01?\x1b\[(.*?)m\x02?')
_ctrl_down_remap = { QtCore.Qt.Key_B : QtCore.Qt.Key_Left,
QtCore.Qt.Key_F : QtCore.Qt.Key_Right,
QtCore.Qt.Key_A : QtCore.Qt.Key_Home,
@@ -133,14 +47,19 @@ class ConsoleWidget(QtGui.QPlainTextEdit):
def __init__(self, parent=None):
QtGui.QPlainTextEdit.__init__(self, parent)
- # Initialize protected variables.
+ # Initialize protected variables. Some variables contain useful state
+ # information for subclasses; they should be considered read-only.
self._ansi_processor = QtAnsiCodeProcessor()
self._completion_widget = CompletionWidget(self)
self._continuation_prompt = '> '
+ self._continuation_prompt_html = None
self._executing = False
self._prompt = ''
+ self._prompt_html = None
self._prompt_pos = 0
self._reading = False
+ self._reading_callback = None
+ self._tab_width = 8
# Set a monospaced font.
self.reset_font()
@@ -191,6 +110,11 @@ class ConsoleWidget(QtGui.QPlainTextEdit):
self._context_menu.exec_(event.globalPos())
+ def dragMoveEvent(self, event):
+ """ Reimplemented to disable moving text by drag and drop.
+ """
+ event.ignore()
+
def keyPressEvent(self, event):
""" Reimplemented to create a console-like interface.
"""
@@ -257,7 +181,10 @@ class ConsoleWidget(QtGui.QPlainTextEdit):
else:
if key in (QtCore.Qt.Key_Return, QtCore.Qt.Key_Enter):
if self._reading:
+ self.appendPlainText('\n')
self._reading = False
+ if self._reading_callback:
+ self._reading_callback()
elif not self._executing:
self.execute(interactive=True)
intercepted = True
@@ -303,7 +230,8 @@ class ConsoleWidget(QtGui.QPlainTextEdit):
# Line deletion (remove continuation prompt)
len_prompt = len(self._continuation_prompt)
- if cursor.columnNumber() == len_prompt and \
+ if not self._reading and \
+ cursor.columnNumber() == len_prompt and \
position != self._prompt_pos:
cursor.setPosition(position - len_prompt,
QtGui.QTextCursor.KeepAnchor)
@@ -333,24 +261,38 @@ class ConsoleWidget(QtGui.QPlainTextEdit):
# 'QPlainTextEdit' interface
#--------------------------------------------------------------------------
+ def appendHtml(self, html):
+ """ Reimplemented to not append HTML as a new paragraph, which doesn't
+ make sense for a console widget.
+ """
+ cursor = self._get_end_cursor()
+ cursor.insertHtml(html)
+
+ # After appending HTML, the text document "remembers" the current
+ # formatting, which means that subsequent calls to 'appendPlainText'
+ # will be formatted similarly, a behavior that we do not want. To
+ # prevent this, we make sure that the last character has no formatting.
+ cursor.movePosition(QtGui.QTextCursor.Left,
+ QtGui.QTextCursor.KeepAnchor)
+ if cursor.selection().toPlainText().trimmed().isEmpty():
+ # If the last character is whitespace, it doesn't matter how it's
+ # formatted, so just clear the formatting.
+ cursor.setCharFormat(QtGui.QTextCharFormat())
+ else:
+ # Otherwise, add an unformatted space.
+ cursor.movePosition(QtGui.QTextCursor.Right)
+ cursor.insertText(' ', QtGui.QTextCharFormat())
+
def appendPlainText(self, text):
""" Reimplemented to not append text as a new paragraph, which doesn't
make sense for a console widget. Also, if enabled, handle ANSI
codes.
"""
- cursor = self.textCursor()
- cursor.movePosition(QtGui.QTextCursor.End)
-
+ cursor = self._get_end_cursor()
if self.ansi_codes:
- format = QtGui.QTextCharFormat()
- previous_end = 0
- for match in self._ansi_pattern.finditer(text):
- cursor.insertText(text[previous_end:match.start()], format)
- previous_end = match.end()
- for code in match.group(1).split(';'):
- self._ansi_processor.set_code(int(code))
+ for substring in self._ansi_processor.split_string(text):
format = self._ansi_processor.get_format()
- cursor.insertText(text[previous_end:], format)
+ cursor.insertText(substring, format)
else:
cursor.insertText(text)
@@ -358,8 +300,7 @@ class ConsoleWidget(QtGui.QPlainTextEdit):
""" Reimplemented to write a new prompt. If 'keep_input' is set,
restores the old input buffer when the new prompt is written.
"""
- super(ConsoleWidget, self).clear()
-
+ QtGui.QPlainTextEdit.clear(self)
if keep_input:
input_buffer = self.input_buffer
self._show_prompt()
@@ -451,9 +392,6 @@ class ConsoleWidget(QtGui.QPlainTextEdit):
cursor = self._get_end_cursor()
cursor.setPosition(self._prompt_pos, QtGui.QTextCursor.KeepAnchor)
-
- # Use QTextDocumentFragment intermediate object because it strips
- # out the Unicode line break characters that Qt insists on inserting.
input_buffer = str(cursor.selection().toPlainText())
# Strip out continuation prompts.
@@ -462,16 +400,21 @@ class ConsoleWidget(QtGui.QPlainTextEdit):
def _set_input_buffer(self, string):
""" Replaces the text in the input buffer with 'string'.
"""
- # Add continuation prompts where necessary.
- lines = string.splitlines()
- for i in xrange(1, len(lines)):
- lines[i] = self._continuation_prompt + lines[i]
- string = '\n'.join(lines)
-
- # Replace buffer with new text.
+ # Remove old text.
cursor = self._get_end_cursor()
cursor.setPosition(self._prompt_pos, QtGui.QTextCursor.KeepAnchor)
- cursor.insertText(string)
+ cursor.removeSelectedText()
+
+ # Insert new text with continuation prompts.
+ lines = string.splitlines(True)
+ if lines:
+ self.appendPlainText(lines[0])
+ for i in xrange(1, len(lines)):
+ if self._continuation_prompt_html is None:
+ self.appendPlainText(self._continuation_prompt)
+ else:
+ self.appendHtml(self._continuation_prompt_html)
+ self.appendPlainText(lines[i])
self.moveCursor(QtGui.QTextCursor.End)
input_buffer = property(_get_input_buffer, _set_input_buffer)
@@ -484,7 +427,7 @@ class ConsoleWidget(QtGui.QPlainTextEdit):
return None
cursor = self.textCursor()
if cursor.position() >= self._prompt_pos:
- text = str(cursor.block().text())
+ text = self._get_block_plain_text(cursor.block())
if cursor.blockNumber() == self._get_prompt_cursor().blockNumber():
return text[len(self._prompt):]
else:
@@ -502,6 +445,9 @@ class ConsoleWidget(QtGui.QPlainTextEdit):
def _set_font(self, font):
""" Sets the base font for the ConsoleWidget to the specified QFont.
"""
+ font_metrics = QtGui.QFontMetrics(font)
+ self.setTabStopWidth(self.tab_width * font_metrics.width(' '))
+
self._completion_widget.setFont(font)
self.document().setDefaultFont(font)
@@ -519,6 +465,21 @@ class ConsoleWidget(QtGui.QPlainTextEdit):
font = QtGui.QFont(name, QtGui.qApp.font().pointSize())
font.setStyleHint(QtGui.QFont.TypeWriter)
self._set_font(font)
+
+ def _get_tab_width(self):
+ """ The width (in terms of space characters) for tab characters.
+ """
+ return self._tab_width
+
+ def _set_tab_width(self, tab_width):
+ """ Sets the width (in terms of space characters) for tab characters.
+ """
+ font_metrics = QtGui.QFontMetrics(self.font)
+ self.setTabStopWidth(tab_width * font_metrics.width(' '))
+
+ self._tab_width = tab_width
+
+ tab_width = property(_get_tab_width, _set_tab_width)
#---------------------------------------------------------------------------
# 'ConsoleWidget' abstract interface
@@ -569,6 +530,27 @@ class ConsoleWidget(QtGui.QPlainTextEdit):
# 'ConsoleWidget' protected interface
#--------------------------------------------------------------------------
+ def _append_html_fetching_plain_text(self, html):
+ """ Appends 'html', then returns the plain text version of it.
+ """
+ anchor = self._get_end_cursor().position()
+ self.appendHtml(html)
+ cursor = self._get_end_cursor()
+ cursor.setPosition(anchor, QtGui.QTextCursor.KeepAnchor)
+ return str(cursor.selection().toPlainText())
+
+ def _append_plain_text_keeping_prompt(self, text):
+ """ Writes 'text' after the current prompt, then restores the old prompt
+ with its old input buffer.
+ """
+ input_buffer = self.input_buffer
+ self.appendPlainText('\n')
+ self._prompt_finished()
+
+ self.appendPlainText(text)
+ self._show_prompt()
+ self.input_buffer = input_buffer
+
def _control_down(self, modifiers):
""" Given a KeyboardModifiers flags object, return whether the Control
key is down (on Mac OS, treat the Command key as a synonym for
@@ -594,8 +576,84 @@ class ConsoleWidget(QtGui.QPlainTextEdit):
if self.gui_completion:
self._completion_widget.show_items(cursor, items)
else:
- text = '\n'.join(items) + '\n'
- self._write_text_keeping_prompt(text)
+ text = self.format_as_columns(items)
+ self._append_plain_text_keeping_prompt(text)
+
+ def format_as_columns(self, items, separator=' '):
+ """ Transform a list of strings into a single string with columns.
+
+ Parameters
+ ----------
+ items : sequence [str]
+ The strings to process.
+
+ separator : str, optional [default is two spaces]
+ The string that separates columns.
+
+ Returns
+ -------
+ The formatted string.
+ """
+ # Note: this code is adapted from columnize 0.3.2.
+ # See http://code.google.com/p/pycolumnize/
+
+ font_metrics = QtGui.QFontMetrics(self.font)
+ displaywidth = max(5, (self.width() / font_metrics.width(' ')) - 1)
+
+ # Some degenerate cases
+ size = len(items)
+ if size == 0:
+ return "\n"
+ elif size == 1:
+ return '%s\n' % str(items[0])
+
+ # Try every row count from 1 upwards
+ array_index = lambda nrows, row, col: nrows*col + row
+ for nrows in range(1, size):
+ ncols = (size + nrows - 1) // nrows
+ colwidths = []
+ totwidth = -len(separator)
+ for col in range(ncols):
+ # Get max column width for this column
+ colwidth = 0
+ for row in range(nrows):
+ i = array_index(nrows, row, col)
+ if i >= size: break
+ x = items[i]
+ colwidth = max(colwidth, len(x))
+ colwidths.append(colwidth)
+ totwidth += colwidth + len(separator)
+ if totwidth > displaywidth:
+ break
+ if totwidth <= displaywidth:
+ break
+
+ # The smallest number of rows computed and the max widths for each
+ # column has been obtained. Now we just have to format each of the rows.
+ string = ''
+ for row in range(nrows):
+ texts = []
+ for col in range(ncols):
+ i = row + nrows*col
+ if i >= size:
+ texts.append('')
+ else:
+ texts.append(items[i])
+ while texts and not texts[-1]:
+ del texts[-1]
+ for col in range(len(texts)):
+ texts[col] = texts[col].ljust(colwidths[col])
+ string += "%s\n" % str(separator.join(texts))
+ return string
+
+ def _get_block_plain_text(self, block):
+ """ Given a QTextBlock, return its unformatted text.
+ """
+ cursor = QtGui.QTextCursor(block)
+ cursor.movePosition(QtGui.QTextCursor.StartOfBlock)
+ cursor.movePosition(QtGui.QTextCursor.EndOfBlock,
+ QtGui.QTextCursor.KeepAnchor)
+ return str(cursor.selection().toPlainText())
def _get_end_cursor(self):
""" Convenience method that returns a cursor for the last character.
@@ -677,6 +735,70 @@ class ConsoleWidget(QtGui.QPlainTextEdit):
self.setReadOnly(True)
self._prompt_finished_hook()
+ def _readline(self, prompt='', callback=None):
+ """ Reads one line of input from the user.
+
+ Parameters
+ ----------
+ prompt : str, optional
+ The prompt to print before reading the line.
+
+ callback : callable, optional
+ A callback to execute with the read line. If not specified, input is
+ read *synchronously* and this method does not return until it has
+ been read.
+
+ Returns
+ -------
+ If a callback is specified, returns nothing. Otherwise, returns the
+ input string with the trailing newline stripped.
+ """
+ if self._reading:
+ raise RuntimeError('Cannot read a line. Widget is already reading.')
+
+ if not callback and not self.isVisible():
+ # If the user cannot see the widget, this function cannot return.
+ raise RuntimeError('Cannot synchronously read a line if the widget'
+ 'is not visible!')
+
+ self._reading = True
+ self._show_prompt(prompt, newline=False)
+
+ if callback is None:
+ self._reading_callback = None
+ while self._reading:
+ QtCore.QCoreApplication.processEvents()
+ return self.input_buffer.rstrip('\n')
+
+ else:
+ self._reading_callback = lambda: \
+ callback(self.input_buffer.rstrip('\n'))
+
+ def _reset(self):
+ """ Clears the console and resets internal state variables.
+ """
+ QtGui.QPlainTextEdit.clear(self)
+ self._executing = self._reading = False
+
+ def _set_continuation_prompt(self, prompt, html=False):
+ """ Sets the continuation prompt.
+
+ Parameters
+ ----------
+ prompt : str
+ The prompt to show when more input is needed.
+
+ html : bool, optional (default False)
+ If set, the prompt will be inserted as formatted HTML. Otherwise,
+ the prompt will be treated as plain text, though ANSI color codes
+ will be handled.
+ """
+ if html:
+ self._continuation_prompt_html = prompt
+ else:
+ self._continuation_prompt = prompt
+ self._continuation_prompt_html = None
+
def _set_position(self, position):
""" Convenience method to set the position of the cursor.
"""
@@ -689,33 +811,60 @@ class ConsoleWidget(QtGui.QPlainTextEdit):
"""
self.setTextCursor(self._get_selection_cursor(start, end))
- def _show_prompt(self, prompt=None):
- """ Writes a new prompt at the end of the buffer. If 'prompt' is not
- specified, uses the previous prompt.
- """
- if prompt is not None:
- self._prompt = prompt
- self.appendPlainText('\n' + self._prompt)
+ def _show_prompt(self, prompt=None, html=False, newline=True):
+ """ Writes a new prompt at the end of the buffer.
+
+ Parameters
+ ----------
+ prompt : str, optional
+ The prompt to show. If not specified, the previous prompt is used.
+
+ html : bool, optional (default False)
+ Only relevant when a prompt is specified. If set, the prompt will
+ be inserted as formatted HTML. Otherwise, the prompt will be treated
+ as plain text, though ANSI color codes will be handled.
+
+ newline : bool, optional (default True)
+ If set, a new line will be written before showing the prompt if
+ there is not already a newline at the end of the buffer.
+ """
+ # Insert a preliminary newline, if necessary.
+ if newline:
+ cursor = self._get_end_cursor()
+ if cursor.position() > 0:
+ cursor.movePosition(QtGui.QTextCursor.Left,
+ QtGui.QTextCursor.KeepAnchor)
+ if str(cursor.selection().toPlainText()) != '\n':
+ self.appendPlainText('\n')
+
+ # Write the prompt.
+ if prompt is None:
+ if self._prompt_html is None:
+ self.appendPlainText(self._prompt)
+ else:
+ self.appendHtml(self._prompt_html)
+ else:
+ if html:
+ self._prompt = self._append_html_fetching_plain_text(prompt)
+ self._prompt_html = prompt
+ else:
+ self.appendPlainText(prompt)
+ self._prompt = prompt
+ self._prompt_html = None
+
self._prompt_pos = self._get_end_cursor().position()
self._prompt_started()
def _show_continuation_prompt(self):
""" Writes a new continuation prompt at the end of the buffer.
"""
- self.appendPlainText(self._continuation_prompt)
- self._prompt_started()
-
- def _write_text_keeping_prompt(self, text):
- """ Writes 'text' after the current prompt, then restores the old prompt
- with its old input buffer.
- """
- input_buffer = self.input_buffer
- self.appendPlainText('\n')
- self._prompt_finished()
+ if self._continuation_prompt_html is None:
+ self.appendPlainText(self._continuation_prompt)
+ else:
+ self._continuation_prompt = self._append_html_fetching_plain_text(
+ self._continuation_prompt_html)
- self.appendPlainText(text)
- self._show_prompt()
- self.input_buffer = input_buffer
+ self._prompt_started()
def _in_buffer(self, position):
""" Returns whether the given position is inside the editing region.
diff --git a/IPython/frontend/qt/console/frontend_widget.py b/IPython/frontend/qt/console/frontend_widget.py
index cf6ce3c..c3ae3ea 100644
--- a/IPython/frontend/qt/console/frontend_widget.py
+++ b/IPython/frontend/qt/console/frontend_widget.py
@@ -1,5 +1,6 @@
# Standard library imports
import signal
+import sys
# System library imports
from pygments.lexers import PythonLexer
@@ -15,12 +16,12 @@ from pygments_highlighter import PygmentsHighlighter
class FrontendHighlighter(PygmentsHighlighter):
- """ A Python PygmentsHighlighter that can be turned on and off and which
- knows about continuation prompts.
+ """ A PygmentsHighlighter that can be turned on and off and that ignores
+ prompts.
"""
def __init__(self, frontend):
- PygmentsHighlighter.__init__(self, frontend.document(), PythonLexer())
+ super(FrontendHighlighter, self).__init__(frontend.document())
self._current_offset = 0
self._frontend = frontend
self.highlighting_on = False
@@ -28,17 +29,32 @@ class FrontendHighlighter(PygmentsHighlighter):
def highlightBlock(self, qstring):
""" Highlight a block of text. Reimplemented to highlight selectively.
"""
- if self.highlighting_on:
- for prompt in (self._frontend._continuation_prompt,
- self._frontend._prompt):
- if qstring.startsWith(prompt):
- qstring.remove(0, len(prompt))
- self._current_offset = len(prompt)
- break
- PygmentsHighlighter.highlightBlock(self, qstring)
+ if not self.highlighting_on:
+ return
+
+ # The input to this function is unicode string that may contain
+ # paragraph break characters, non-breaking spaces, etc. Here we acquire
+ # the string as plain text so we can compare it.
+ current_block = self.currentBlock()
+ string = self._frontend._get_block_plain_text(current_block)
+
+ # Decide whether to check for the regular or continuation prompt.
+ if current_block.contains(self._frontend._prompt_pos):
+ prompt = self._frontend._prompt
+ else:
+ prompt = self._frontend._continuation_prompt
+
+ # Don't highlight the part of the string that contains the prompt.
+ if string.startswith(prompt):
+ self._current_offset = len(prompt)
+ qstring.remove(0, len(prompt))
+ else:
+ self._current_offset = 0
+
+ PygmentsHighlighter.highlightBlock(self, qstring)
def setFormat(self, start, count, format):
- """ Reimplemented to avoid highlighting continuation prompts.
+ """ Reimplemented to highlight selectively.
"""
start += self._current_offset
PygmentsHighlighter.setFormat(self, start, count, format)
@@ -47,7 +63,7 @@ class FrontendHighlighter(PygmentsHighlighter):
class FrontendWidget(HistoryConsoleWidget):
""" A Qt frontend for a generic Python kernel.
"""
-
+
# Emitted when an 'execute_reply' is received from the kernel.
executed = QtCore.pyqtSignal(object)
@@ -58,10 +74,6 @@ class FrontendWidget(HistoryConsoleWidget):
def __init__(self, parent=None):
super(FrontendWidget, self).__init__(parent)
- # ConsoleWidget protected variables.
- self._continuation_prompt = '... '
- self._prompt = '>>> '
-
# FrontendWidget protected variables.
self._call_tip_widget = CallTipWidget(self)
self._completion_lexer = CompletionLexer(PythonLexer())
@@ -70,6 +82,10 @@ class FrontendWidget(HistoryConsoleWidget):
self._input_splitter = InputSplitter(input_mode='replace')
self._kernel_manager = None
+ # Configure the ConsoleWidget.
+ self.tab_width = 4
+ self._set_continuation_prompt('... ')
+
self.document().contentsChange.connect(self._document_contents_change)
#---------------------------------------------------------------------------
@@ -103,7 +119,7 @@ class FrontendWidget(HistoryConsoleWidget):
prompt created. When triggered by an Enter/Return key press,
'interactive' is True; otherwise, it is False.
"""
- complete = self._input_splitter.push(source)
+ complete = self._input_splitter.push(source.expandtabs(4))
if interactive:
complete = not self._input_splitter.push_accepts_more()
return complete
@@ -117,18 +133,22 @@ class FrontendWidget(HistoryConsoleWidget):
def _prompt_started_hook(self):
""" Called immediately after a new prompt is displayed.
"""
- self._highlighter.highlighting_on = True
+ if not self._reading:
+ self._highlighter.highlighting_on = True
- # Auto-indent if this is a continuation prompt.
- if self._get_prompt_cursor().blockNumber() != \
- self._get_end_cursor().blockNumber():
- self.appendPlainText(' ' * self._input_splitter.indent_spaces)
+ # Auto-indent if this is a continuation prompt.
+ if self._get_prompt_cursor().blockNumber() != \
+ self._get_end_cursor().blockNumber():
+ spaces = self._input_splitter.indent_spaces
+ self.appendPlainText('\t' * (spaces / self.tab_width))
+ self.appendPlainText(' ' * (spaces % self.tab_width))
def _prompt_finished_hook(self):
""" Called immediately after a prompt is finished, i.e. when some input
will be processed and a new prompt displayed.
"""
- self._highlighter.highlighting_on = False
+ if not self._reading:
+ self._highlighter.highlighting_on = False
def _tab_pressed(self):
""" Called when the tab key is pressed. Returns whether to continue
@@ -136,9 +156,7 @@ class FrontendWidget(HistoryConsoleWidget):
"""
self._keep_cursor_in_buffer()
cursor = self.textCursor()
- if not self._complete():
- cursor.insertText(' ')
- return False
+ return not self._complete()
#---------------------------------------------------------------------------
# 'FrontendWidget' interface
@@ -161,22 +179,24 @@ class FrontendWidget(HistoryConsoleWidget):
"""
# Disconnect the old kernel manager, if necessary.
if self._kernel_manager is not None:
- self._kernel_manager.started_listening.disconnect(
- self._started_listening)
- self._kernel_manager.stopped_listening.disconnect(
- self._stopped_listening)
+ self._kernel_manager.started_channels.disconnect(
+ self._started_channels)
+ self._kernel_manager.stopped_channels.disconnect(
+ self._stopped_channels)
# Disconnect the old kernel manager's channels.
sub = self._kernel_manager.sub_channel
xreq = self._kernel_manager.xreq_channel
+ rep = self._kernel_manager.rep_channel
sub.message_received.disconnect(self._handle_sub)
xreq.execute_reply.disconnect(self._handle_execute_reply)
xreq.complete_reply.disconnect(self._handle_complete_reply)
xreq.object_info_reply.disconnect(self._handle_object_info_reply)
+ rep.readline_requested.disconnect(self._handle_req)
# Handle the case where the old kernel manager is still listening.
if self._kernel_manager.channels_running:
- self._stopped_listening()
+ self._stopped_channels()
# Set the new kernel manager.
self._kernel_manager = kernel_manager
@@ -184,21 +204,23 @@ class FrontendWidget(HistoryConsoleWidget):
return
# Connect the new kernel manager.
- kernel_manager.started_listening.connect(self._started_listening)
- kernel_manager.stopped_listening.connect(self._stopped_listening)
+ kernel_manager.started_channels.connect(self._started_channels)
+ kernel_manager.stopped_channels.connect(self._stopped_channels)
# Connect the new kernel manager's channels.
sub = kernel_manager.sub_channel
xreq = kernel_manager.xreq_channel
+ rep = kernel_manager.rep_channel
sub.message_received.connect(self._handle_sub)
xreq.execute_reply.connect(self._handle_execute_reply)
xreq.complete_reply.connect(self._handle_complete_reply)
xreq.object_info_reply.connect(self._handle_object_info_reply)
+ rep.readline_requested.connect(self._handle_req)
- # Handle the case where the kernel manager started listening before
+ # Handle the case where the kernel manager started channels before
# we connected.
if kernel_manager.channels_running:
- self._started_listening()
+ self._started_channels()
kernel_manager = property(_get_kernel_manager, _set_kernel_manager)
@@ -240,6 +262,13 @@ class FrontendWidget(HistoryConsoleWidget):
self._complete_pos = self.textCursor().position()
return True
+ def _get_banner(self):
+ """ Gets a banner to display at the beginning of a session.
+ """
+ banner = 'Python %s on %s\nType "help", "copyright", "credits" or ' \
+ '"license" for more information.'
+ return banner % (sys.version, sys.platform)
+
def _get_context(self, cursor=None):
""" Gets the context at the current cursor location.
"""
@@ -247,7 +276,7 @@ class FrontendWidget(HistoryConsoleWidget):
cursor = self.textCursor()
cursor.movePosition(QtGui.QTextCursor.StartOfLine,
QtGui.QTextCursor.KeepAnchor)
- text = unicode(cursor.selectedText())
+ text = str(cursor.selection().toPlainText())
return self._completion_lexer.get_context(text)
def _interrupt_kernel(self):
@@ -259,8 +288,26 @@ class FrontendWidget(HistoryConsoleWidget):
self.appendPlainText('Kernel process is either remote or '
'unspecified. Cannot interrupt.\n')
+ def _show_interpreter_prompt(self):
+ """ Shows a prompt for the interpreter.
+ """
+ self._show_prompt('>>> ')
+
#------ Signal handlers ----------------------------------------------------
+ def _started_channels(self):
+ """ Called when the kernel manager has started listening.
+ """
+ self._reset()
+ self.appendPlainText(self._get_banner())
+ self._show_interpreter_prompt()
+
+ def _stopped_channels(self):
+ """ Called when the kernel manager has stopped listening.
+ """
+ # FIXME: Print a message here?
+ pass
+
def _document_contents_change(self, position, removed, added):
""" Called whenever the document's content changes. Display a calltip
if appropriate.
@@ -272,6 +319,15 @@ class FrontendWidget(HistoryConsoleWidget):
if position == self.textCursor().position():
self._call_tip()
+ def _handle_req(self, req):
+ # Make sure that all output from the SUB channel has been processed
+ # before entering readline mode.
+ self.kernel_manager.sub_channel.flush()
+
+ def callback(line):
+ self.kernel_manager.rep_channel.readline(line)
+ self._readline(callback=callback)
+
def _handle_sub(self, omsg):
if self._hidden:
return
@@ -280,15 +336,13 @@ class FrontendWidget(HistoryConsoleWidget):
handler(omsg)
def _handle_pyout(self, omsg):
- session = omsg['parent_header']['session']
- if session == self.kernel_manager.session.session:
- self.appendPlainText(omsg['content']['data'] + '\n')
+ self.appendPlainText(omsg['content']['data'] + '\n')
def _handle_stream(self, omsg):
self.appendPlainText(omsg['content']['data'])
self.moveCursor(QtGui.QTextCursor.End)
- def _handle_execute_reply(self, rep):
+ def _handle_execute_reply(self, reply):
if self._hidden:
return
@@ -296,16 +350,20 @@ class FrontendWidget(HistoryConsoleWidget):
# before writing a new prompt.
self.kernel_manager.sub_channel.flush()
- content = rep['content']
- status = content['status']
+ status = reply['content']['status']
if status == 'error':
- self.appendPlainText(content['traceback'][-1])
+ self._handle_execute_error(reply)
elif status == 'aborted':
text = "ERROR: ABORTED\n"
self.appendPlainText(text)
self._hidden = True
- self._show_prompt()
- self.executed.emit(rep)
+ self._show_interpreter_prompt()
+ self.executed.emit(reply)
+
+ def _handle_execute_error(self, reply):
+ content = reply['content']
+ traceback = ''.join(content['traceback'])
+ self.appendPlainText(traceback)
def _handle_complete_reply(self, rep):
cursor = self.textCursor()
@@ -322,9 +380,3 @@ class FrontendWidget(HistoryConsoleWidget):
doc = rep['content']['docstring']
if doc:
self._call_tip_widget.show_docstring(doc)
-
- def _started_listening(self):
- self.clear()
-
- def _stopped_listening(self):
- pass
diff --git a/IPython/frontend/qt/console/ipython_widget.py b/IPython/frontend/qt/console/ipython_widget.py
index 97c580d..ec34d66 100644
--- a/IPython/frontend/qt/console/ipython_widget.py
+++ b/IPython/frontend/qt/console/ipython_widget.py
@@ -2,6 +2,7 @@
from PyQt4 import QtCore, QtGui
# Local imports
+from IPython.core.usage import default_banner
from frontend_widget import FrontendWidget
@@ -9,6 +10,15 @@ class IPythonWidget(FrontendWidget):
""" A FrontendWidget for an IPython kernel.
"""
+ # The default stylesheet for prompts, colors, etc.
+ default_stylesheet = """
+ .error { color: red; }
+ .in-prompt { color: navy; }
+ .in-prompt-number { font-weight: bold; }
+ .out-prompt { color: darkred; }
+ .out-prompt-number { font-weight: bold; }
+ """
+
#---------------------------------------------------------------------------
# 'QObject' interface
#---------------------------------------------------------------------------
@@ -16,7 +26,12 @@ class IPythonWidget(FrontendWidget):
def __init__(self, parent=None):
super(IPythonWidget, self).__init__(parent)
+ # Initialize protected variables.
self._magic_overrides = {}
+ self._prompt_count = 0
+
+ # Set a default stylesheet.
+ self.set_style_sheet(self.default_stylesheet)
#---------------------------------------------------------------------------
# 'ConsoleWidget' abstract interface
@@ -37,7 +52,7 @@ class IPythonWidget(FrontendWidget):
output = callback(arguments)
if output:
self.appendPlainText(output)
- self._show_prompt()
+ self._show_interpreter_prompt()
else:
super(IPythonWidget, self)._execute(source, hidden)
@@ -51,6 +66,56 @@ class IPythonWidget(FrontendWidget):
self.execute('run %s' % path, hidden=hidden)
#---------------------------------------------------------------------------
+ # 'FrontendWidget' protected interface
+ #---------------------------------------------------------------------------
+
+ def _get_banner(self):
+ """ Reimplemented to return IPython's default banner.
+ """
+ return default_banner
+
+ def _show_interpreter_prompt(self):
+ """ Reimplemented for IPython-style prompts.
+ """
+ self._prompt_count += 1
+ prompt_template = '%s'
+ prompt_body = '
In [%i]: '
+ prompt = (prompt_template % prompt_body) % self._prompt_count
+ self._show_prompt(prompt, html=True)
+
+ # Update continuation prompt to reflect (possibly) new prompt length.
+ cont_prompt_chars = '...: '
+ space_count = len(self._prompt.lstrip()) - len(cont_prompt_chars)
+ cont_prompt_body = ' ' * space_count + cont_prompt_chars
+ self._continuation_prompt_html = prompt_template % cont_prompt_body
+
+ #------ Signal handlers ----------------------------------------------------
+
+ def _handle_execute_error(self, reply):
+ """ Reimplemented for IPython-style traceback formatting.
+ """
+ content = reply['content']
+ traceback_lines = content['traceback'][:]
+ traceback = ''.join(traceback_lines)
+ traceback = traceback.replace(' ', ' ')
+ traceback = traceback.replace('\n', '
')
+
+ ename = content['ename']
+ ename_styled = '%s' % ename
+ traceback = traceback.replace(ename, ename_styled)
+
+ self.appendHtml(traceback)
+
+ def _handle_pyout(self, omsg):
+ """ Reimplemented for IPython-style "display hook".
+ """
+ prompt_template = '%s'
+ prompt_body = 'Out[%i]: '
+ prompt = (prompt_template % prompt_body) % self._prompt_count
+ self.appendHtml(prompt)
+ self.appendPlainText(omsg['content']['data'] + '\n')
+
+ #---------------------------------------------------------------------------
# 'IPythonWidget' interface
#---------------------------------------------------------------------------
@@ -71,32 +136,26 @@ class IPythonWidget(FrontendWidget):
except KeyError:
pass
+ def set_style_sheet(self, stylesheet):
+ """ Sets the style sheet.
+ """
+ self.document().setDefaultStyleSheet(stylesheet)
+
if __name__ == '__main__':
- import signal
from IPython.frontend.qt.kernelmanager import QtKernelManager
+ # Don't let Qt or ZMQ swallow KeyboardInterupts.
+ import signal
+ signal.signal(signal.SIGINT, signal.SIG_DFL)
+
# Create a KernelManager.
kernel_manager = QtKernelManager()
kernel_manager.start_kernel()
kernel_manager.start_channels()
- # Don't let Qt or ZMQ swallow KeyboardInterupts.
- # FIXME: Gah, ZMQ swallows even custom signal handlers. So for now we leave
- # behind a kernel process when Ctrl-C is pressed.
- #def sigint_hook(signum, frame):
- # QtGui.qApp.quit()
- #signal.signal(signal.SIGINT, sigint_hook)
- signal.signal(signal.SIGINT, signal.SIG_DFL)
-
- # Create the application, making sure to clean up nicely when we exit.
- app = QtGui.QApplication([])
- def quit_hook():
- kernel_manager.stop_channels()
- kernel_manager.kill_kernel()
- app.aboutToQuit.connect(quit_hook)
-
# Launch the application.
+ app = QtGui.QApplication([])
widget = IPythonWidget()
widget.kernel_manager = kernel_manager
widget.setWindowTitle('Python')
diff --git a/IPython/frontend/qt/console/pygments_highlighter.py b/IPython/frontend/qt/console/pygments_highlighter.py
index 51197a8..4b45dc9 100644
--- a/IPython/frontend/qt/console/pygments_highlighter.py
+++ b/IPython/frontend/qt/console/pygments_highlighter.py
@@ -1,7 +1,7 @@
# System library imports.
from PyQt4 import QtGui
from pygments.lexer import RegexLexer, _TokenType, Text, Error
-from pygments.lexers import CLexer, CppLexer, PythonLexer
+from pygments.lexers import PythonLexer
from pygments.styles.default import DefaultStyle
from pygments.token import Comment
@@ -133,7 +133,7 @@ class PygmentsHighlighter(QtGui.QSyntaxHighlighter):
if token in self._formats:
return self._formats[token]
result = None
- for key, value in self._style.style_for_token(token) .items():
+ for key, value in self._style.style_for_token(token).items():
if value:
if result is None:
result = QtGui.QTextCharFormat()
@@ -171,12 +171,11 @@ class PygmentsHighlighter(QtGui.QSyntaxHighlighter):
qcolor = self._get_color(color)
result = QtGui.QBrush(qcolor)
self._brushes[color] = result
-
return result
def _get_color(self, color):
qcolor = QtGui.QColor()
- qcolor.setRgb(int(color[:2],base=16),
+ qcolor.setRgb(int(color[:2], base=16),
int(color[2:4], base=16),
int(color[4:6], base=16))
return qcolor
diff --git a/IPython/frontend/qt/console/tests/test_ansi_code_processor.py b/IPython/frontend/qt/console/tests/test_ansi_code_processor.py
new file mode 100644
index 0000000..cad76dc
--- /dev/null
+++ b/IPython/frontend/qt/console/tests/test_ansi_code_processor.py
@@ -0,0 +1,32 @@
+# Standard library imports
+import unittest
+
+# Local imports
+from IPython.frontend.qt.console.ansi_code_processor import AnsiCodeProcessor
+
+
+class TestAnsiCodeProcessor(unittest.TestCase):
+
+ def setUp(self):
+ self.processor = AnsiCodeProcessor()
+
+ def testColors(self):
+ string = "first\x1b[34mblue\x1b[0mlast"
+ i = -1
+ for i, substring in enumerate(self.processor.split_string(string)):
+ if i == 0:
+ self.assertEquals(substring, 'first')
+ self.assertEquals(self.processor.foreground_color, None)
+ elif i == 1:
+ self.assertEquals(substring, 'blue')
+ self.assertEquals(self.processor.foreground_color, 4)
+ elif i == 2:
+ self.assertEquals(substring, 'last')
+ self.assertEquals(self.processor.foreground_color, None)
+ else:
+ self.fail("Too many substrings.")
+ self.assertEquals(i, 2, "Too few substrings.")
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/IPython/frontend/qt/kernelmanager.py b/IPython/frontend/qt/kernelmanager.py
index 0962a91..c8e27ba 100644
--- a/IPython/frontend/qt/kernelmanager.py
+++ b/IPython/frontend/qt/kernelmanager.py
@@ -17,6 +17,9 @@ from util import MetaQObjectHasTraits
# * QtCore.QObject.__init__ takes 1 argument, the parent. So if you are going
# to use super, any class that comes before QObject must pass it something
# reasonable.
+# In summary, I don't think using super in these situations will work.
+# Instead we will need to call the __init__ methods of both parents
+# by hand. Not pretty, but it works.
class QtSubSocketChannel(SubSocketChannel, QtCore.QObject):
@@ -102,6 +105,12 @@ class QtXReqSocketChannel(XReqSocketChannel, QtCore.QObject):
class QtRepSocketChannel(RepSocketChannel, QtCore.QObject):
+ # Emitted when any message is received.
+ message_received = QtCore.pyqtSignal(object)
+
+ # Emitted when a readline request is received.
+ readline_requested = QtCore.pyqtSignal(object)
+
#---------------------------------------------------------------------------
# 'object' interface
#---------------------------------------------------------------------------
@@ -112,6 +121,22 @@ class QtRepSocketChannel(RepSocketChannel, QtCore.QObject):
QtCore.QObject.__init__(self)
RepSocketChannel.__init__(self, *args, **kw)
+ #---------------------------------------------------------------------------
+ # 'RepSocketChannel' interface
+ #---------------------------------------------------------------------------
+
+ def call_handlers(self, msg):
+ """ Reimplemented to emit signals instead of making callbacks.
+ """
+ # Emit the generic signal.
+ self.message_received.emit(msg)
+
+ # Emit signals for specialized message types.
+ msg_type = msg['msg_type']
+ if msg_type == 'readline_request':
+ self.readline_requested.emit(msg)
+
+
class QtKernelManager(KernelManager, QtCore.QObject):
""" A KernelManager that provides signals and slots.
"""
@@ -119,10 +144,10 @@ class QtKernelManager(KernelManager, QtCore.QObject):
__metaclass__ = MetaQObjectHasTraits
# Emitted when the kernel manager has started listening.
- started_listening = QtCore.pyqtSignal()
+ started_channels = QtCore.pyqtSignal()
# Emitted when the kernel manager has stopped listening.
- stopped_listening = QtCore.pyqtSignal()
+ stopped_channels = QtCore.pyqtSignal()
# Use Qt-specific channel classes that emit signals.
sub_channel_class = QtSubSocketChannel
@@ -134,6 +159,16 @@ class QtKernelManager(KernelManager, QtCore.QObject):
KernelManager.__init__(self, *args, **kw)
#---------------------------------------------------------------------------
+ # 'object' interface
+ #---------------------------------------------------------------------------
+
+ def __init__(self, *args, **kw):
+ """ Reimplemented to ensure that QtCore.QObject is initialized first.
+ """
+ QtCore.QObject.__init__(self)
+ KernelManager.__init__(self, *args, **kw)
+
+ #---------------------------------------------------------------------------
# 'KernelManager' interface
#---------------------------------------------------------------------------
@@ -141,10 +176,10 @@ class QtKernelManager(KernelManager, QtCore.QObject):
""" Reimplemented to emit signal.
"""
super(QtKernelManager, self).start_channels()
- self.started_listening.emit()
+ self.started_channels.emit()
def stop_channels(self):
""" Reimplemented to emit signal.
"""
super(QtKernelManager, self).stop_channels()
- self.stopped_listening.emit()
+ self.stopped_channels.emit()
diff --git a/IPython/frontend/qt/util.py b/IPython/frontend/qt/util.py
index 9e283cf..2e0746c 100644
--- a/IPython/frontend/qt/util.py
+++ b/IPython/frontend/qt/util.py
@@ -11,7 +11,7 @@ from IPython.utils.traitlets import HasTraits
MetaHasTraits = type(HasTraits)
MetaQObject = type(QtCore.QObject)
-# You can switch the order of the parents here.
+# You can switch the order of the parents here and it doesn't seem to matter.
class MetaQObjectHasTraits(MetaQObject, MetaHasTraits):
""" A metaclass that inherits from the metaclasses of both HasTraits and
QObject.
@@ -19,9 +19,4 @@ class MetaQObjectHasTraits(MetaQObject, MetaHasTraits):
Using this metaclass allows a class to inherit from both HasTraits and
QObject. See QtKernelManager for an example.
"""
- # pass
- # ???You can get rid of this, but only if the order above is MetaQObject, MetaHasTraits
- # def __init__(cls, name, bases, dct):
- # MetaQObject.__init__(cls, name, bases, dct)
- # MetaHasTraits.__init__(cls, name, bases, dct)
-
+ pass
diff --git a/IPython/zmq/kernel.py b/IPython/zmq/kernel.py
index 683ec9b..285fd66 100755
--- a/IPython/zmq/kernel.py
+++ b/IPython/zmq/kernel.py
@@ -11,12 +11,19 @@ Things to do:
* Implement event loop and poll version.
"""
+#-----------------------------------------------------------------------------
+# Imports
+#-----------------------------------------------------------------------------
+
# Standard library imports.
import __builtin__
+from code import CommandCompiler
+from cStringIO import StringIO
+import os
import sys
+from threading import Thread
import time
import traceback
-from code import CommandCompiler
# System library imports.
import zmq
@@ -26,18 +33,109 @@ from IPython.external.argparse import ArgumentParser
from session import Session, Message, extract_header
from completer import KernelCompleter
+#-----------------------------------------------------------------------------
+# Kernel and stream classes
+#-----------------------------------------------------------------------------
+
+class InStream(object):
+ """ A file like object that reads from a 0MQ XREQ socket."""
+
+ def __init__(self, session, socket):
+ self.session = session
+ self.socket = socket
+
+ def close(self):
+ self.socket = None
+
+ def flush(self):
+ if self.socket is None:
+ raise ValueError('I/O operation on closed file')
+
+ def isatty(self):
+ return False
+
+ def next(self):
+ raise IOError('Seek not supported.')
+
+ def read(self, size=-1):
+ # FIXME: Do we want another request for this?
+ string = '\n'.join(self.readlines())
+ return self._truncate(string, size)
+
+ def readline(self, size=-1):
+ if self.socket is None:
+ raise ValueError('I/O operation on closed file')
+ else:
+ content = dict(size=size)
+ msg = self.session.msg('readline_request', content=content)
+ reply = self._request(msg)
+ line = reply['content']['line']
+ return self._truncate(line, size)
+
+ def readlines(self, sizehint=-1):
+ # Sizehint is ignored, as is permitted.
+ if self.socket is None:
+ raise ValueError('I/O operation on closed file')
+ else:
+ lines = []
+ while True:
+ line = self.readline()
+ if line:
+ lines.append(line)
+ else:
+ break
+ return lines
+
+ def seek(self, offset, whence=None):
+ raise IOError('Seek not supported.')
+
+ def write(self, string):
+ raise IOError('Write not supported on a read only stream.')
+
+ def writelines(self, sequence):
+ raise IOError('Write not supported on a read only stream.')
+
+ def _request(self, msg):
+ # Flush output before making the request. This ensures, for example,
+ # that raw_input(prompt) actually gets a prompt written.
+ sys.stderr.flush()
+ sys.stdout.flush()
+
+ self.socket.send_json(msg)
+ while True:
+ try:
+ reply = self.socket.recv_json(zmq.NOBLOCK)
+ except zmq.ZMQError, e:
+ if e.errno == zmq.EAGAIN:
+ pass
+ else:
+ raise
+ else:
+ break
+ return reply
+
+ def _truncate(self, string, size):
+ if size >= 0:
+ if isinstance(string, str):
+ return string[:size]
+ elif isinstance(string, unicode):
+ encoded = string.encode('utf-8')[:size]
+ return encoded.decode('utf-8', 'ignore')
+ return string
+
class OutStream(object):
"""A file like object that publishes the stream to a 0MQ PUB socket."""
- def __init__(self, session, pub_socket, name, max_buffer=200):
+ # The time interval between automatic flushes, in seconds.
+ flush_interval = 0.05
+
+ def __init__(self, session, pub_socket, name):
self.session = session
self.pub_socket = pub_socket
self.name = name
- self._buffer = []
- self._buffer_len = 0
- self.max_buffer = max_buffer
self.parent_header = {}
+ self._new_buffer()
def set_parent(self, parent):
self.parent_header = extract_header(parent)
@@ -49,47 +147,50 @@ class OutStream(object):
if self.pub_socket is None:
raise ValueError(u'I/O operation on closed file')
else:
- if self._buffer:
- data = ''.join(self._buffer)
+ data = self._buffer.getvalue()
+ if data:
content = {u'name':self.name, u'data':data}
msg = self.session.msg(u'stream', content=content,
parent=self.parent_header)
print>>sys.__stdout__, Message(msg)
self.pub_socket.send_json(msg)
- self._buffer_len = 0
- self._buffer = []
+
+ self._buffer.close()
+ self._new_buffer()
- def isattr(self):
+ def isatty(self):
return False
def next(self):
raise IOError('Read not supported on a write only stream.')
- def read(self, size=None):
+ def read(self, size=-1):
raise IOError('Read not supported on a write only stream.')
- readline=read
+ def readline(self, size=-1):
+ raise IOError('Read not supported on a write only stream.')
- def write(self, s):
+ def write(self, string):
if self.pub_socket is None:
raise ValueError('I/O operation on closed file')
else:
- self._buffer.append(s)
- self._buffer_len += len(s)
- self._maybe_send()
-
- def _maybe_send(self):
- if '\n' in self._buffer[-1]:
- self.flush()
- if self._buffer_len > self.max_buffer:
- self.flush()
+ self._buffer.write(string)
+ current_time = time.time()
+ if self._start <= 0:
+ self._start = current_time
+ elif current_time - self._start > self.flush_interval:
+ self.flush()
def writelines(self, sequence):
if self.pub_socket is None:
raise ValueError('I/O operation on closed file')
else:
- for s in sequence:
- self.write(s)
+ for string in sequence:
+ self.write(string)
+
+ def _new_buffer(self):
+ self._buffer = StringIO()
+ self._start = -1
class DisplayHook(object):
@@ -112,28 +213,6 @@ class DisplayHook(object):
self.parent_header = extract_header(parent)
-class RawInput(object):
-
- def __init__(self, session, socket):
- self.session = session
- self.socket = socket
-
- def __call__(self, prompt=None):
- msg = self.session.msg(u'raw_input')
- self.socket.send_json(msg)
- while True:
- try:
- reply = self.socket.recv_json(zmq.NOBLOCK)
- except zmq.ZMQError, e:
- if e.errno == zmq.EAGAIN:
- pass
- else:
- raise
- else:
- break
- return reply[u'content'][u'data']
-
-
class Kernel(object):
def __init__(self, session, reply_socket, pub_socket):
@@ -183,6 +262,7 @@ class Kernel(object):
return
pyin_msg = self.session.msg(u'pyin',{u'code':code}, parent=parent)
self.pub_socket.send_json(pyin_msg)
+
try:
comp_code = self.compiler(code, '')
sys.displayhook.set_parent(parent)
@@ -194,7 +274,7 @@ class Kernel(object):
exc_content = {
u'status' : u'error',
u'traceback' : tb,
- u'etype' : unicode(etype),
+ u'ename' : unicode(etype.__name__),
u'evalue' : unicode(evalue)
}
exc_msg = self.session.msg(u'pyerr', exc_content, parent)
@@ -202,6 +282,12 @@ class Kernel(object):
reply_content = exc_content
else:
reply_content = {'status' : 'ok'}
+
+ # Flush output before sending the reply.
+ sys.stderr.flush()
+ sys.stdout.flush()
+
+ # Send the reply.
reply_msg = self.session.msg(u'execute_reply', reply_content, parent)
print>>sys.__stdout__, Message(reply_msg)
self.reply_socket.send(ident, zmq.SNDMORE)
@@ -270,19 +356,62 @@ class Kernel(object):
else:
handler(ident, omsg)
+#-----------------------------------------------------------------------------
+# Kernel main and launch functions
+#-----------------------------------------------------------------------------
+
+class ExitPollerUnix(Thread):
+ """ A Unix-specific daemon thread that terminates the program immediately
+ when this process' parent process no longer exists.
+ """
+
+ def __init__(self):
+ super(ExitPollerUnix, self).__init__()
+ self.daemon = True
+
+ def run(self):
+ # We cannot use os.waitpid because it works only for child processes.
+ from errno import EINTR
+ while True:
+ try:
+ if os.getppid() == 1:
+ os._exit(1)
+ time.sleep(1.0)
+ except OSError, e:
+ if e.errno == EINTR:
+ continue
+ raise
+
+class ExitPollerWindows(Thread):
+ """ A Windows-specific daemon thread that terminates the program immediately
+ when a Win32 handle is signaled.
+ """
+
+ def __init__(self, handle):
+ super(ExitPollerWindows, self).__init__()
+ self.daemon = True
+ self.handle = handle
+
+ def run(self):
+ from _subprocess import WaitForSingleObject, WAIT_OBJECT_0, INFINITE
+ result = WaitForSingleObject(self.handle, INFINITE)
+ if result == WAIT_OBJECT_0:
+ os._exit(1)
+
def bind_port(socket, ip, port):
""" Binds the specified ZMQ socket. If the port is less than zero, a random
port is chosen. Returns the port that was bound.
"""
connection = 'tcp://%s' % ip
- if port < 0:
+ if port <= 0:
port = socket.bind_to_random_port(connection)
else:
connection += ':%i' % port
socket.bind(connection)
return port
+
def main():
""" Main entry point for launching a kernel.
"""
@@ -291,12 +420,21 @@ def main():
parser.add_argument('--ip', type=str, default='127.0.0.1',
help='set the kernel\'s IP address [default: local]')
parser.add_argument('--xrep', type=int, metavar='PORT', default=0,
- help='set the XREP Channel port [default: random]')
+ help='set the XREP channel port [default: random]')
parser.add_argument('--pub', type=int, metavar='PORT', default=0,
- help='set the PUB Channel port [default: random]')
+ help='set the PUB channel port [default: random]')
+ parser.add_argument('--req', type=int, metavar='PORT', default=0,
+ help='set the REQ channel port [default: random]')
+ if sys.platform == 'win32':
+ parser.add_argument('--parent', type=int, metavar='HANDLE',
+ default=0, help='kill this process if the process '
+ 'with HANDLE dies')
+ else:
+ parser.add_argument('--parent', action='store_true',
+ help='kill this process if its parent dies')
namespace = parser.parse_args()
- # Create context, session, and kernel sockets.
+ # Create a context, a session, and the kernel sockets.
print >>sys.__stdout__, "Starting the kernel..."
context = zmq.Context()
session = Session(username=u'kernel')
@@ -309,34 +447,63 @@ def main():
pub_port = bind_port(pub_socket, namespace.ip, namespace.pub)
print >>sys.__stdout__, "PUB Channel on port", pub_port
+ req_socket = context.socket(zmq.XREQ)
+ req_port = bind_port(req_socket, namespace.ip, namespace.req)
+ print >>sys.__stdout__, "REQ Channel on port", req_port
+
# Redirect input streams and set a display hook.
+ sys.stdin = InStream(session, req_socket)
sys.stdout = OutStream(session, pub_socket, u'stdout')
sys.stderr = OutStream(session, pub_socket, u'stderr')
sys.displayhook = DisplayHook(session, pub_socket)
+ # Create the kernel.
kernel = Kernel(session, reply_socket, pub_socket)
- # For debugging convenience, put sleep and a string in the namespace, so we
- # have them every time we start.
- kernel.user_ns['sleep'] = time.sleep
- kernel.user_ns['s'] = 'Test string'
-
- print >>sys.__stdout__, "Use Ctrl-\\ (NOT Ctrl-C!) to terminate."
+ # Configure this kernel/process to die on parent termination, if necessary.
+ if namespace.parent:
+ if sys.platform == 'win32':
+ poller = ExitPollerWindows(namespace.parent)
+ else:
+ poller = ExitPollerUnix()
+ poller.start()
+
+ # Start the kernel mainloop.
kernel.start()
-def launch_kernel(xrep_port=0, pub_port=0):
- """ Launches a localhost kernel, binding to the specified ports. For any
- port that is left unspecified, a port is chosen by the operating system.
- Returns a tuple of form:
- (kernel_process [Popen], rep_port [int], sub_port [int])
+def launch_kernel(xrep_port=0, pub_port=0, req_port=0, independent=False):
+ """ Launches a localhost kernel, binding to the specified ports.
+
+ Parameters
+ ----------
+ xrep_port : int, optional
+ The port to use for XREP channel.
+
+ pub_port : int, optional
+ The port to use for the SUB channel.
+
+ req_port : int, optional
+ The port to use for the REQ (raw input) channel.
+
+ independent : bool, optional (default False)
+ If set, the kernel process is guaranteed to survive if this process
+ dies. If not set, an effort is made to ensure that the kernel is killed
+ when this process dies. Note that in this case it is still good practice
+ to kill kernels manually before exiting.
+
+ Returns
+ -------
+ A tuple of form:
+ (kernel_process, xrep_port, pub_port, req_port)
+ where kernel_process is a Popen object and the ports are integers.
"""
import socket
from subprocess import Popen
# Find open ports as necessary.
ports = []
- ports_needed = int(xrep_port == 0) + int(pub_port == 0)
+ ports_needed = int(xrep_port <= 0) + int(pub_port <= 0) + int(req_port <= 0)
for i in xrange(ports_needed):
sock = socket.socket()
sock.bind(('', 0))
@@ -345,16 +512,35 @@ def launch_kernel(xrep_port=0, pub_port=0):
port = sock.getsockname()[1]
sock.close()
ports[i] = port
- if xrep_port == 0:
- xrep_port = ports.pop()
- if pub_port == 0:
- pub_port = ports.pop()
+ if xrep_port <= 0:
+ xrep_port = ports.pop(0)
+ if pub_port <= 0:
+ pub_port = ports.pop(0)
+ if req_port <= 0:
+ req_port = ports.pop(0)
# Spawn a kernel.
command = 'from IPython.zmq.kernel import main; main()'
- proc = Popen([ sys.executable, '-c', command,
- '--xrep', str(xrep_port), '--pub', str(pub_port) ])
- return proc, xrep_port, pub_port
+ arguments = [ sys.executable, '-c', command, '--xrep', str(xrep_port),
+ '--pub', str(pub_port), '--req', str(req_port) ]
+ if independent:
+ if sys.platform == 'win32':
+ proc = Popen(['start', '/b'] + arguments, shell=True)
+ else:
+ proc = Popen(arguments, preexec_fn=lambda: os.setsid())
+ else:
+ if sys.platform == 'win32':
+ from _subprocess import DuplicateHandle, GetCurrentProcess, \
+ DUPLICATE_SAME_ACCESS
+ pid = GetCurrentProcess()
+ handle = DuplicateHandle(pid, pid, pid, 0,
+ True, # Inheritable by new processes.
+ DUPLICATE_SAME_ACCESS)
+ proc = Popen(arguments + ['--parent', str(int(handle))])
+ else:
+ proc = Popen(arguments + ['--parent'])
+
+ return proc, xrep_port, pub_port, req_port
if __name__ == '__main__':
diff --git a/IPython/zmq/kernelmanager.py b/IPython/zmq/kernelmanager.py
index 2236118..fadc334 100644
--- a/IPython/zmq/kernelmanager.py
+++ b/IPython/zmq/kernelmanager.py
@@ -74,7 +74,8 @@ class ZmqSocketChannel(Thread):
self.context = context
self.session = session
if address[1] == 0:
- raise InvalidPortNumber('The port number for a channel cannot be 0.')
+ message = 'The port number for a channel cannot be 0.'
+ raise InvalidPortNumber(message)
self._address = address
def stop(self):
@@ -198,7 +199,6 @@ class XReqSocketChannel(ZmqSocketChannel):
Returns
-------
The msg_id of the message sent.
-
"""
content = dict(text=text, line=line)
msg = self.session.msg('complete_request', content)
@@ -217,7 +217,6 @@ class XReqSocketChannel(ZmqSocketChannel):
-------
The msg_id of the message sent.
"""
- print oname
content = dict(oname=oname)
msg = self.session.msg('object_info_request', content)
self._queue_request(msg)
@@ -338,15 +337,84 @@ class SubSocketChannel(ZmqSocketChannel):
class RepSocketChannel(ZmqSocketChannel):
"""A reply channel to handle raw_input requests that the kernel makes."""
- def on_raw_input(self):
- pass
+ msg_queue = None
+
+ def __init__(self, context, session, address):
+ self.msg_queue = Queue()
+ super(RepSocketChannel, self).__init__(context, session, address)
+
+ def run(self):
+ """The thread's main activity. Call start() instead."""
+ self.socket = self.context.socket(zmq.XREQ)
+ self.socket.setsockopt(zmq.IDENTITY, self.session.session)
+ self.socket.connect('tcp://%s:%i' % self.address)
+ self.ioloop = ioloop.IOLoop()
+ self.iostate = POLLERR|POLLIN
+ self.ioloop.add_handler(self.socket, self._handle_events,
+ self.iostate)
+ self.ioloop.start()
+
+ def stop(self):
+ self.ioloop.stop()
+ super(RepSocketChannel, self).stop()
+
+ def call_handlers(self, msg):
+ """This method is called in the ioloop thread when a message arrives.
+
+ Subclasses should override this method to handle incoming messages.
+ It is important to remember that this method is called in the thread
+ so that some logic must be done to ensure that the application leve
+ handlers are called in the application thread.
+ """
+ raise NotImplementedError('call_handlers must be defined in a subclass.')
+
+ def readline(self, line):
+ """A send a line of raw input to the kernel.
+
+ Parameters
+ ----------
+ line : str
+ The line of the input.
+ """
+ content = dict(line=line)
+ msg = self.session.msg('readline_reply', content)
+ self._queue_reply(msg)
+
+ def _handle_events(self, socket, events):
+ if events & POLLERR:
+ self._handle_err()
+ if events & POLLOUT:
+ self._handle_send()
+ if events & POLLIN:
+ self._handle_recv()
+
+ def _handle_recv(self):
+ msg = self.socket.recv_json()
+ self.call_handlers(msg)
+
+ def _handle_send(self):
+ try:
+ msg = self.msg_queue.get(False)
+ except Empty:
+ pass
+ else:
+ self.socket.send_json(msg)
+ if self.msg_queue.empty():
+ self.drop_io_state(POLLOUT)
+
+ def _handle_err(self):
+ # We don't want to let this go silently, so eventually we should log.
+ raise zmq.ZMQError()
+
+ def _queue_reply(self, msg):
+ self.msg_queue.put(msg)
+ self.add_io_state(POLLOUT)
#-----------------------------------------------------------------------------
# Main kernel manager class
#-----------------------------------------------------------------------------
-
class KernelManager(HasTraits):
""" Manages a kernel for a frontend.
@@ -380,6 +448,7 @@ class KernelManager(HasTraits):
def __init__(self, xreq_address=None, sub_address=None, rep_address=None,
context=None, session=None):
+ super(KernelManager, self).__init__()
self._xreq_address = (LOCALHOST, 0) if xreq_address is None else xreq_address
self._sub_address = (LOCALHOST, 0) if sub_address is None else sub_address
self._rep_address = (LOCALHOST, 0) if rep_address is None else rep_address
@@ -430,21 +499,18 @@ class KernelManager(HasTraits):
If random ports (port=0) are being used, this method must be called
before the channels are created.
"""
- xreq, sub = self.xreq_address, self.sub_address
- if xreq[0] != LOCALHOST or sub[0] != LOCALHOST:
+ xreq, sub, rep = self.xreq_address, self.sub_address, self.rep_address
+ if xreq[0] != LOCALHOST or sub[0] != LOCALHOST or rep[0] != LOCALHOST:
raise RuntimeError("Can only launch a kernel on localhost."
"Make sure that the '*_address' attributes are "
"configured properly.")
- kernel, xrep, pub = launch_kernel(xrep_port=xreq[1], pub_port=sub[1])
+ kernel, xrep, pub, req = launch_kernel(
+ xrep_port=xreq[1], pub_port=sub[1], req_port=rep[1])
self._kernel = kernel
- print xrep, pub
self._xreq_address = (LOCALHOST, xrep)
self._sub_address = (LOCALHOST, pub)
- # The rep channel is not fully working yet, but its base class makes
- # sure the port is not 0. We set to -1 for now until the rep channel
- # is fully working.
- self._rep_address = (LOCALHOST, -1)
+ self._rep_address = (LOCALHOST, req)
@property
def has_kernel(self):