summaryrefslogtreecommitdiffstats
path: root/ptpython/python_input.py
diff options
context:
space:
mode:
Diffstat (limited to 'ptpython/python_input.py')
-rw-r--r--ptpython/python_input.py329
1 files changed, 198 insertions, 131 deletions
diff --git a/ptpython/python_input.py b/ptpython/python_input.py
index e63cdf1..54ddbef 100644
--- a/ptpython/python_input.py
+++ b/ptpython/python_input.py
@@ -2,12 +2,11 @@
Application for reading Python input.
This can be used for creation of Python REPLs.
"""
-import __future__
+from __future__ import annotations
-import threading
-from asyncio import get_event_loop
+from asyncio import get_running_loop
from functools import partial
-from typing import TYPE_CHECKING, Any, Callable, Dict, Generic, List, Optional, TypeVar
+from typing import TYPE_CHECKING, Any, Callable, Dict, Generic, Mapping, TypeVar, Union
from prompt_toolkit.application import Application, get_app
from prompt_toolkit.auto_suggest import (
@@ -24,9 +23,15 @@ from prompt_toolkit.completion import (
ThreadedCompleter,
merge_completers,
)
+from prompt_toolkit.cursor_shapes import (
+ AnyCursorShapeConfig,
+ CursorShape,
+ DynamicCursorShapeConfig,
+ ModalCursorShapeConfig,
+)
from prompt_toolkit.document import Document
from prompt_toolkit.enums import DEFAULT_BUFFER, EditingMode
-from prompt_toolkit.filters import Condition
+from prompt_toolkit.filters import Condition, FilterOrBool
from prompt_toolkit.formatted_text import AnyFormattedText
from prompt_toolkit.history import (
FileHistory,
@@ -44,7 +49,13 @@ from prompt_toolkit.key_binding.bindings.auto_suggest import load_auto_suggest_b
from prompt_toolkit.key_binding.bindings.open_in_editor import (
load_open_in_editor_bindings,
)
+from prompt_toolkit.key_binding.key_bindings import Binding, KeyHandlerCallable
+from prompt_toolkit.key_binding.key_processor import KeyPressEvent
from prompt_toolkit.key_binding.vi_state import InputMode
+from prompt_toolkit.keys import Keys
+from prompt_toolkit.layout.containers import AnyContainer
+from prompt_toolkit.layout.dimension import AnyDimension
+from prompt_toolkit.layout.processors import Processor
from prompt_toolkit.lexers import DynamicLexer, Lexer, SimpleLexer
from prompt_toolkit.output import ColorDepth, Output
from prompt_toolkit.styles import (
@@ -73,6 +84,11 @@ from .style import generate_style, get_all_code_styles, get_all_ui_styles
from .utils import unindent_code
from .validator import PythonValidator
+# Isort introduces a SyntaxError, if we'd write `import __future__`.
+# https://github.com/PyCQA/isort/issues/2100
+__future__ = __import__("__future__")
+
+
__all__ = ["PythonInput"]
@@ -80,22 +96,23 @@ if TYPE_CHECKING:
from typing_extensions import Protocol
class _SupportsLessThan(Protocol):
- # Taken from typeshed. _T is used by "sorted", which needs anything
+ # Taken from typeshed. _T_lt is used by "sorted", which needs anything
# sortable.
def __lt__(self, __other: Any) -> bool:
...
-_T = TypeVar("_T", bound="_SupportsLessThan")
+_T_lt = TypeVar("_T_lt", bound="_SupportsLessThan")
+_T_kh = TypeVar("_T_kh", bound=Union[KeyHandlerCallable, Binding])
-class OptionCategory:
- def __init__(self, title: str, options: List["Option"]) -> None:
+class OptionCategory(Generic[_T_lt]):
+ def __init__(self, title: str, options: list[Option[_T_lt]]) -> None:
self.title = title
self.options = options
-class Option(Generic[_T]):
+class Option(Generic[_T_lt]):
"""
Ptpython configuration option that can be shown and modified from the
sidebar.
@@ -111,10 +128,10 @@ class Option(Generic[_T]):
self,
title: str,
description: str,
- get_current_value: Callable[[], _T],
+ get_current_value: Callable[[], _T_lt],
# We accept `object` as return type for the select functions, because
# often they return an unused boolean. Maybe this can be improved.
- get_values: Callable[[], Dict[_T, Callable[[], object]]],
+ get_values: Callable[[], Mapping[_T_lt, Callable[[], object]]],
) -> None:
self.title = title
self.description = description
@@ -122,7 +139,7 @@ class Option(Generic[_T]):
self.get_values = get_values
@property
- def values(self) -> Dict[_T, Callable[[], object]]:
+ def values(self) -> Mapping[_T_lt, Callable[[], object]]:
return self.get_values()
def activate_next(self, _previous: bool = False) -> None:
@@ -174,29 +191,34 @@ class PythonInput:
python_input = PythonInput(...)
python_code = python_input.app.run()
+
+ :param create_app: When `False`, don't create and manage a prompt_toolkit
+ application. The default is `True` and should only be set
+ to false if PythonInput is being embedded in a separate
+ prompt_toolkit application.
"""
def __init__(
self,
- get_globals: Optional[_GetNamespace] = None,
- get_locals: Optional[_GetNamespace] = None,
- history_filename: Optional[str] = None,
+ get_globals: _GetNamespace | None = None,
+ get_locals: _GetNamespace | None = None,
+ history_filename: str | None = None,
vi_mode: bool = False,
- color_depth: Optional[ColorDepth] = None,
+ color_depth: ColorDepth | None = None,
# Input/output.
- input: Optional[Input] = None,
- output: Optional[Output] = None,
+ input: Input | None = None,
+ output: Output | None = None,
# For internal use.
- extra_key_bindings: Optional[KeyBindings] = None,
- _completer: Optional[Completer] = None,
- _validator: Optional[Validator] = None,
- _lexer: Optional[Lexer] = None,
- _extra_buffer_processors=None,
- _extra_layout_body=None,
- _extra_toolbars=None,
- _input_buffer_height=None,
+ extra_key_bindings: KeyBindings | None = None,
+ create_app: bool = True,
+ _completer: Completer | None = None,
+ _validator: Validator | None = None,
+ _lexer: Lexer | None = None,
+ _extra_buffer_processors: list[Processor] | None = None,
+ _extra_layout_body: AnyContainer | None = None,
+ _extra_toolbars: list[AnyContainer] | None = None,
+ _input_buffer_height: AnyDimension | None = None,
) -> None:
-
self.get_globals: _GetNamespace = get_globals or (lambda: {})
self.get_locals: _GetNamespace = get_locals or self.get_globals
@@ -234,7 +256,7 @@ class PythonInput:
self.history = InMemoryHistory()
self._input_buffer_height = _input_buffer_height
- self._extra_layout_body = _extra_layout_body or []
+ self._extra_layout_body = _extra_layout_body
self._extra_toolbars = _extra_toolbars or []
self._extra_buffer_processors = _extra_buffer_processors or []
@@ -293,7 +315,7 @@ class PythonInput:
self.show_exit_confirmation: bool = False
# The title to be displayed in the terminal. (None or string.)
- self.terminal_title: Optional[str] = None
+ self.terminal_title: str | None = None
self.exit_message: str = "Do you really want to exit?"
self.insert_blank_line_after_output: bool = True # (For the REPL.)
@@ -304,11 +326,23 @@ class PythonInput:
self.search_buffer: Buffer = Buffer()
self.docstring_buffer: Buffer = Buffer(read_only=True)
+ # Cursor shapes.
+ self.cursor_shape_config = "Block"
+ self.all_cursor_shape_configs: dict[str, AnyCursorShapeConfig] = {
+ "Block": CursorShape.BLOCK,
+ "Underline": CursorShape.UNDERLINE,
+ "Beam": CursorShape.BEAM,
+ "Modal (vi)": ModalCursorShapeConfig(),
+ "Blink block": CursorShape.BLINKING_BLOCK,
+ "Blink under": CursorShape.BLINKING_UNDERLINE,
+ "Blink beam": CursorShape.BLINKING_BEAM,
+ }
+
# Tokens to be shown at the prompt.
self.prompt_style: str = "classic" # The currently active style.
# Styles selectable from the menu.
- self.all_prompt_styles: Dict[str, PromptStyle] = {
+ self.all_prompt_styles: dict[str, PromptStyle] = {
"ipython": IPythonPrompt(self),
"classic": ClassicPrompt(),
}
@@ -322,7 +356,7 @@ class PythonInput:
].out_prompt()
#: Load styles.
- self.code_styles: Dict[str, BaseStyle] = get_all_code_styles()
+ self.code_styles: dict[str, BaseStyle] = get_all_code_styles()
self.ui_styles = get_all_ui_styles()
self._current_code_style_name: str = "default"
self._current_ui_style_name: str = "default"
@@ -340,11 +374,11 @@ class PythonInput:
self.options = self._create_options()
self.selected_option_index: int = 0
- #: Incremeting integer counting the current statement.
+ #: Incrementing integer counting the current statement.
self.current_statement_index: int = 1
# Code signatures. (This is set asynchronously after a timeout.)
- self.signatures: List[Signature] = []
+ self.signatures: list[Signature] = []
# Boolean indicating whether we have a signatures thread running.
# (Never run more than one at the same time.)
@@ -380,10 +414,16 @@ class PythonInput:
extra_toolbars=self._extra_toolbars,
)
- self.app = self._create_application(input, output)
-
- if vi_mode:
- self.app.editing_mode = EditingMode.VI
+ # Create an app if requested. If not, the global get_app() is returned
+ # for self.app via property getter.
+ if create_app:
+ self._app: Application[str] | None = self._create_application(input, output)
+ # Setting vi_mode will not work unless the prompt_toolkit
+ # application has been created.
+ if vi_mode:
+ self.app.editing_mode = EditingMode.VI
+ else:
+ self._app = None
def _accept_handler(self, buff: Buffer) -> bool:
app = get_app()
@@ -393,12 +433,12 @@ class PythonInput:
@property
def option_count(self) -> int:
- " Return the total amount of options. (In all categories together.) "
+ "Return the total amount of options. (In all categories together.)"
return sum(len(category.options) for category in self.options)
@property
- def selected_option(self) -> Option:
- " Return the currently selected option. "
+ def selected_option(self) -> Option[Any]:
+ "Return the currently selected option."
i = 0
for category in self.options:
for o in category.options:
@@ -432,24 +472,36 @@ class PythonInput:
return flags
- @property
- def add_key_binding(self) -> Callable[[_T], _T]:
+ def add_key_binding(
+ self,
+ *keys: Keys | str,
+ filter: FilterOrBool = True,
+ eager: FilterOrBool = False,
+ is_global: FilterOrBool = False,
+ save_before: Callable[[KeyPressEvent], bool] = (lambda e: True),
+ record_in_macro: FilterOrBool = True,
+ ) -> Callable[[_T_kh], _T_kh]:
"""
Shortcut for adding new key bindings.
(Mostly useful for a config.py file, that receives
a PythonInput/Repl instance as input.)
+ All arguments are identical to prompt_toolkit's `KeyBindings.add`.
+
::
@python_input.add_key_binding(Keys.ControlX, filter=...)
def handler(event):
...
"""
-
- def add_binding_decorator(*k, **kw):
- return self.extra_key_bindings.add(*k, **kw)
-
- return add_binding_decorator
+ return self.extra_key_bindings.add(
+ *keys,
+ filter=filter,
+ eager=eager,
+ is_global=is_global,
+ save_before=save_before,
+ record_in_macro=record_in_macro,
+ )
def install_code_colorscheme(self, name: str, style: BaseStyle) -> None:
"""
@@ -503,7 +555,7 @@ class PythonInput:
self.ui_styles[self._current_ui_style_name],
)
- def _create_options(self) -> List[OptionCategory]:
+ def _create_options(self) -> list[OptionCategory[Any]]:
"""
Create a list of `Option` instances for the options sidebar.
"""
@@ -519,15 +571,17 @@ class PythonInput:
return True
def simple_option(
- title: str, description: str, field_name: str, values: Optional[List] = None
- ) -> Option:
- " Create Simple on/of option. "
- values = values or ["off", "on"]
-
- def get_current_value():
+ title: str,
+ description: str,
+ field_name: str,
+ values: tuple[str, str] = ("off", "on"),
+ ) -> Option[str]:
+ "Create Simple on/of option."
+
+ def get_current_value() -> str:
return values[bool(getattr(self, field_name))]
- def get_values():
+ def get_values() -> dict[str, Callable[[], bool]]:
return {
values[1]: lambda: enable(field_name),
values[0]: lambda: disable(field_name),
@@ -555,6 +609,16 @@ class PythonInput:
"Vi": lambda: enable("vi_mode"),
},
),
+ Option(
+ title="Cursor shape",
+ description="Change the cursor style, possibly according "
+ "to the Vi input mode.",
+ get_current_value=lambda: self.cursor_shape_config,
+ get_values=lambda: {
+ s: partial(enable, "cursor_shape_config", s)
+ for s in self.all_cursor_shape_configs
+ },
+ ),
simple_option(
title="Paste mode",
description="When enabled, don't indent automatically.",
@@ -704,10 +768,10 @@ class PythonInput:
title="Prompt",
description="Visualisation of the prompt. ('>>>' or 'In [1]:')",
get_current_value=lambda: self.prompt_style,
- get_values=lambda: dict(
- (s, partial(enable, "prompt_style", s))
+ get_values=lambda: {
+ s: partial(enable, "prompt_style", s)
for s in self.all_prompt_styles
- ),
+ },
),
simple_option(
title="Blank line after input",
@@ -778,7 +842,7 @@ class PythonInput:
[
simple_option(
title="Syntax highlighting",
- description="Use colors for syntax highligthing",
+ description="Use colors for syntax highlighting",
field_name="enable_syntax_highlighting",
),
simple_option(
@@ -799,10 +863,10 @@ class PythonInput:
title="User interface",
description="Color scheme to use for the user interface.",
get_current_value=lambda: self._current_ui_style_name,
- get_values=lambda: dict(
- (name, partial(self.use_ui_colorscheme, name))
+ get_values=lambda: {
+ name: partial(self.use_ui_colorscheme, name)
for name in self.ui_styles
- ),
+ },
),
Option(
title="Color depth",
@@ -836,8 +900,8 @@ class PythonInput:
]
def _create_application(
- self, input: Optional[Input], output: Optional[Output]
- ) -> Application:
+ self, input: Input | None, output: Output | None
+ ) -> Application[str]:
"""
Create an `Application` instance.
"""
@@ -867,6 +931,9 @@ class PythonInput:
style_transformation=self.style_transformation,
include_default_pygments_style=False,
reverse_vi_search_direction=True,
+ cursor=DynamicCursorShapeConfig(
+ lambda: self.all_cursor_shape_configs[self.cursor_shape_config]
+ ),
input=input,
output=output,
)
@@ -914,23 +981,19 @@ class PythonInput:
else:
self.editing_mode = EditingMode.EMACS
- def _on_input_timeout(self, buff: Buffer, loop=None) -> None:
+ @property
+ def app(self) -> Application[str]:
+ if self._app is None:
+ return get_app()
+ return self._app
+
+ def _on_input_timeout(self, buff: Buffer) -> None:
"""
When there is no input activity,
in another thread, get the signature of the current code.
"""
- app = self.app
-
- # Never run multiple get-signature threads.
- if self._get_signatures_thread_running:
- return
- self._get_signatures_thread_running = True
- document = buff.document
-
- loop = loop or get_event_loop()
-
- def run():
+ def get_signatures_in_executor(document: Document) -> list[Signature]:
# First, get signatures from Jedi. If we didn't found any and if
# "dictionary completion" (eval-based completion) is enabled, then
# get signatures using eval.
@@ -942,26 +1005,47 @@ class PythonInput:
document, self.get_locals(), self.get_globals()
)
- self._get_signatures_thread_running = False
+ return signatures
- # Set signatures and redraw if the text didn't change in the
- # meantime. Otherwise request new signatures.
- if buff.text == document.text:
- self.signatures = signatures
+ app = self.app
- # Set docstring in docstring buffer.
- if signatures:
- self.docstring_buffer.reset(
- document=Document(signatures[0].docstring, cursor_position=0)
+ async def on_timeout_task() -> None:
+ loop = get_running_loop()
+
+ # Never run multiple get-signature threads.
+ if self._get_signatures_thread_running:
+ return
+ self._get_signatures_thread_running = True
+
+ try:
+ while True:
+ document = buff.document
+ signatures = await loop.run_in_executor(
+ None, get_signatures_in_executor, document
)
- else:
- self.docstring_buffer.reset()
- app.invalidate()
+ # If the text didn't change in the meantime, take these
+ # signatures. Otherwise, try again.
+ if buff.text == document.text:
+ break
+ finally:
+ self._get_signatures_thread_running = False
+
+ # Set signatures and redraw.
+ self.signatures = signatures
+
+ # Set docstring in docstring buffer.
+ if signatures:
+ self.docstring_buffer.reset(
+ document=Document(signatures[0].docstring, cursor_position=0)
+ )
else:
- self._on_input_timeout(buff, loop=loop)
+ self.docstring_buffer.reset()
+
+ app.invalidate()
- loop.run_in_executor(None, run)
+ if app.is_running:
+ app.create_background_task(on_timeout_task())
def on_reset(self) -> None:
self.signatures = []
@@ -970,7 +1054,7 @@ class PythonInput:
"""
Display the history.
"""
- app = get_app()
+ app = self.app
app.vi_state.input_mode = InputMode.NAVIGATION
history = PythonHistory(self, self.default_buffer.document)
@@ -999,6 +1083,7 @@ class PythonInput:
This can raise EOFError, when Control-D is pressed.
"""
+
# Capture the current input_mode in order to restore it after reset,
# for ViState.reset() sets it to InputMode.INSERT unconditionally and
# doesn't accept any arguments.
@@ -1012,43 +1097,25 @@ class PythonInput:
self.app.vi_state.input_mode = InputMode.NAVIGATION
# Run the UI.
- result: str = ""
- exception: Optional[BaseException] = None
-
- def in_thread() -> None:
- nonlocal result, exception
+ while True:
try:
- while True:
- try:
- result = self.app.run(pre_run=pre_run)
-
- if result.lstrip().startswith("\x1a"):
- # When the input starts with Ctrl-Z, quit the REPL.
- # (Important for Windows users.)
- raise EOFError
-
- # Remove leading whitespace.
- # (Users can add extra indentation, which happens for
- # instance because of copy/pasting code.)
- result = unindent_code(result)
-
- if result and not result.isspace():
- return
- except KeyboardInterrupt:
- # Abort - try again.
- self.default_buffer.document = Document()
- except BaseException as e:
- exception = e
- return
-
- finally:
- if self.insert_blank_line_after_input:
- self.app.output.write("\n")
-
- thread = threading.Thread(target=in_thread)
- thread.start()
- thread.join()
-
- if exception is not None:
- raise exception
- return result
+ result = self.app.run(pre_run=pre_run, in_thread=True)
+
+ if result.lstrip().startswith("\x1a"):
+ # When the input starts with Ctrl-Z, quit the REPL.
+ # (Important for Windows users.)
+ raise EOFError
+
+ # Remove leading whitespace.
+ # (Users can add extra indentation, which happens for
+ # instance because of copy/pasting code.)
+ result = unindent_code(result)
+
+ if result and not result.isspace():
+ if self.insert_blank_line_after_input:
+ self.app.output.write("\n")
+
+ return result
+ except KeyboardInterrupt:
+ # Abort - try again.
+ self.default_buffer.document = Document()