jupyter_widget.py 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615
  1. """A FrontendWidget that emulates a repl for a Jupyter kernel.
  2. This supports the additional functionality provided by Jupyter kernel.
  3. """
  4. # Copyright (c) Jupyter Development Team.
  5. # Distributed under the terms of the Modified BSD License.
  6. from collections import namedtuple
  7. import os.path
  8. import re
  9. from subprocess import Popen
  10. import sys
  11. import time
  12. from textwrap import dedent
  13. from warnings import warn
  14. from qtpy import QtCore, QtGui
  15. from IPython.lib.lexers import IPythonLexer, IPython3Lexer
  16. from pygments.lexers import get_lexer_by_name
  17. from pygments.util import ClassNotFound
  18. from qtconsole import __version__
  19. from traitlets import Bool, Unicode, observe, default
  20. from .frontend_widget import FrontendWidget
  21. from . import styles
  22. #-----------------------------------------------------------------------------
  23. # Constants
  24. #-----------------------------------------------------------------------------
  25. # Default strings to build and display input and output prompts (and separators
  26. # in between)
  27. default_in_prompt = 'In [<span class="in-prompt-number">%i</span>]: '
  28. default_out_prompt = 'Out[<span class="out-prompt-number">%i</span>]: '
  29. default_input_sep = '\n'
  30. default_output_sep = ''
  31. default_output_sep2 = ''
  32. # Base path for most payload sources.
  33. zmq_shell_source = 'ipykernel.zmqshell.ZMQInteractiveShell'
  34. if sys.platform.startswith('win'):
  35. default_editor = 'notepad'
  36. else:
  37. default_editor = ''
  38. #-----------------------------------------------------------------------------
  39. # JupyterWidget class
  40. #-----------------------------------------------------------------------------
  41. class IPythonWidget(FrontendWidget):
  42. """Dummy class for config inheritance. Destroyed below."""
  43. class JupyterWidget(IPythonWidget):
  44. """A FrontendWidget for a Jupyter kernel."""
  45. # If set, the 'custom_edit_requested(str, int)' signal will be emitted when
  46. # an editor is needed for a file. This overrides 'editor' and 'editor_line'
  47. # settings.
  48. custom_edit = Bool(False)
  49. custom_edit_requested = QtCore.Signal(object, object)
  50. editor = Unicode(default_editor, config=True,
  51. help="""
  52. A command for invoking a GUI text editor. If the string contains a
  53. {filename} format specifier, it will be used. Otherwise, the filename
  54. will be appended to the end the command. To use a terminal text editor,
  55. the command should launch a new terminal, e.g.
  56. ``"gnome-terminal -- vim"``.
  57. """)
  58. editor_line = Unicode(config=True,
  59. help="""
  60. The editor command to use when a specific line number is requested. The
  61. string should contain two format specifiers: {line} and {filename}. If
  62. this parameter is not specified, the line number option to the %edit
  63. magic will be ignored.
  64. """)
  65. style_sheet = Unicode(config=True,
  66. help="""
  67. A CSS stylesheet. The stylesheet can contain classes for:
  68. 1. Qt: QPlainTextEdit, QFrame, QWidget, etc
  69. 2. Pygments: .c, .k, .o, etc. (see PygmentsHighlighter)
  70. 3. QtConsole: .error, .in-prompt, .out-prompt, etc
  71. """)
  72. syntax_style = Unicode(config=True,
  73. help="""
  74. If not empty, use this Pygments style for syntax highlighting.
  75. Otherwise, the style sheet is queried for Pygments style
  76. information.
  77. """)
  78. # Prompts.
  79. in_prompt = Unicode(default_in_prompt, config=True)
  80. out_prompt = Unicode(default_out_prompt, config=True)
  81. input_sep = Unicode(default_input_sep, config=True)
  82. output_sep = Unicode(default_output_sep, config=True)
  83. output_sep2 = Unicode(default_output_sep2, config=True)
  84. # JupyterWidget protected class variables.
  85. _PromptBlock = namedtuple('_PromptBlock', ['block', 'length', 'number'])
  86. _payload_source_edit = 'edit_magic'
  87. _payload_source_exit = 'ask_exit'
  88. _payload_source_next_input = 'set_next_input'
  89. _payload_source_page = 'page'
  90. _retrying_history_request = False
  91. _starting = False
  92. #---------------------------------------------------------------------------
  93. # 'object' interface
  94. #---------------------------------------------------------------------------
  95. def __init__(self, *args, **kw):
  96. super(JupyterWidget, self).__init__(*args, **kw)
  97. # JupyterWidget protected variables.
  98. self._payload_handlers = {
  99. self._payload_source_edit : self._handle_payload_edit,
  100. self._payload_source_exit : self._handle_payload_exit,
  101. self._payload_source_page : self._handle_payload_page,
  102. self._payload_source_next_input : self._handle_payload_next_input }
  103. self._previous_prompt_obj = None
  104. self._keep_kernel_on_exit = None
  105. # Initialize widget styling.
  106. if self.style_sheet:
  107. self._style_sheet_changed()
  108. self._syntax_style_changed()
  109. else:
  110. self.set_default_style()
  111. # Initialize language name.
  112. self.language_name = None
  113. #---------------------------------------------------------------------------
  114. # 'BaseFrontendMixin' abstract interface
  115. #
  116. # For JupyterWidget, override FrontendWidget methods which implement the
  117. # BaseFrontend Mixin abstract interface
  118. #---------------------------------------------------------------------------
  119. def _handle_complete_reply(self, rep):
  120. """Support Jupyter's improved completion machinery.
  121. """
  122. self.log.debug("complete: %s", rep.get('content', ''))
  123. cursor = self._get_cursor()
  124. info = self._request_info.get('complete')
  125. if (info and info.id == rep['parent_header']['msg_id']
  126. and info.pos == self._get_input_buffer_cursor_pos()
  127. and info.code == self.input_buffer):
  128. content = rep['content']
  129. matches = content['matches']
  130. start = content['cursor_start']
  131. end = content['cursor_end']
  132. start = max(start, 0)
  133. end = max(end, start)
  134. # Move the control's cursor to the desired end point
  135. cursor_pos = self._get_input_buffer_cursor_pos()
  136. if end < cursor_pos:
  137. cursor.movePosition(QtGui.QTextCursor.Left,
  138. n=(cursor_pos - end))
  139. elif end > cursor_pos:
  140. cursor.movePosition(QtGui.QTextCursor.Right,
  141. n=(end - cursor_pos))
  142. # This line actually applies the move to control's cursor
  143. self._control.setTextCursor(cursor)
  144. offset = end - start
  145. # Move the local cursor object to the start of the match and
  146. # complete.
  147. cursor.movePosition(QtGui.QTextCursor.Left, n=offset)
  148. self._complete_with_items(cursor, matches)
  149. def _handle_execute_reply(self, msg):
  150. """Support prompt requests.
  151. """
  152. msg_id = msg['parent_header'].get('msg_id')
  153. info = self._request_info['execute'].get(msg_id)
  154. if info and info.kind == 'prompt':
  155. content = msg['content']
  156. if content['status'] == 'aborted':
  157. self._show_interpreter_prompt()
  158. else:
  159. number = content['execution_count'] + 1
  160. self._show_interpreter_prompt(number)
  161. self._request_info['execute'].pop(msg_id)
  162. else:
  163. super(JupyterWidget, self)._handle_execute_reply(msg)
  164. def _handle_history_reply(self, msg):
  165. """ Handle history tail replies, which are only supported
  166. by Jupyter kernels.
  167. """
  168. content = msg['content']
  169. if 'history' not in content:
  170. self.log.error("History request failed: %r"%content)
  171. if content.get('status', '') == 'aborted' and \
  172. not self._retrying_history_request:
  173. # a *different* action caused this request to be aborted, so
  174. # we should try again.
  175. self.log.error("Retrying aborted history request")
  176. # prevent multiple retries of aborted requests:
  177. self._retrying_history_request = True
  178. # wait out the kernel's queue flush, which is currently timed at 0.1s
  179. time.sleep(0.25)
  180. self.kernel_client.history(hist_access_type='tail',n=1000)
  181. else:
  182. self._retrying_history_request = False
  183. return
  184. # reset retry flag
  185. self._retrying_history_request = False
  186. history_items = content['history']
  187. self.log.debug("Received history reply with %i entries", len(history_items))
  188. items = []
  189. last_cell = u""
  190. for _, _, cell in history_items:
  191. cell = cell.rstrip()
  192. if cell != last_cell:
  193. items.append(cell)
  194. last_cell = cell
  195. self._set_history(items)
  196. def _insert_other_input(self, cursor, content, remote=True):
  197. """Insert function for input from other frontends"""
  198. n = content.get('execution_count', 0)
  199. prompt = self._make_in_prompt(n, remote=remote)
  200. cont_prompt = self._make_continuation_prompt(self._prompt, remote=remote)
  201. cursor.insertText('\n')
  202. for i, line in enumerate(content['code'].strip().split('\n')):
  203. if i == 0:
  204. self._insert_html(cursor, prompt)
  205. else:
  206. self._insert_html(cursor, cont_prompt)
  207. self._insert_plain_text(cursor, line + '\n')
  208. # Update current prompt number
  209. self._update_prompt(n + 1)
  210. def _handle_execute_input(self, msg):
  211. """Handle an execute_input message"""
  212. self.log.debug("execute_input: %s", msg.get('content', ''))
  213. if self.include_output(msg):
  214. self._append_custom(
  215. self._insert_other_input, msg['content'], before_prompt=True)
  216. elif not self._prompt:
  217. self._append_custom(
  218. self._insert_other_input, msg['content'],
  219. before_prompt=True, remote=False)
  220. def _handle_execute_result(self, msg):
  221. """Handle an execute_result message"""
  222. self.log.debug("execute_result: %s", msg.get('content', ''))
  223. if self.include_output(msg):
  224. self.flush_clearoutput()
  225. content = msg['content']
  226. prompt_number = content.get('execution_count', 0)
  227. data = content['data']
  228. if 'text/plain' in data:
  229. self._append_plain_text(self.output_sep, before_prompt=True)
  230. self._append_html(
  231. self._make_out_prompt(prompt_number, remote=not self.from_here(msg)),
  232. before_prompt=True
  233. )
  234. text = data['text/plain']
  235. # If the repr is multiline, make sure we start on a new line,
  236. # so that its lines are aligned.
  237. if "\n" in text and not self.output_sep.endswith("\n"):
  238. self._append_plain_text('\n', before_prompt=True)
  239. self._append_plain_text(text + self.output_sep2, before_prompt=True)
  240. if not self.from_here(msg):
  241. self._append_plain_text('\n', before_prompt=True)
  242. def _handle_display_data(self, msg):
  243. """The base handler for the ``display_data`` message."""
  244. # For now, we don't display data from other frontends, but we
  245. # eventually will as this allows all frontends to monitor the display
  246. # data. But we need to figure out how to handle this in the GUI.
  247. if self.include_output(msg):
  248. self.flush_clearoutput()
  249. data = msg['content']['data']
  250. metadata = msg['content']['metadata']
  251. # In the regular JupyterWidget, we simply print the plain text
  252. # representation.
  253. if 'text/plain' in data:
  254. text = data['text/plain']
  255. self._append_plain_text(text, True)
  256. # This newline seems to be needed for text and html output.
  257. self._append_plain_text(u'\n', True)
  258. def _handle_kernel_info_reply(self, rep):
  259. """Handle kernel info replies."""
  260. content = rep['content']
  261. self.language_name = content['language_info']['name']
  262. pygments_lexer = content['language_info'].get('pygments_lexer', '')
  263. try:
  264. # Other kernels with pygments_lexer info will have to be
  265. # added here by hand.
  266. if pygments_lexer == 'ipython3':
  267. lexer = IPython3Lexer()
  268. elif pygments_lexer == 'ipython2':
  269. lexer = IPythonLexer()
  270. else:
  271. lexer = get_lexer_by_name(self.language_name)
  272. self._highlighter._lexer = lexer
  273. except ClassNotFound:
  274. pass
  275. self.kernel_banner = content.get('banner', '')
  276. if self._starting:
  277. # finish handling started channels
  278. self._starting = False
  279. super(JupyterWidget, self)._started_channels()
  280. def _started_channels(self):
  281. """Make a history request"""
  282. self._starting = True
  283. self.kernel_client.kernel_info()
  284. self.kernel_client.history(hist_access_type='tail', n=1000)
  285. #---------------------------------------------------------------------------
  286. # 'FrontendWidget' protected interface
  287. #---------------------------------------------------------------------------
  288. def _process_execute_error(self, msg):
  289. """Handle an execute_error message"""
  290. self.log.debug("execute_error: %s", msg.get('content', ''))
  291. content = msg['content']
  292. traceback = '\n'.join(content['traceback']) + '\n'
  293. if False:
  294. # FIXME: For now, tracebacks come as plain text, so we can't
  295. # use the html renderer yet. Once we refactor ultratb to
  296. # produce properly styled tracebacks, this branch should be the
  297. # default
  298. traceback = traceback.replace(' ', '&nbsp;')
  299. traceback = traceback.replace('\n', '<br/>')
  300. ename = content['ename']
  301. ename_styled = '<span class="error">%s</span>' % ename
  302. traceback = traceback.replace(ename, ename_styled)
  303. self._append_html(traceback)
  304. else:
  305. # This is the fallback for now, using plain text with ansi
  306. # escapes
  307. self._append_plain_text(traceback, before_prompt=not self.from_here(msg))
  308. def _process_execute_payload(self, item):
  309. """ Reimplemented to dispatch payloads to handler methods.
  310. """
  311. handler = self._payload_handlers.get(item['source'])
  312. if handler is None:
  313. # We have no handler for this type of payload, simply ignore it
  314. return False
  315. else:
  316. handler(item)
  317. return True
  318. def _show_interpreter_prompt(self, number=None):
  319. """ Reimplemented for IPython-style prompts.
  320. """
  321. # If a number was not specified, make a prompt number request.
  322. if number is None:
  323. msg_id = self.kernel_client.execute('', silent=True)
  324. info = self._ExecutionRequest(msg_id, 'prompt')
  325. self._request_info['execute'][msg_id] = info
  326. return
  327. # Show a new prompt and save information about it so that it can be
  328. # updated later if the prompt number turns out to be wrong.
  329. self._prompt_sep = self.input_sep
  330. self._show_prompt(self._make_in_prompt(number), html=True)
  331. block = self._control.document().lastBlock()
  332. length = len(self._prompt)
  333. self._previous_prompt_obj = self._PromptBlock(block, length, number)
  334. # Update continuation prompt to reflect (possibly) new prompt length.
  335. self._set_continuation_prompt(
  336. self._make_continuation_prompt(self._prompt), html=True)
  337. def _update_prompt(self, new_prompt_number):
  338. """Replace the last displayed prompt with a new one."""
  339. if self._previous_prompt_obj is None:
  340. return
  341. block = self._previous_prompt_obj.block
  342. # Make sure the prompt block has not been erased.
  343. if block.isValid() and block.text():
  344. # Remove the old prompt and insert a new prompt.
  345. cursor = QtGui.QTextCursor(block)
  346. cursor.movePosition(QtGui.QTextCursor.Right,
  347. QtGui.QTextCursor.KeepAnchor,
  348. self._previous_prompt_obj.length)
  349. prompt = self._make_in_prompt(new_prompt_number)
  350. self._prompt = self._insert_html_fetching_plain_text(
  351. cursor, prompt)
  352. # When the HTML is inserted, Qt blows away the syntax
  353. # highlighting for the line, so we need to rehighlight it.
  354. self._highlighter.rehighlightBlock(cursor.block())
  355. # Update the prompt cursor
  356. self._prompt_cursor.setPosition(cursor.position() - 1)
  357. # Store the updated prompt.
  358. block = self._control.document().lastBlock()
  359. length = len(self._prompt)
  360. self._previous_prompt_obj = self._PromptBlock(block, length, new_prompt_number)
  361. def _show_interpreter_prompt_for_reply(self, msg):
  362. """ Reimplemented for IPython-style prompts.
  363. """
  364. # Update the old prompt number if necessary.
  365. content = msg['content']
  366. # abort replies do not have any keys:
  367. if content['status'] == 'aborted':
  368. if self._previous_prompt_obj:
  369. previous_prompt_number = self._previous_prompt_obj.number
  370. else:
  371. previous_prompt_number = 0
  372. else:
  373. previous_prompt_number = content['execution_count']
  374. if self._previous_prompt_obj and \
  375. self._previous_prompt_obj.number != previous_prompt_number:
  376. self._update_prompt(previous_prompt_number)
  377. self._previous_prompt_obj = None
  378. # Show a new prompt with the kernel's estimated prompt number.
  379. self._show_interpreter_prompt(previous_prompt_number + 1)
  380. #---------------------------------------------------------------------------
  381. # 'JupyterWidget' interface
  382. #---------------------------------------------------------------------------
  383. def set_default_style(self, colors='lightbg'):
  384. """ Sets the widget style to the class defaults.
  385. Parameters
  386. ----------
  387. colors : str, optional (default lightbg)
  388. Whether to use the default light background or dark
  389. background or B&W style.
  390. """
  391. colors = colors.lower()
  392. if colors=='lightbg':
  393. self.style_sheet = styles.default_light_style_sheet
  394. self.syntax_style = styles.default_light_syntax_style
  395. elif colors=='linux':
  396. self.style_sheet = styles.default_dark_style_sheet
  397. self.syntax_style = styles.default_dark_syntax_style
  398. elif colors=='nocolor':
  399. self.style_sheet = styles.default_bw_style_sheet
  400. self.syntax_style = styles.default_bw_syntax_style
  401. else:
  402. raise KeyError("No such color scheme: %s"%colors)
  403. #---------------------------------------------------------------------------
  404. # 'JupyterWidget' protected interface
  405. #---------------------------------------------------------------------------
  406. def _edit(self, filename, line=None):
  407. """ Opens a Python script for editing.
  408. Parameters
  409. ----------
  410. filename : str
  411. A path to a local system file.
  412. line : int, optional
  413. A line of interest in the file.
  414. """
  415. if self.custom_edit:
  416. self.custom_edit_requested.emit(filename, line)
  417. elif not self.editor:
  418. self._append_plain_text('No default editor available.\n'
  419. 'Specify a GUI text editor in the `JupyterWidget.editor` '
  420. 'configurable to enable the %edit magic')
  421. else:
  422. try:
  423. filename = '"%s"' % filename
  424. if line and self.editor_line:
  425. command = self.editor_line.format(filename=filename,
  426. line=line)
  427. else:
  428. try:
  429. command = self.editor.format()
  430. except KeyError:
  431. command = self.editor.format(filename=filename)
  432. else:
  433. command += ' ' + filename
  434. except KeyError:
  435. self._append_plain_text('Invalid editor command.\n')
  436. else:
  437. try:
  438. Popen(command, shell=True)
  439. except OSError:
  440. msg = 'Opening editor with command "%s" failed.\n'
  441. self._append_plain_text(msg % command)
  442. def _make_in_prompt(self, number, remote=False):
  443. """ Given a prompt number, returns an HTML In prompt.
  444. """
  445. try:
  446. body = self.in_prompt % number
  447. except TypeError:
  448. # allow in_prompt to leave out number, e.g. '>>> '
  449. from xml.sax.saxutils import escape
  450. body = escape(self.in_prompt)
  451. if remote:
  452. body = self.other_output_prefix + body
  453. return '<span class="in-prompt">%s</span>' % body
  454. def _make_continuation_prompt(self, prompt, remote=False):
  455. """ Given a plain text version of an In prompt, returns an HTML
  456. continuation prompt.
  457. """
  458. end_chars = '...: '
  459. space_count = len(prompt.lstrip('\n')) - len(end_chars)
  460. if remote:
  461. space_count += len(self.other_output_prefix.rsplit('\n')[-1])
  462. body = '&nbsp;' * space_count + end_chars
  463. return '<span class="in-prompt">%s</span>' % body
  464. def _make_out_prompt(self, number, remote=False):
  465. """ Given a prompt number, returns an HTML Out prompt.
  466. """
  467. try:
  468. body = self.out_prompt % number
  469. except TypeError:
  470. # allow out_prompt to leave out number, e.g. '<<< '
  471. from xml.sax.saxutils import escape
  472. body = escape(self.out_prompt)
  473. if remote:
  474. body = self.other_output_prefix + body
  475. return '<span class="out-prompt">%s</span>' % body
  476. #------ Payload handlers --------------------------------------------------
  477. # Payload handlers with a generic interface: each takes the opaque payload
  478. # dict, unpacks it and calls the underlying functions with the necessary
  479. # arguments.
  480. def _handle_payload_edit(self, item):
  481. self._edit(item['filename'], item['line_number'])
  482. def _handle_payload_exit(self, item):
  483. self._keep_kernel_on_exit = item['keepkernel']
  484. self.exit_requested.emit(self)
  485. def _handle_payload_next_input(self, item):
  486. self.input_buffer = item['text']
  487. def _handle_payload_page(self, item):
  488. # Since the plain text widget supports only a very small subset of HTML
  489. # and we have no control over the HTML source, we only page HTML
  490. # payloads in the rich text widget.
  491. data = item['data']
  492. if 'text/html' in data and self.kind == 'rich':
  493. self._page(data['text/html'], html=True)
  494. else:
  495. self._page(data['text/plain'], html=False)
  496. #------ Trait change handlers --------------------------------------------
  497. @observe('style_sheet')
  498. def _style_sheet_changed(self, changed=None):
  499. """ Set the style sheets of the underlying widgets.
  500. """
  501. self.setStyleSheet(self.style_sheet)
  502. if self._control is not None:
  503. self._control.document().setDefaultStyleSheet(self.style_sheet)
  504. if self._page_control is not None:
  505. self._page_control.document().setDefaultStyleSheet(self.style_sheet)
  506. @observe('syntax_style')
  507. def _syntax_style_changed(self, changed=None):
  508. """ Set the style for the syntax highlighter.
  509. """
  510. if self._highlighter is None:
  511. # ignore premature calls
  512. return
  513. if self.syntax_style:
  514. self._highlighter.set_style(self.syntax_style)
  515. self._ansi_processor.set_background_color(self.syntax_style)
  516. else:
  517. self._highlighter.set_style_sheet(self.style_sheet)
  518. #------ Trait default initializers -----------------------------------------
  519. @default('banner')
  520. def _banner_default(self):
  521. return "Jupyter QtConsole {version}\n".format(version=__version__)
  522. # Clobber IPythonWidget above:
  523. class IPythonWidget(JupyterWidget):
  524. """Deprecated class; use JupyterWidget."""
  525. def __init__(self, *a, **kw):
  526. warn("IPythonWidget is deprecated; use JupyterWidget",
  527. DeprecationWarning)
  528. super(IPythonWidget, self).__init__(*a, **kw)