From 7ecaeab501770f7ed085ee88ad6b73af2f2846f0 2010-08-09 22:13:09 From: Brian Granger Date: 2010-08-09 22:13:09 Subject: [PATCH] Merge branch 'epatters-qtfrontend' into kernelmanager Conflicts: IPython/frontend/qt/kernelmanager.py IPython/frontend/qt/util.py --- 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):