pw_console: ptpython repl, execution and display
Enables a ptpython based repl in the ReplPane. User repl code is
executed in it's own thread with stdout and stderr patched to
capture output.
Change-Id: I005481f1ecd6805ce9a74fbf57ff0f2317b9d2aa
Reviewed-on: https://pigweed-review.googlesource.com/c/pigweed/pigweed/+/48960
Pigweed-Auto-Submit: Anthony DiGirolamo <tonymd@google.com>
Commit-Queue: Auto-Submit <auto-submit@pigweed.google.com.iam.gserviceaccount.com>
Reviewed-by: Joe Ethier <jethier@google.com>
diff --git a/pw_console/docs.rst b/pw_console/docs.rst
index 0f41a3a..51f1bdd 100644
--- a/pw_console/docs.rst
+++ b/pw_console/docs.rst
@@ -5,22 +5,34 @@
----------
The Pigweed Console provides a Python repl (read eval print loop) using
-`ptpython <https://github.com/prompt-toolkit/ptpython>`_ and a log message
-viewer in a single-window terminal based interface. It is designed to be a
-replacement for
-`IPython's embed() <https://ipython.readthedocs.io/en/stable/interactive/reference.html#embedding>`_
-function.
+`ptpython`_ and a log message viewer in a single-window terminal based
+interface. It is designed to be a replacement for `IPython's embed()`_ function.
+
+.. warning::
+ The Pigweed Console is under heavy development. A user manual and usage
+ information will be documented as features near completion.
==========
-Motivation
+Goals
==========
-``pw_console`` is the complete solution for interacting with hardware devices
+``pw_console`` is a complete solution for interacting with hardware devices
using :ref:`module-pw_rpc` over a :ref:`module-pw_hdlc` transport.
The repl allows interactive RPC sending while the log viewer provides immediate
feedback on device status.
+- Interactive Python repl and log viewer in a single terminal window.
+
+- Easily embeddable within a project's own custom console. This should allow
+ users to define their own transport layer.
+
+- Plugin framework to add custom status toolbars or window panes.
+
+- Log viewer with searching and filtering.
+
+- Daemon that provides a many-to-many mapping between consoles and devices.
+
=====
Usage
=====
@@ -42,7 +54,18 @@
Thread and Event Loop Design
----------------------------
-Here's a diagram showing how ``pw_console`` threads and asyncio tasks are organized.
+In `ptpython`_ and `IPython`_ all user repl code is run in the foreground. This
+allows interrupts like ``Ctrl-C`` and functions like ``print()`` and
+``time.sleep()`` to work as expected. Pigweed's Console doesn't use this
+approach as it would hide or freeze the `prompt_toolkit`_ user interface while
+running repl code.
+
+To get around this issue all user repl code is run in a dedicated thread with
+stdout and stderr patched to capture output. This lets the user interface stay
+responsive and new log messages to continue to be displayed.
+
+Here's a diagram showing how ``pw_console`` threads and `asyncio`_ tasks are
+organized.
.. mermaid::
@@ -96,3 +119,9 @@
repl-.->|Run Code| replThread
pluginToolbar-.->|Register Plugin| pluginThread
pluginPane-.->|Register Plugin| pluginThread2
+
+.. _IPython's embed(): https://ipython.readthedocs.io/en/stable/interactive/reference.html#embedding
+.. _IPython: https://ipython.readthedocs.io/
+.. _asyncio: https://docs.python.org/3/library/asyncio.html
+.. _prompt_toolkit: https://python-prompt-toolkit.readthedocs.io/
+.. _ptpython: https://github.com/prompt-toolkit/ptpython/
diff --git a/pw_console/py/BUILD.gn b/pw_console/py/BUILD.gn
index 98ec217..10865c2 100644
--- a/pw_console/py/BUILD.gn
+++ b/pw_console/py/BUILD.gn
@@ -23,14 +23,17 @@
"pw_console/__main__.py",
"pw_console/console_app.py",
"pw_console/help_window.py",
+ "pw_console/helpers.py",
"pw_console/key_bindings.py",
"pw_console/log_pane.py",
+ "pw_console/pw_ptpython_repl.py",
"pw_console/repl_pane.py",
"pw_console/style.py",
]
tests = [
"console_app_test.py",
"help_window_test.py",
+ "repl_pane_test.py",
]
python_deps = [
"$dir_pw_cli/py",
diff --git a/pw_console/py/help_window_test.py b/pw_console/py/help_window_test.py
index 55feb3d..d7352b6 100644
--- a/pw_console/py/help_window_test.py
+++ b/pw_console/py/help_window_test.py
@@ -23,7 +23,7 @@
class TestHelpWindow(unittest.TestCase):
- """Tests for ConsoleApp."""
+ """Tests for HelpWindow text and keybind lists."""
def setUp(self):
self.maxDiff = None # pylint: disable=invalid-name
diff --git a/pw_console/py/pw_console/console_app.py b/pw_console/py/pw_console/console_app.py
index ae50e93..d9444f5 100644
--- a/pw_console/py/pw_console/console_app.py
+++ b/pw_console/py/pw_console/console_app.py
@@ -38,10 +38,12 @@
MenuItem,
)
from prompt_toolkit.key_binding import merge_key_bindings
+from ptpython.key_bindings import load_python_bindings # type: ignore
from pw_console.help_window import HelpWindow
from pw_console.key_bindings import create_key_bindings
from pw_console.log_pane import LogPane
+from pw_console.pw_ptpython_repl import PwPtPythonRepl
from pw_console.repl_pane import ReplPane
from pw_console.style import pw_console_styles
@@ -94,9 +96,19 @@
self.show_help_window = False
self.vertical_split = False
- # Create one log pane and the repl pane.
+ # Create one log pane.
self.log_pane = LogPane(application=self)
- self.repl_pane = ReplPane(application=self)
+
+ # Create a ptpython repl instance.
+ self.pw_ptpython_repl = PwPtPythonRepl(
+ get_globals=lambda: global_vars,
+ get_locals=lambda: local_vars,
+ )
+
+ self.repl_pane = ReplPane(
+ application=self,
+ python_repl=self.pw_ptpython_repl,
+ )
# List of enabled panes.
self.active_panes = [
@@ -162,14 +174,14 @@
layout=self.layout,
after_render=self.run_after_render_hooks,
key_bindings=merge_key_bindings([
- # TODO: pull key bindings from ptpython
- # load_python_bindings(self.pw_ptpython_repl),
+ # Pull key bindings from ptpython
+ load_python_bindings(self.pw_ptpython_repl),
self.key_bindings,
]),
style=DynamicStyle(lambda: merge_styles([
pw_console_styles,
- # TODO: Include ptpython styles
- # self.pw_ptpython_repl._current_style
+ # Include ptpython styles
+ self.pw_ptpython_repl._current_style, # pylint: disable=protected-access
])),
enable_page_navigation_bindings=True,
full_screen=True,
diff --git a/pw_console/py/pw_console/helpers.py b/pw_console/py/pw_console/helpers.py
new file mode 100644
index 0000000..484009b
--- /dev/null
+++ b/pw_console/py/pw_console/helpers.py
@@ -0,0 +1,19 @@
+# Copyright 2021 The Pigweed Authors
+#
+# Licensed under the Apache License, Version 2.0 (the "License"); you may not
+# use this file except in compliance with the License. You may obtain a copy of
+# the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+# License for the specific language governing permissions and limitations under
+# the License.
+"""Helper functions."""
+
+
+def remove_formatting(formatted_text):
+ """Throw away style info from formatted text tuples."""
+ return ''.join([formatted_tuple[1] for formatted_tuple in formatted_text]) # pylint: disable=not-an-iterable
diff --git a/pw_console/py/pw_console/key_bindings.py b/pw_console/py/pw_console/key_bindings.py
index 3579d17..2e527b4 100644
--- a/pw_console/py/pw_console/key_bindings.py
+++ b/pw_console/py/pw_console/key_bindings.py
@@ -56,6 +56,8 @@
@bindings.add('c-q')
def exit_(event):
"""Quit the console application."""
+ # TODO(tonymd): Cancel any existing user repl or plugin tasks before
+ # exiting.
event.app.exit()
@bindings.add('s-tab')
@@ -71,4 +73,17 @@
"""Move focus to the previous widget."""
focus_previous(event)
+ # Bindings for when the ReplPane input field is in focus.
+ @bindings.add('c-c', filter=has_focus(console_app.pw_ptpython_repl))
+ def handle_ctrl_c(event):
+ """Reset the python repl on Ctrl-c"""
+ console_app.repl_pane.ctrl_c()
+
+ @bindings.add('c-d', filter=has_focus(console_app.pw_ptpython_repl))
+ def handle_ctrl_d(event):
+ """Do nothing on ctrl-d."""
+ # TODO(tonymd): Allow ctrl-d to quit the whole app with confirmation
+ # like ipython.
+ pass
+
return bindings
diff --git a/pw_console/py/pw_console/pw_ptpython_repl.py b/pw_console/py/pw_console/pw_ptpython_repl.py
new file mode 100644
index 0000000..bcc9fc1
--- /dev/null
+++ b/pw_console/py/pw_console/pw_ptpython_repl.py
@@ -0,0 +1,185 @@
+# Copyright 2021 The Pigweed Authors
+#
+# Licensed under the Apache License, Version 2.0 (the "License"); you may not
+# use this file except in compliance with the License. You may obtain a copy of
+# the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+# License for the specific language governing permissions and limitations under
+# the License.
+"""PwPtPythonPane class."""
+
+import asyncio
+import logging
+import io
+import sys
+from functools import partial
+from pathlib import Path
+
+from prompt_toolkit.buffer import Buffer
+import ptpython.repl # type: ignore
+
+from pw_console.helpers import remove_formatting
+
+_LOG = logging.getLogger(__package__)
+
+
+class PwPtPythonRepl(ptpython.repl.PythonRepl):
+ """A ptpython repl class with changes to code execution and output related
+ methods."""
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args,
+ create_app=False,
+ history_filename=(Path.home() /
+ '.pw_console_history').as_posix(),
+ color_depth='256 colors',
+ _input_buffer_height=8,
+ **kwargs)
+ # Change some ptpython.repl defaults.
+ self.use_code_colorscheme('zenburn')
+ self.show_status_bar = False
+ self.show_exit_confirmation = False
+ self.complete_private_attributes = False
+
+ # Additional state variables.
+ self.repl_pane = None
+ self._last_result = None
+
+ def __pt_container__(self):
+ """Return the prompt_toolkit root container for class."""
+ return self.ptpython_layout.root_container
+
+ def set_repl_pane(self, repl_pane):
+ """Update the parent pw_console.ReplPane reference."""
+ self.repl_pane = repl_pane
+
+ def _save_result(self, formatted_text):
+ """Save the last repl execution result."""
+ unformatted_result = remove_formatting(formatted_text)
+ self._last_result = unformatted_result
+
+ def clear_last_result(self):
+ """Erase the last repl execution result."""
+ self._last_result = None
+
+ def _update_output_buffer(self):
+ self.repl_pane.update_output_buffer()
+
+ def show_result(self, result):
+ """Format and save output results.
+
+ This function is called from the _run_user_code() function which is
+ always run from the user code thread, within
+ .run_and_show_expression_async().
+ """
+ formatted_result = self._format_result_output(result)
+ self._save_result(formatted_result)
+
+ def _handle_exception(self, e: BaseException) -> None:
+ """Format and save output results.
+
+ This function is called from the _run_user_code() function which is
+ always run from the user code thread, within
+ .run_and_show_expression_async().
+ """
+ formatted_result = self._format_exception_output(e)
+ self._save_result(formatted_result.__pt_formatted_text__())
+
+ def user_code_complete_callback(self, input_text, future):
+ """Callback to run after user repl code is finished."""
+ # If there was an exception it will be saved in self._last_result
+ result = self._last_result
+ # _last_result consumed, erase for the next run.
+ self.clear_last_result()
+
+ stdout_contents = None
+ stderr_contents = None
+ if future.result():
+ future_result = future.result()
+ stdout_contents = future_result['stdout']
+ stderr_contents = future_result['stderr']
+ result_value = future_result['result']
+
+ if result_value is not None:
+ formatted_result = self._format_result_output(result_value)
+ result = remove_formatting(formatted_result)
+
+ # Job is finished, append the last result.
+ self.repl_pane.append_result_to_executed_code(input_text, future,
+ result, stdout_contents,
+ stderr_contents)
+
+ # Rebuild output buffer.
+ self._update_output_buffer()
+
+ # Trigger a prompt_toolkit application redraw.
+ self.repl_pane.application.application.invalidate()
+
+ async def _run_user_code(self, text):
+ """Run user code and capture stdout+err.
+
+ This fuction should be run in a separate thread from the main
+ prompt_toolkit application."""
+ # NOTE: This function runs in a separate thread using the asyncio event
+ # loop defined by self.repl_pane.application.user_code_loop. Patching
+ # stdout here will not effect the stdout used by prompt_toolkit and the
+ # main user interface.
+
+ # Patch stdout and stderr to capture repl print() statements.
+ original_stdout = sys.stdout
+ original_stderr = sys.stderr
+
+ temp_out = io.StringIO()
+ temp_err = io.StringIO()
+
+ sys.stdout = temp_out
+ sys.stderr = temp_err
+
+ # Run user repl code
+ try:
+ result = await self.run_and_show_expression_async(text)
+ finally:
+ # Always restore original stdout and stderr
+ sys.stdout = original_stdout
+ sys.stderr = original_stderr
+
+ # Save the captured output
+ stdout_contents = temp_out.getvalue()
+ stderr_contents = temp_err.getvalue()
+
+ return {
+ 'stdout': stdout_contents,
+ 'stderr': stderr_contents,
+ 'result': result
+ }
+
+ def _accept_handler(self, buff: Buffer) -> bool:
+ """Function executed when pressing enter in the ptpython.repl.PythonRepl
+ input buffer."""
+ # Do nothing if no text is entered.
+ if len(buff.text) == 0:
+ return False
+
+ # Execute the repl code in the the separate user_code thread loop.
+ future = asyncio.run_coroutine_threadsafe(
+ # This function will be executed in a separate thread.
+ self._run_user_code(buff.text),
+ # Using this asyncio event loop.
+ self.repl_pane.application.user_code_loop)
+ # Run user_code_complete_callback() when done.
+ done_callback = partial(self.user_code_complete_callback, buff.text)
+ future.add_done_callback(done_callback)
+
+ # Save the input text and future object.
+ self.repl_pane.append_executed_code(buff.text, future)
+
+ # Rebuild the parent ReplPane output buffer.
+ self._update_output_buffer()
+
+ # TODO: Return True if exception is found?
+ # Don't keep input for now. Return True to keep input text.
+ return False
diff --git a/pw_console/py/pw_console/repl_pane.py b/pw_console/py/pw_console/repl_pane.py
index 5d301ca..86ac717 100644
--- a/pw_console/py/pw_console/repl_pane.py
+++ b/pw_console/py/pw_console/repl_pane.py
@@ -13,8 +13,11 @@
# the License.
"""ReplPane class."""
+import concurrent
import logging
+from dataclasses import dataclass
from functools import partial
+from pathlib import Path
from typing import (
Any,
Callable,
@@ -23,10 +26,12 @@
Optional,
)
+from jinja2 import Template
from prompt_toolkit.filters import (
Condition,
has_focus,
)
+from prompt_toolkit.document import Document
from prompt_toolkit.mouse_events import MouseEvent, MouseEventType
from prompt_toolkit.layout.dimension import AnyDimension
from prompt_toolkit.widgets import TextArea
@@ -44,11 +49,18 @@
from prompt_toolkit.lexers import PygmentsLexer # type: ignore
from pygments.lexers.python import PythonLexer # type: ignore
+from pw_console.pw_ptpython_repl import PwPtPythonRepl
+
_LOG = logging.getLogger(__package__)
_Namespace = Dict[str, Any]
_GetNamespace = Callable[[], _Namespace]
+_OUTPUT_TEMPLATE_PATH = (Path(__file__).parent / 'templates' /
+ 'repl_output.jinja')
+with _OUTPUT_TEMPLATE_PATH.open() as tmpl:
+ OUTPUT_TEMPLATE = tmpl.read()
+
def mouse_focus_handler(repl_pane, mouse_event: MouseEvent):
"""Focus the repl_pane on click."""
@@ -89,9 +101,9 @@
"""Return toolbar text showing if the ReplPane is in focus or not."""
focused_text = (
# Style
- "",
+ '',
# Text
- " [FOCUSED] ",
+ ' [FOCUSED] ',
# Mouse handler
partial(mouse_focus_handler, repl_pane),
)
@@ -171,6 +183,20 @@
filter=Condition(lambda: repl_pane.show_bottom_toolbar))
+@dataclass
+class UserCodeExecution:
+ """Class to hold a single user repl execution event."""
+ input: str
+ future: concurrent.futures.Future
+ output: str
+ stdout: str
+ stderr: str
+
+ @property
+ def is_running(self):
+ return not self.future.done()
+
+
class ReplPane:
"""Pane for reading Python input."""
@@ -178,8 +204,7 @@
def __init__(
self,
application: Any,
- # TODO: Include ptpython repl.
- # python_repl: PwPtPythonRepl,
+ python_repl: PwPtPythonRepl,
# TODO: Make the height of input+output windows match the log pane
# height. (Using minimum output height of 5 for now).
output_height: Optional[AnyDimension] = Dimension(preferred=5),
@@ -197,13 +222,10 @@
self.show_top_toolbar = True
self.show_bottom_toolbar = True
- # TODO: Include ptpython repl.
- self.pw_ptpython_repl = Window(content=FormattedTextControl(
- [('', '>>> Repl input buffer')], focusable=True))
- self.last_error_output = ""
+ self.pw_ptpython_repl = python_repl
+ self.pw_ptpython_repl.set_repl_pane(self)
self.output_field = TextArea(
- text='Repl output buffer',
style='class:output-field',
height=output_height,
# text=help_text,
@@ -265,3 +287,75 @@
def after_render_hook(self):
"""Run tasks after the last UI render."""
+
+ def ctrl_c(self):
+ """Ctrl-C keybinding behavior."""
+ # If there is text in the input buffer, clear it.
+ if self.pw_ptpython_repl.default_buffer.text:
+ self.clear_input_buffer()
+ else:
+ self.interrupt_last_code_execution()
+
+ def clear_input_buffer(self):
+ self.pw_ptpython_repl.default_buffer.reset()
+
+ def interrupt_last_code_execution(self):
+ code = self._get_currently_running_code()
+ if code:
+ code.future.cancel()
+ code.output = 'Canceled'
+ self.pw_ptpython_repl.clear_last_result()
+ self.update_output_buffer()
+
+ def _get_currently_running_code(self):
+ for code in self.executed_code:
+ if not code.future.done():
+ return code
+ return None
+
+ def _get_executed_code(self, future):
+ for code in self.executed_code:
+ if code.future == future:
+ return code
+ return None
+
+ def _log_executed_code(self, code, prefix=''):
+ text = self.get_output_buffer_text([code], show_index=False)
+ _LOG.info('[PYTHON] %s\n%s', prefix, text)
+
+ def append_executed_code(self, text, future):
+ user_code = UserCodeExecution(input=text,
+ future=future,
+ output=None,
+ stdout=None,
+ stderr=None)
+ self.executed_code.append(user_code)
+ self._log_executed_code(user_code, prefix='START')
+
+ def append_result_to_executed_code(self,
+ _input_text,
+ future,
+ result_text,
+ stdout_text='',
+ stderr_text=''):
+ code = self._get_executed_code(future)
+ if code:
+ code.output = result_text
+ code.stdout = stdout_text
+ code.stderr = stderr_text
+ self._log_executed_code(code, prefix='FINISH')
+ self.update_output_buffer()
+
+ def get_output_buffer_text(self, code_items=None, show_index=True):
+ executed_code = code_items or self.executed_code
+ template = Template(OUTPUT_TEMPLATE,
+ trim_blocks=True,
+ lstrip_blocks=True)
+ return template.render(code_items=executed_code,
+ show_index=show_index).strip()
+
+ def update_output_buffer(self):
+ text = self.get_output_buffer_text()
+ self.output_field.buffer.document = Document(text=text,
+ cursor_position=len(text))
+ self.application.redraw_ui()
diff --git a/pw_console/py/pw_console/templates/repl_output.jinja b/pw_console/py/pw_console/templates/repl_output.jinja
new file mode 100644
index 0000000..1ccf272
--- /dev/null
+++ b/pw_console/py/pw_console/templates/repl_output.jinja
@@ -0,0 +1,34 @@
+{#
+Copyright 2021 The Pigweed Authors
+
+Licensed under the Apache License, Version 2.0 (the "License"); you may not
+use this file except in compliance with the License. You may obtain a copy of
+the License at
+
+ https://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+License for the specific language governing permissions and limitations under
+the License.
+#}
+{% for code in code_items %}
+{% set index = loop.index if show_index else '' %}
+{% set prompt_width = 7 + index|string|length %}
+In [{{index}}]: {{ code.input|indent(width=prompt_width) }}
+{% if code.is_running %}
+Running...
+{% else %}
+{% if code.stdout -%}
+ {{ code.stdout }}
+{%- endif %}
+{% if code.stderr -%}
+ {{ code.stderr }}
+{%- endif %}
+{% if code.output %}
+Out[{{index}}]: {{ code.output|indent(width=prompt_width) }}
+{% endif %}
+{% endif %}
+
+{% endfor -%}
diff --git a/pw_console/py/repl_pane_test.py b/pw_console/py/repl_pane_test.py
new file mode 100644
index 0000000..1a82925
--- /dev/null
+++ b/pw_console/py/repl_pane_test.py
@@ -0,0 +1,144 @@
+# Copyright 2021 The Pigweed Authors
+#
+# Licensed under the Apache License, Version 2.0 (the "License"); you may not
+# use this file except in compliance with the License. You may obtain a copy of
+# the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+# License for the specific language governing permissions and limitations under
+# the License.
+"""Tests for pw_console.console_app"""
+
+import asyncio
+import threading
+import builtins
+import unittest
+from inspect import cleandoc
+from unittest.mock import Mock, MagicMock
+
+from prompt_toolkit.application import create_app_session
+# inclusive-language: ignore
+from prompt_toolkit.output import DummyOutput as FakeOutput
+
+from pw_console.console_app import ConsoleApp
+from pw_console.repl_pane import ReplPane
+from pw_console.pw_ptpython_repl import PwPtPythonRepl
+
+
+class TestReplPane(unittest.TestCase):
+ """Tests for ReplPane."""
+ def test_repl_code_return_values(self) -> None:
+ """Test stdout, return values, and exceptions can be returned from
+ running user repl code."""
+ app = Mock()
+
+ global_vars = {
+ '__name__': '__main__',
+ '__package__': None,
+ '__doc__': None,
+ '__builtins__': builtins,
+ }
+
+ pw_ptpython_repl = PwPtPythonRepl(
+ get_globals=lambda: global_vars,
+ get_locals=lambda: global_vars,
+ )
+ repl_pane = ReplPane(
+ application=app,
+ python_repl=pw_ptpython_repl,
+ )
+ # Check pw_ptpython_repl has a reference to the parent repl_pane.
+ self.assertEqual(repl_pane, pw_ptpython_repl.repl_pane)
+
+ # Define a function, should return nothing.
+ code = cleandoc("""
+ def run():
+ print('The answer is ', end='')
+ return 1+1+4+16+20
+ """)
+ # pylint: disable=protected-access
+ result = asyncio.run(pw_ptpython_repl._run_user_code(code))
+ self.assertEqual(result, {'stdout': '', 'stderr': '', 'result': None})
+
+ # Check stdout and return value
+ result = asyncio.run(pw_ptpython_repl._run_user_code('run()'))
+ self.assertEqual(result, {
+ 'stdout': 'The answer is ',
+ 'stderr': '',
+ 'result': 42
+ })
+
+ # Check for repl exception
+ result = asyncio.run(pw_ptpython_repl._run_user_code('return "blah"'))
+ self.assertIn("SyntaxError: 'return' outside function",
+ pw_ptpython_repl._last_result)
+
+ def test_user_thread(self) -> None:
+ """Test user code thread."""
+ with create_app_session(output=FakeOutput()):
+ app = ConsoleApp()
+ app.start_user_code_thread()
+
+ pw_ptpython_repl = app.pw_ptpython_repl
+ repl_pane = app.repl_pane
+
+ pw_ptpython_repl.user_code_complete_callback = MagicMock(
+ wraps=pw_ptpython_repl.user_code_complete_callback)
+ user_code_done = threading.Event()
+
+ code = cleandoc("""
+ import time
+ def run():
+ time.sleep(0.3)
+ print('The answer is ', end='')
+ return 1+1+4+16+20
+ """)
+
+ input_buffer = MagicMock(text=code)
+ # pylint: disable=protected-access
+ pw_ptpython_repl._accept_handler(input_buffer)
+
+ # Get last executed code object.
+ user_code1 = repl_pane.executed_code[-1]
+ # Wait for repl code to finish.
+ user_code1.future.add_done_callback(
+ lambda future: user_code_done.set())
+ user_code_done.wait(timeout=3)
+
+ pw_ptpython_repl.user_code_complete_callback.assert_called_once()
+ self.assertIsNotNone(user_code1)
+ self.assertTrue(user_code1.future.done())
+ self.assertEqual(user_code1.input, code)
+ self.assertEqual(user_code1.output, None)
+ # stdout / stderr may be '' or None
+ self.assertFalse(user_code1.stdout)
+ self.assertFalse(user_code1.stderr)
+
+ user_code_done.clear()
+ pw_ptpython_repl.user_code_complete_callback.reset_mock()
+
+ input_buffer = MagicMock(text='run()')
+ pw_ptpython_repl._accept_handler(input_buffer)
+
+ # Get last executed code object.
+ user_code2 = repl_pane.executed_code[-1]
+ # Wait for repl code to finish.
+ user_code2.future.add_done_callback(
+ lambda future: user_code_done.set())
+ user_code_done.wait(timeout=3)
+
+ pw_ptpython_repl.user_code_complete_callback.assert_called_once()
+ self.assertIsNotNone(user_code2)
+ self.assertTrue(user_code2.future.done())
+ self.assertEqual(user_code2.input, 'run()')
+ self.assertEqual(user_code2.output, '42')
+ self.assertEqual(user_code2.stdout, 'The answer is ')
+ self.assertFalse(user_code2.stderr)
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/pw_console/py/setup.py b/pw_console/py/setup.py
index 20d727d..c4b379d 100644
--- a/pw_console/py/setup.py
+++ b/pw_console/py/setup.py
@@ -26,6 +26,7 @@
'pw_console': [
'py.typed',
'templates/keybind_list.jinja',
+ 'templates/repl_output.jinja',
]
},
zip_safe=False,
@@ -39,7 +40,8 @@
'ipython',
'jinja2',
'prompt_toolkit',
- 'ptpython',
+ # Required features are not yet in https://pypi.org/project/ptpython/
+ 'ptpython @ git+https://github.com/prompt-toolkit/ptpython.git@b74af76',
'pw_cli',
'pw_tokenizer',
'pygments',