From a04c37d06d11598226a3713966b654961eb54db7 Mon Sep 17 00:00:00 2001 From: Jonathan Slenders Date: Fri, 7 Jun 2024 21:20:16 +0000 Subject: [PATCH 01/30] Compute padding parameters for Box widget lazily. --- src/prompt_toolkit/widgets/base.py | 47 +++++++++++++++++++----------- 1 file changed, 30 insertions(+), 17 deletions(-) diff --git a/src/prompt_toolkit/widgets/base.py b/src/prompt_toolkit/widgets/base.py index 709b7a949..e2330e9e1 100644 --- a/src/prompt_toolkit/widgets/base.py +++ b/src/prompt_toolkit/widgets/base.py @@ -59,7 +59,7 @@ FormattedTextControl, GetLinePrefixCallable, ) -from prompt_toolkit.layout.dimension import AnyDimension, to_dimension +from prompt_toolkit.layout.dimension import AnyDimension from prompt_toolkit.layout.dimension import Dimension as D from prompt_toolkit.layout.margins import ( ConditionalMargin, @@ -636,31 +636,44 @@ def __init__( modal: bool = False, key_bindings: KeyBindings | None = None, ) -> None: - if padding is None: - padding = D(preferred=0) - - def get(value: AnyDimension) -> D: - if value is None: - value = padding - return to_dimension(value) - - self.padding_left = get(padding_left) - self.padding_right = get(padding_right) - self.padding_top = get(padding_top) - self.padding_bottom = get(padding_bottom) + self.padding = padding + self.padding_left = padding_left + self.padding_right = padding_right + self.padding_top = padding_top + self.padding_bottom = padding_bottom self.body = body + def left() -> AnyDimension: + if self.padding_left is None: + return self.padding + return self.padding_left + + def right() -> AnyDimension: + if self.padding_right is None: + return self.padding + return self.padding_right + + def top() -> AnyDimension: + if self.padding_top is None: + return self.padding + return self.padding_top + + def bottom() -> AnyDimension: + if self.padding_bottom is None: + return self.padding + return self.padding_bottom + self.container = HSplit( [ - Window(height=self.padding_top, char=char), + Window(height=top, char=char), VSplit( [ - Window(width=self.padding_left, char=char), + Window(width=left, char=char), body, - Window(width=self.padding_right, char=char), + Window(width=right, char=char), ] ), - Window(height=self.padding_bottom, char=char), + Window(height=bottom, char=char), ], width=width, height=height, From 15f3aecf5f00fe2db23e3a2b6b4d8da3d4416e44 Mon Sep 17 00:00:00 2001 From: Jonathan Slenders Date: Fri, 7 Jun 2024 21:33:36 +0000 Subject: [PATCH 02/30] Allow passing exception classes for KeyboardInterrupt and EOFError in PromptSession. --- src/prompt_toolkit/shortcuts/prompt.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/prompt_toolkit/shortcuts/prompt.py b/src/prompt_toolkit/shortcuts/prompt.py index 115d89007..d0732bc13 100644 --- a/src/prompt_toolkit/shortcuts/prompt.py +++ b/src/prompt_toolkit/shortcuts/prompt.py @@ -324,6 +324,10 @@ class PromptSession(Generic[_T]): :param input: `Input` object. (Note that the preferred way to change the input/output is by creating an `AppSession`.) :param output: `Output` object. + :param interrupt_exception: The exception type that will be raised when + there is a keyboard interrupt (control-c keypress). + :param eof_exception: The exception type that will be raised when there is + an end-of-file/exit event (control-d keypress). """ _fields = ( @@ -410,6 +414,8 @@ def __init__( refresh_interval: float = 0, input: Input | None = None, output: Output | None = None, + interrupt_exception: type[BaseException] = KeyboardInterrupt, + eof_exception: type[BaseException] = EOFError, ) -> None: history = history or InMemoryHistory() clipboard = clipboard or InMemoryClipboard() @@ -459,6 +465,8 @@ def __init__( self.reserve_space_for_menu = reserve_space_for_menu self.tempfile_suffix = tempfile_suffix self.tempfile = tempfile + self.interrupt_exception = interrupt_exception + self.eof_exception = eof_exception # Create buffers, layout and Application. self.history = history @@ -811,7 +819,7 @@ def _complete_like_readline(event: E) -> None: @handle("") def _keyboard_interrupt(event: E) -> None: "Abort when Control-C has been pressed." - event.app.exit(exception=KeyboardInterrupt, style="class:aborting") + event.app.exit(exception=self.interrupt_exception(), style="class:aborting") @Condition def ctrl_d_condition() -> bool: @@ -826,7 +834,7 @@ def ctrl_d_condition() -> bool: @handle("c-d", filter=ctrl_d_condition & default_focused) def _eof(event: E) -> None: "Exit when Control-D has been pressed." - event.app.exit(exception=EOFError, style="class:exiting") + event.app.exit(exception=self.eof_exception(), style="class:exiting") suspend_supported = Condition(suspend_to_background_supported) From 8889675ae45b0a09bad941490c222afddfd2b335 Mon Sep 17 00:00:00 2001 From: jbm950 Date: Wed, 5 Jun 2024 18:28:22 -0400 Subject: [PATCH 03/30] fix(Examples): address a typo in one of the examples This commit fixes a typo in the module docstring of the Floats example. Refs: #1882 --- examples/full-screen/simple-demos/floats.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/full-screen/simple-demos/floats.py b/examples/full-screen/simple-demos/floats.py index 4d79c2b80..d31b7900c 100755 --- a/examples/full-screen/simple-demos/floats.py +++ b/examples/full-screen/simple-demos/floats.py @@ -1,6 +1,6 @@ #!/usr/bin/env python """ -Horizontal split example. +Floats example. """ from prompt_toolkit.application import Application From 67e644b3015f8748576af41f7e8bc8d02671b9bb Mon Sep 17 00:00:00 2001 From: Jonathan Slenders Date: Mon, 10 Jun 2024 10:51:30 +0000 Subject: [PATCH 04/30] Small simplification in Screen code. See also: https://github.com/prompt-toolkit/python-prompt-toolkit/pull/1877/ --- src/prompt_toolkit/layout/screen.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/prompt_toolkit/layout/screen.py b/src/prompt_toolkit/layout/screen.py index 0f19f52a8..475f540d1 100644 --- a/src/prompt_toolkit/layout/screen.py +++ b/src/prompt_toolkit/layout/screen.py @@ -169,7 +169,7 @@ def __init__( #: Escape sequences to be injected. self.zero_width_escapes: defaultdict[int, defaultdict[int, str]] = defaultdict( - lambda: defaultdict(lambda: "") + lambda: defaultdict(str) ) #: Position of the cursor. From 669541123c9a72da1fda662cbd0a18ffe9e6d113 Mon Sep 17 00:00:00 2001 From: Jonathan Slenders Date: Mon, 10 Jun 2024 10:57:10 +0000 Subject: [PATCH 05/30] Release 3.0.47 --- CHANGELOG | 11 +++++++++++ docs/conf.py | 4 ++-- src/prompt_toolkit/__init__.py | 2 +- 3 files changed, 14 insertions(+), 3 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index fa02b8568..272823232 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,6 +1,17 @@ CHANGELOG ========= +3.0.47: 2024-06-10 +------------------ + +New features: +- Allow passing exception classes for `KeyboardInterrupt` and `EOFError` in + `PromptSession`. + +Fixes: +- Compute padding parameters for `Box` widget lazily. + + 3.0.46: 2024-06-04 ------------------ diff --git a/docs/conf.py b/docs/conf.py index dbbcecc09..1c6bb576d 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -48,9 +48,9 @@ # built documents. # # The short X.Y version. -version = "3.0.46" +version = "3.0.47" # The full version, including alpha/beta/rc tags. -release = "3.0.46" +release = "3.0.47" # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. diff --git a/src/prompt_toolkit/__init__.py b/src/prompt_toolkit/__init__.py index 9f194f1b4..7f6f30251 100644 --- a/src/prompt_toolkit/__init__.py +++ b/src/prompt_toolkit/__init__.py @@ -28,7 +28,7 @@ from .shortcuts import PromptSession, print_formatted_text, prompt # Don't forget to update in `docs/conf.py`! -__version__ = "3.0.46" +__version__ = "3.0.47" assert pep440.match(__version__) From 75615b1d19e24f7fe632ff5f7b7c2f54ecbe5a5d Mon Sep 17 00:00:00 2001 From: Tomasz Chalupnik Date: Wed, 25 Sep 2024 11:47:14 +0200 Subject: [PATCH 06/30] Add @overload to `contrib.regular_languages.compiler.Variables.get` - Improves type hints for the `Variables` class. - Fixes ruff pipeline check. - Fixes several mypy issues. --- .github/workflows/test.yaml | 2 +- .../contrib/regular_languages/compiler.py | 17 +++++++++----- src/prompt_toolkit/output/defaults.py | 22 +++++++++++-------- src/prompt_toolkit/patch_stdout.py | 2 +- 4 files changed, 27 insertions(+), 16 deletions(-) diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index eedbdd8ef..c153c2b18 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -31,7 +31,7 @@ jobs: pip list - name: Ruff run: | - ruff . + ruff check . ruff format --check . typos . - name: Tests diff --git a/src/prompt_toolkit/contrib/regular_languages/compiler.py b/src/prompt_toolkit/contrib/regular_languages/compiler.py index dd558a68a..4009d54f2 100644 --- a/src/prompt_toolkit/contrib/regular_languages/compiler.py +++ b/src/prompt_toolkit/contrib/regular_languages/compiler.py @@ -42,7 +42,7 @@ from __future__ import annotations import re -from typing import Callable, Dict, Iterable, Iterator, Pattern +from typing import Callable, Dict, Iterable, Iterator, Pattern, TypeVar, overload from typing import Match as RegexMatch from .regex_parser import ( @@ -57,9 +57,7 @@ tokenize_regex, ) -__all__ = [ - "compile", -] +__all__ = ["compile", "Match", "Variables"] # Name of the named group in the regex, matching trailing input. @@ -491,6 +489,9 @@ def end_nodes(self) -> Iterable[MatchVariable]: yield MatchVariable(varname, value, (reg[0], reg[1])) +_T = TypeVar("_T") + + class Variables: def __init__(self, tuples: list[tuple[str, str, tuple[int, int]]]) -> None: #: List of (varname, value, slice) tuples. @@ -502,7 +503,13 @@ def __repr__(self) -> str: ", ".join(f"{k}={v!r}" for k, v, _ in self._tuples), ) - def get(self, key: str, default: str | None = None) -> str | None: + @overload + def get(self, key: str) -> str | None: ... + + @overload + def get(self, key: str, default: str | _T) -> str | _T: ... + + def get(self, key: str, default: str | _T | None = None) -> str | _T | None: items = self.getall(key) return items[0] if items else default diff --git a/src/prompt_toolkit/output/defaults.py b/src/prompt_toolkit/output/defaults.py index ed114e32a..6b06ed43c 100644 --- a/src/prompt_toolkit/output/defaults.py +++ b/src/prompt_toolkit/output/defaults.py @@ -1,7 +1,7 @@ from __future__ import annotations import sys -from typing import TextIO, cast +from typing import TYPE_CHECKING, TextIO, cast from prompt_toolkit.utils import ( get_bell_environment_variable, @@ -13,13 +13,17 @@ from .color_depth import ColorDepth from .plain_text import PlainTextOutput +if TYPE_CHECKING: + from prompt_toolkit.patch_stdout import StdoutProxy + + __all__ = [ "create_output", ] def create_output( - stdout: TextIO | None = None, always_prefer_tty: bool = False + stdout: TextIO | StdoutProxy | None = None, always_prefer_tty: bool = False ) -> Output: """ Return an :class:`~prompt_toolkit.output.Output` instance for the command @@ -54,13 +58,6 @@ def create_output( stdout = io break - # If the output is still `None`, use a DummyOutput. - # This happens for instance on Windows, when running the application under - # `pythonw.exe`. In that case, there won't be a terminal Window, and - # stdin/stdout/stderr are `None`. - if stdout is None: - return DummyOutput() - # If the patch_stdout context manager has been used, then sys.stdout is # replaced by this proxy. For prompt_toolkit applications, we want to use # the real stdout. @@ -69,6 +66,13 @@ def create_output( while isinstance(stdout, StdoutProxy): stdout = stdout.original_stdout + # If the output is still `None`, use a DummyOutput. + # This happens for instance on Windows, when running the application under + # `pythonw.exe`. In that case, there won't be a terminal Window, and + # stdin/stdout/stderr are `None`. + if stdout is None: + return DummyOutput() + if sys.platform == "win32": from .conemu import ConEmuOutput from .win32 import Win32Output diff --git a/src/prompt_toolkit/patch_stdout.py b/src/prompt_toolkit/patch_stdout.py index 4958e9d2e..e1f2a7a2c 100644 --- a/src/prompt_toolkit/patch_stdout.py +++ b/src/prompt_toolkit/patch_stdout.py @@ -273,7 +273,7 @@ def flush(self) -> None: self._flush() @property - def original_stdout(self) -> TextIO: + def original_stdout(self) -> TextIO | None: return self._output.stdout or sys.__stdout__ # Attributes for compatibility with sys.__stdout__: From 728983be0ec4e290905a6282bcc6c7c3ffd15cda Mon Sep 17 00:00:00 2001 From: wzy <32936898+Freed-Wu@users.noreply.github.com> Date: Wed, 25 Sep 2024 17:55:35 +0800 Subject: [PATCH 07/30] Improve `ModalCursorShapeConfig` for various input modes (#1900) - Also display a beam cursor for Vi "insert multiple" mode (visual block insert). - Also display an underline cursor for Vi "replace single" mode. - Display a beam in Emacs mode (like Vi's insert). --- src/prompt_toolkit/cursor_shapes.py | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/src/prompt_toolkit/cursor_shapes.py b/src/prompt_toolkit/cursor_shapes.py index 453b72c3c..cbd8e593f 100644 --- a/src/prompt_toolkit/cursor_shapes.py +++ b/src/prompt_toolkit/cursor_shapes.py @@ -69,10 +69,23 @@ class ModalCursorShapeConfig(CursorShapeConfig): def get_cursor_shape(self, application: Application[Any]) -> CursorShape: if application.editing_mode == EditingMode.VI: - if application.vi_state.input_mode == InputMode.INSERT: + if application.vi_state.input_mode in { + InputMode.NAVIGATION, + }: + return CursorShape.BLOCK + if application.vi_state.input_mode in { + InputMode.INSERT, + InputMode.INSERT_MULTIPLE, + }: return CursorShape.BEAM - if application.vi_state.input_mode == InputMode.REPLACE: + if application.vi_state.input_mode == { + InputMode.REPLACE, + InputMode.REPLACE_SINGLE, + }: return CursorShape.UNDERLINE + elif application.editing_mode == EditingMode.EMACS: + # like vi's INSERT + return CursorShape.BEAM # Default return CursorShape.BLOCK From df4e244bae0f864f8fae2af3fe42a6e81bb330ea Mon Sep 17 00:00:00 2001 From: Max R Date: Wed, 25 Sep 2024 05:57:09 -0400 Subject: [PATCH 08/30] Update type definition for `words` argument in completers (#1889) Accept `Sequence` instead of `list`. --- src/prompt_toolkit/completion/fuzzy_completer.py | 4 ++-- src/prompt_toolkit/completion/word_completer.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/prompt_toolkit/completion/fuzzy_completer.py b/src/prompt_toolkit/completion/fuzzy_completer.py index 25ea8923a..82625ab63 100644 --- a/src/prompt_toolkit/completion/fuzzy_completer.py +++ b/src/prompt_toolkit/completion/fuzzy_completer.py @@ -1,7 +1,7 @@ from __future__ import annotations import re -from typing import Callable, Iterable, NamedTuple +from typing import Callable, Iterable, NamedTuple, Sequence from prompt_toolkit.document import Document from prompt_toolkit.filters import FilterOrBool, to_filter @@ -187,7 +187,7 @@ class FuzzyWordCompleter(Completer): def __init__( self, - words: list[str] | Callable[[], list[str]], + words: Sequence[str] | Callable[[], Sequence[str]], meta_dict: dict[str, str] | None = None, WORD: bool = False, ) -> None: diff --git a/src/prompt_toolkit/completion/word_completer.py b/src/prompt_toolkit/completion/word_completer.py index 6ef4031fa..2e124056b 100644 --- a/src/prompt_toolkit/completion/word_completer.py +++ b/src/prompt_toolkit/completion/word_completer.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import Callable, Iterable, Mapping, Pattern +from typing import Callable, Iterable, Mapping, Pattern, Sequence from prompt_toolkit.completion import CompleteEvent, Completer, Completion from prompt_toolkit.document import Document @@ -33,7 +33,7 @@ class WordCompleter(Completer): def __init__( self, - words: list[str] | Callable[[], list[str]], + words: Sequence[str] | Callable[[], Sequence[str]], ignore_case: bool = False, display_dict: Mapping[str, AnyFormattedText] | None = None, meta_dict: Mapping[str, AnyFormattedText] | None = None, From ae1d63501e28ca34639abc637edc416840a13d72 Mon Sep 17 00:00:00 2001 From: Jonathan Slenders Date: Wed, 25 Sep 2024 12:08:29 +0200 Subject: [PATCH 09/30] Fixup for previous cursorshapes commit (#1900). (#1920) --- src/prompt_toolkit/cursor_shapes.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/prompt_toolkit/cursor_shapes.py b/src/prompt_toolkit/cursor_shapes.py index cbd8e593f..01d10926a 100644 --- a/src/prompt_toolkit/cursor_shapes.py +++ b/src/prompt_toolkit/cursor_shapes.py @@ -78,7 +78,7 @@ def get_cursor_shape(self, application: Application[Any]) -> CursorShape: InputMode.INSERT_MULTIPLE, }: return CursorShape.BEAM - if application.vi_state.input_mode == { + if application.vi_state.input_mode in { InputMode.REPLACE, InputMode.REPLACE_SINGLE, }: From 435bd99cf2abb229c13d5b1106467c7f6af599ed Mon Sep 17 00:00:00 2001 From: Jonathan Slenders Date: Wed, 25 Sep 2024 12:21:05 +0200 Subject: [PATCH 10/30] Release 3.0.48 --- CHANGELOG | 13 +++++++++++++ docs/conf.py | 4 ++-- src/prompt_toolkit/__init__.py | 2 +- 3 files changed, 16 insertions(+), 3 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index 272823232..b58229c3d 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,6 +1,19 @@ CHANGELOG ========= +3.0.48: 2024-09-25 +------------------ + +Fixes: +- Typing improvements: + * Add `@overload` to `contrib.regular_languages.compiler.Variables.get`. + * Use `Sequence` instead of `list` for `words` argument in completers. +- Improve `ModalCursorShapeConfig`: + * Display an "underscore" cursor in Vi's "replace single" mode, like + "replace" mode. + * Display an "beam" cursor in Emacs (insert) mode. + + 3.0.47: 2024-06-10 ------------------ diff --git a/docs/conf.py b/docs/conf.py index 1c6bb576d..a5d1f1608 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -48,9 +48,9 @@ # built documents. # # The short X.Y version. -version = "3.0.47" +version = "3.0.48" # The full version, including alpha/beta/rc tags. -release = "3.0.47" +release = "3.0.48" # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. diff --git a/src/prompt_toolkit/__init__.py b/src/prompt_toolkit/__init__.py index 7f6f30251..80da72d1e 100644 --- a/src/prompt_toolkit/__init__.py +++ b/src/prompt_toolkit/__init__.py @@ -28,7 +28,7 @@ from .shortcuts import PromptSession, print_formatted_text, prompt # Don't forget to update in `docs/conf.py`! -__version__ = "3.0.47" +__version__ = "3.0.48" assert pep440.match(__version__) From f2c7a159efcc2ccecf2ce1cf30fd247bb572f3d9 Mon Sep 17 00:00:00 2001 From: Jonathan Slenders Date: Fri, 4 Oct 2024 16:24:47 +0200 Subject: [PATCH 11/30] Fix cursor that remains in hidden state when application exits. (#1925) When we have an application that doesn't show the cursor and `erase_when_done` is being used, the cursor remained hidden after the application terminated. --- src/prompt_toolkit/renderer.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/prompt_toolkit/renderer.py b/src/prompt_toolkit/renderer.py index 3f92303a8..d0590830d 100644 --- a/src/prompt_toolkit/renderer.py +++ b/src/prompt_toolkit/renderer.py @@ -257,7 +257,7 @@ def get_max_column_index(row: dict[int, Char]) -> int: # give weird artifacts on resize events.) reset_attributes() - if screen.show_cursor or is_done: + if screen.show_cursor: output.show_cursor() return current_pos, last_style @@ -416,6 +416,7 @@ def reset(self, _scroll: bool = False, leave_alternate_screen: bool = True) -> N self._bracketed_paste_enabled = False self.output.reset_cursor_shape() + self.output.show_cursor() # NOTE: No need to set/reset cursor key mode here. From c12ac9164357b72dd998f30560de0a9b3dd615ee Mon Sep 17 00:00:00 2001 From: Jonathan Slenders Date: Fri, 4 Oct 2024 16:25:31 +0200 Subject: [PATCH 12/30] Handle InvalidStateError during termination when using `run_in_terminal`/`patch_stdout`. (#1926) In some edge cases, during cancellation, probably when using anyio, we can get this `InvalidStateError` during termination. This patch fixes that bug. ``` Traceback (most recent call last): File "/home/jonathan/git/python-prompt-toolkit/src/prompt_toolkit/application/run_in_terminal.py", line 49, in run async with in_terminal(render_cli_done=render_cli_done): File "/home/jonathan/.local/share/uv/python/cpython-3.11.10-linux-aarch64-gnu/lib/python3.11/contextlib.py", line 217, in __aexit__ await anext(self.gen) File "/home/jonathan/git/python-prompt-toolkit/src/prompt_toolkit/application/run_in_terminal.py", line 114, in in_terminal new_run_in_terminal_f.set_result(None) asyncio.exceptions.InvalidStateError: invalid state ``` --- src/prompt_toolkit/application/run_in_terminal.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/prompt_toolkit/application/run_in_terminal.py b/src/prompt_toolkit/application/run_in_terminal.py index 18a3dadeb..1f5e18ea7 100644 --- a/src/prompt_toolkit/application/run_in_terminal.py +++ b/src/prompt_toolkit/application/run_in_terminal.py @@ -111,4 +111,7 @@ async def f(): app._request_absolute_cursor_position() app._redraw() finally: - new_run_in_terminal_f.set_result(None) + # (Check for `.done()`, because it can be that this future was + # cancelled.) + if not new_run_in_terminal_f.done(): + new_run_in_terminal_f.set_result(None) From 543350c2748c37d3663d40ebf83422a5861f6afc Mon Sep 17 00:00:00 2001 From: Jonathan Slenders Date: Sat, 9 Nov 2024 20:59:02 +0000 Subject: [PATCH 13/30] Improve code formatting in docs. Thanks: @tahadnan --- docs/pages/asking_for_input.rst | 268 ++++++++++++++++++-------------- docs/pages/getting_started.rst | 4 +- 2 files changed, 154 insertions(+), 118 deletions(-) diff --git a/docs/pages/asking_for_input.rst b/docs/pages/asking_for_input.rst index 20619ac1f..093bbe0d0 100644 --- a/docs/pages/asking_for_input.rst +++ b/docs/pages/asking_for_input.rst @@ -24,8 +24,8 @@ and returns the text. Just like ``(raw_)input``. from prompt_toolkit import prompt - text = prompt('Give me some input: ') - print('You said: %s' % text) + text = prompt("Give me some input: ") + print(f"You said: {text}") .. image:: ../images/hello-world-prompt.png @@ -85,8 +85,8 @@ base class. from prompt_toolkit.shortcuts import prompt from prompt_toolkit.lexers import PygmentsLexer - text = prompt('Enter HTML: ', lexer=PygmentsLexer(HtmlLexer)) - print('You said: %s' % text) + text = prompt("Enter HTML: ", lexer=PygmentsLexer(HtmlLexer)) + print(f"You said: {text}") .. image:: ../images/html-input.png @@ -102,10 +102,14 @@ you can do the following: from prompt_toolkit.lexers import PygmentsLexer from prompt_toolkit.styles.pygments import style_from_pygments_cls - style = style_from_pygments_cls(get_style_by_name('monokai')) - text = prompt('Enter HTML: ', lexer=PygmentsLexer(HtmlLexer), style=style, - include_default_pygments_style=False) - print('You said: %s' % text) + style = style_from_pygments_cls(get_style_by_name("monokai")) + text = prompt( + "Enter HTML: ", + lexer=PygmentsLexer(HtmlLexer), + style=style, + include_default_pygments_style=False + ) + print(f"You said: {text}") We pass ``include_default_pygments_style=False``, because otherwise, both styles will be merged, possibly giving slightly different colors in the outcome @@ -131,12 +135,15 @@ function: from prompt_toolkit.lexers import PygmentsLexer our_style = Style.from_dict({ - 'pygments.comment': '#888888 bold', - 'pygments.keyword': '#ff88ff bold', + "pygments.comment": "#888888 bold", + "pygments.keyword": "#ff88ff bold", }) - text = prompt('Enter HTML: ', lexer=PygmentsLexer(HtmlLexer), - style=our_style) + text = prompt( + "Enter HTML: ", + lexer=PygmentsLexer(HtmlLexer), + style=our_style + ) The style dictionary is very similar to the Pygments ``styles`` dictionary, @@ -167,12 +174,14 @@ Suppose we'd like to use a Pygments style, for instance from prompt_toolkit.lexers import PygmentsLexer from pygments.styles.tango import TangoStyle from pygments.lexers.html import HtmlLexer - - tango_style = style_from_pygments_cls (TangoStyle) - - text = prompt ('Enter HTML: ', - lexer=PygmentsLexer(HtmlLexer), - style=tango_style) + + tango_style = style_from_pygments_cls(TangoStyle) + + text = prompt( + "Enter HTML: ", + lexer=PygmentsLexer(HtmlLexer), + style=tango_style + ) Creating a custom style could be done like this: @@ -188,13 +197,16 @@ Creating a custom style could be done like this: our_style = merge_styles([ style_from_pygments_cls(TangoStyle), Style.from_dict({ - 'pygments.comment': '#888888 bold', - 'pygments.keyword': '#ff88ff bold', + "pygments.comment": "#888888 bold", + "pygments.keyword": "#ff88ff bold", }) ]) - text = prompt('Enter HTML: ', lexer=PygmentsLexer(HtmlLexer), - style=our_style) + text = prompt( + "Enter HTML: ", + lexer=PygmentsLexer(HtmlLexer), + style=our_style + ) Coloring the prompt itself @@ -212,24 +224,24 @@ names to refer to the style. style = Style.from_dict({ # User input (default text). - '': '#ff0066', + "": "#ff0066", # Prompt. - 'username': '#884444', - 'at': '#00aa00', - 'colon': '#0000aa', - 'pound': '#00aa00', - 'host': '#00ffff bg:#444400', - 'path': 'ansicyan underline', + "username": "#884444", + "at": "#00aa00", + "colon": "#0000aa", + "pound": "#00aa00", + "host": "#00ffff bg:#444400", + "path": "ansicyan underline", }) message = [ - ('class:username', 'john'), - ('class:at', '@'), - ('class:host', 'localhost'), - ('class:colon', ':'), - ('class:path', '/user/john'), - ('class:pound', '# '), + ("class:username", "john"), + ("class:at", "@"), + ("class:host", "localhost"), + ("class:colon", ":"), + ("class:path", "/user/john"), + ("class:pound", "# "), ] text = prompt(message, style=style) @@ -264,9 +276,9 @@ a completer that implements that interface. from prompt_toolkit import prompt from prompt_toolkit.completion import WordCompleter - html_completer = WordCompleter(['', '', '', '']) - text = prompt('Enter HTML: ', completer=html_completer) - print('You said: %s' % text) + html_completer = WordCompleter(["<html>", "<body>", "<head>", "<title>"]) + text = prompt("Enter HTML: ", completer=html_completer) + print(f"You said: {text}") :class:`~prompt_toolkit.completion.WordCompleter` is a simple completer that completes the last word before the cursor with any of the given words. @@ -299,18 +311,18 @@ levels. :class:`~prompt_toolkit.completion.NestedCompleter` solves this issue: from prompt_toolkit.completion import NestedCompleter completer = NestedCompleter.from_nested_dict({ - 'show': { - 'version': None, - 'clock': None, - 'ip': { - 'interface': {'brief'} + "show": { + "version": None, + "clock": None, + "ip": { + "interface": {"brief"} } }, - 'exit': None, + "exit": None, }) - text = prompt('# ', completer=completer) - print('You said: %s' % text) + text = prompt("# ", completer=completer) + print(f"You said: {text}") Whenever there is a ``None`` value in the dictionary, it means that there is no further nested completion at that point. When all values of a dictionary would @@ -330,9 +342,9 @@ instance: class MyCustomCompleter(Completer): def get_completions(self, document, complete_event): - yield Completion('completion', start_position=0) + yield Completion("completion", start_position=0) - text = prompt('> ', completer=MyCustomCompleter()) + text = prompt("> ", completer=MyCustomCompleter()) A :class:`~prompt_toolkit.completion.Completer` class has to implement a generator named :meth:`~prompt_toolkit.completion.Completer.get_completions` @@ -361,16 +373,25 @@ in the completion menu or toolbar. This is possible by passing a style to each class MyCustomCompleter(Completer): def get_completions(self, document, complete_event): # Display this completion, black on yellow. - yield Completion('completion1', start_position=0, - style='bg:ansiyellow fg:ansiblack') + yield Completion( + "completion1", + start_position=0, + style="bg:ansiyellow fg:ansiblack" + ) # Underline completion. - yield Completion('completion2', start_position=0, - style='underline') + yield Completion( + "completion2", + start_position=0, + style="underline" + ) # Specify class name, which will be looked up in the style sheet. - yield Completion('completion3', start_position=0, - style='class:special-completion') + yield Completion( + "completion3", + start_position=0, + style="class:special-completion" + ) The "colorful-prompts.py" example uses completion styling: @@ -390,9 +411,11 @@ can also be combined with the ``style`` attribute. For instance: class MyCustomCompleter(Completer): def get_completions(self, document, complete_event): yield Completion( - 'completion1', start_position=0, - display=HTML('<b>completion</b><ansired>1</ansired>'), - style='bg:ansiyellow') + "completion1", + start_position=0, + display=HTML("<b>completion</b><ansired>1</ansired>"), + style="bg:ansiyellow" + ) Fuzzy completion @@ -419,8 +442,11 @@ option: .. code:: python - text = prompt('Enter HTML: ', completer=my_completer, - complete_while_typing=True) + text = prompt( + "Enter HTML: ", + completer=my_completer, + complete_while_typing=True + ) Notice that this setting is incompatible with the ``enable_history_search`` option. The reason for this is that the up and down key bindings would conflict @@ -438,7 +464,7 @@ a background thread. This is possible by wrapping the completer in a .. code:: python - text = prompt('> ', completer=MyCustomCompleter(), complete_in_thread=True) + text = prompt("> ", completer=MyCustomCompleter(), complete_in_thread=True) Input validation @@ -472,11 +498,13 @@ takes a :class:`~prompt_toolkit.document.Document` as input and raises if not c.isdigit(): break - raise ValidationError(message='This input contains non-numeric characters', - cursor_position=i) + raise ValidationError( + message="This input contains non-numeric characters", + cursor_position=i + ) - number = int(prompt('Give a number: ', validator=NumberValidator())) - print('You said: %i' % number) + number = int(prompt("Give a number: ", validator=NumberValidator())) + print(f"You said: {number}") .. image:: ../images/number-validator.png @@ -485,8 +513,11 @@ prompt_toolkit can also validate after the user presses the enter key: .. code:: python - prompt('Give a number: ', validator=NumberValidator(), - validate_while_typing=False) + prompt( + "Give a number: ", + validator=NumberValidator(), + validate_while_typing=False + ) If the input validation contains some heavy CPU intensive code, but you don't want to block the event loop, then it's recommended to wrap the validator class @@ -511,11 +542,12 @@ follows: validator = Validator.from_callable( is_number, - error_message='This input contains non-numeric characters', - move_cursor_to_end=True) + error_message="This input contains non-numeric characters", + move_cursor_to_end=True + ) - number = int(prompt('Give a number: ', validator=validator)) - print('You said: %i' % number) + number = int(prompt("Give a number: ", validator=validator)) + print(f"You said: {number}") We define a function that takes a string, and tells whether it's valid input or not by returning a boolean. @@ -556,7 +588,7 @@ passed either to a :class:`~prompt_toolkit.shortcuts.PromptSession` or to the from prompt_toolkit import PromptSession from prompt_toolkit.history import FileHistory - session = PromptSession(history=FileHistory('~/.myhistory')) + session = PromptSession(history=FileHistory("~/.myhistory")) while True: session.prompt() @@ -591,8 +623,8 @@ Example: session = PromptSession() while True: - text = session.prompt('> ', auto_suggest=AutoSuggestFromHistory()) - print('You said: %s' % text) + text = session.prompt("> ", auto_suggest=AutoSuggestFromHistory()) + print(f"You said: {text}") .. image:: ../images/auto-suggestion.png @@ -624,10 +656,10 @@ of the foreground. from prompt_toolkit.formatted_text import HTML def bottom_toolbar(): - return HTML('This is a <b><style bg="ansired">Toolbar</style></b>!') + return HTML("This is a <b><style bg="ansired">Toolbar</style></b>!") - text = prompt('> ', bottom_toolbar=bottom_toolbar) - print('You said: %s' % text) + text = prompt("> ", bottom_toolbar=bottom_toolbar) + print(f"You said: {text}") .. image:: ../images/bottom-toolbar.png @@ -639,14 +671,14 @@ Similar, we could use a list of style/text tuples. from prompt_toolkit.styles import Style def bottom_toolbar(): - return [('class:bottom-toolbar', ' This is a toolbar. ')] + return [("class:bottom-toolbar", " This is a toolbar. ")] style = Style.from_dict({ - 'bottom-toolbar': '#ffffff bg:#333333', + "bottom-toolbar": "#ffffff bg:#333333", }) - text = prompt('> ', bottom_toolbar=bottom_toolbar, style=style) - print('You said: %s' % text) + text = prompt("> ", bottom_toolbar=bottom_toolbar, style=style) + print(f"You said: {text}") The default class name is ``bottom-toolbar`` and that will also be used to fill the background of the toolbar. @@ -669,13 +701,13 @@ callable which returns either. from prompt_toolkit.styles import Style example_style = Style.from_dict({ - 'rprompt': 'bg:#ff0066 #ffffff', + "rprompt": "bg:#ff0066 #ffffff", }) def get_rprompt(): - return '<rprompt>' + return "<rprompt>" - answer = prompt('> ', rprompt=get_rprompt, style=example_style) + answer = prompt("> ", rprompt=get_rprompt, style=example_style) .. image:: ../images/rprompt.png @@ -699,7 +731,7 @@ binding are required, just pass ``vi_mode=True``. from prompt_toolkit import prompt - prompt('> ', vi_mode=True) + prompt("> ", vi_mode=True) Adding custom key bindings @@ -711,7 +743,8 @@ usual Vi or Emacs behavior. We can extend this by passing another ``key_bindings`` argument of the :func:`~prompt_toolkit.shortcuts.prompt` function or the :class:`~prompt_toolkit.shortcuts.PromptSession` class. -An example of a prompt that prints ``'hello world'`` when :kbd:`Control-T` is pressed. +An example of a prompt that prints ``'hello world'`` when :kbd:`Control-T` is +pressed. .. code:: python @@ -721,20 +754,20 @@ An example of a prompt that prints ``'hello world'`` when :kbd:`Control-T` is pr bindings = KeyBindings() - @bindings.add('c-t') + @bindings.add("c-t") def _(event): - " Say 'hello' when `c-t` is pressed. " + " Say "hello" when `c-t` is pressed. " def print_hello(): - print('hello world') + print("hello world") run_in_terminal(print_hello) - @bindings.add('c-x') + @bindings.add("c-x") def _(event): " Exit when `c-x` is pressed. " event.app.exit() - text = prompt('> ', key_bindings=bindings) - print('You said: %s' % text) + text = prompt("> ", key_bindings=bindings) + print(f"You said: {text}") Note that we use @@ -770,12 +803,12 @@ filters <filters>`.) " Only activate key binding on the second half of each minute. " return datetime.datetime.now().second > 30 - @bindings.add('c-t', filter=is_active) + @bindings.add("c-t", filter=is_active) def _(event): # ... pass - prompt('> ', key_bindings=bindings) + prompt("> ", key_bindings=bindings) Dynamically switch between Emacs and Vi mode @@ -797,7 +830,7 @@ attribute. We can change the key bindings by changing this attribute from bindings = KeyBindings() # Add an additional key binding for toggling this flag. - @bindings.add('f4') + @bindings.add("f4") def _(event): " Toggle between Emacs and Vi mode. " app = event.app @@ -810,12 +843,12 @@ attribute. We can change the key bindings by changing this attribute from # Add a toolbar at the bottom to display the current input mode. def bottom_toolbar(): " Display the current input mode. " - text = 'Vi' if get_app().editing_mode == EditingMode.VI else 'Emacs' + text = "Vi" if get_app().editing_mode == EditingMode.VI else "Emacs" return [ - ('class:toolbar', ' [F4] %s ' % text) + ("class:toolbar", " [F4] %s " % text) ] - prompt('> ', key_bindings=bindings, bottom_toolbar=bottom_toolbar) + prompt("> ", key_bindings=bindings, bottom_toolbar=bottom_toolbar) run() @@ -832,7 +865,7 @@ the following key binding. kb = KeyBindings() - @kb.add('c-space') + @kb.add("c-space") def _(event): " Initialize autocompletion, or select the next completion. " buff = event.app.current_buffer @@ -854,7 +887,7 @@ Reading multiline input is as easy as passing the ``multiline=True`` parameter. from prompt_toolkit import prompt - prompt('> ', multiline=True) + prompt("> ", multiline=True) A side effect of this is that the enter key will now insert a newline instead of accepting and returning the input. The user will now have to press @@ -873,11 +906,14 @@ prompt.) from prompt_toolkit import prompt def prompt_continuation(width, line_number, is_soft_wrap): - return '.' * width - # Or: return [('', '.' * width)] + return "." * width + # Or: return [("", "." * width)] - prompt('multiline input> ', multiline=True, - prompt_continuation=prompt_continuation) + prompt( + "multiline input> ", + multiline=True, + prompt_continuation=prompt_continuation + ) .. image:: ../images/multiline-input.png @@ -892,7 +928,7 @@ A default value can be given: from prompt_toolkit import prompt import getpass - prompt('What is your name: ', default='%s' % getpass.getuser()) + prompt("What is your name: ", default=f"{getpass.getuser()}") Mouse support @@ -907,7 +943,7 @@ Enabling can be done by passing the ``mouse_support=True`` option. from prompt_toolkit import prompt - prompt('What is your name: ', mouse_support=True) + prompt("What is your name: ", mouse_support=True) Line wrapping @@ -921,7 +957,7 @@ scroll horizontally. from prompt_toolkit import prompt - prompt('What is your name: ', wrap_lines=False) + prompt("What is your name: ", wrap_lines=False) Password input @@ -934,7 +970,7 @@ asterisks (``*`` characters). from prompt_toolkit import prompt - prompt('Enter password: ', is_password=True) + prompt("Enter password: ", is_password=True) Cursor shapes @@ -952,13 +988,13 @@ input mode. from prompt_toolkit.cursor_shapes import CursorShape, ModalCursorShapeConfig # Several possible values for the `cursor_shape_config` parameter: - prompt('>', cursor=CursorShape.BLOCK) - prompt('>', cursor=CursorShape.UNDERLINE) - prompt('>', cursor=CursorShape.BEAM) - prompt('>', cursor=CursorShape.BLINKING_BLOCK) - prompt('>', cursor=CursorShape.BLINKING_UNDERLINE) - prompt('>', cursor=CursorShape.BLINKING_BEAM) - prompt('>', cursor=ModalCursorShapeConfig()) + prompt(">", cursor=CursorShape.BLOCK) + prompt(">", cursor=CursorShape.UNDERLINE) + prompt(">", cursor=CursorShape.BEAM) + prompt(">", cursor=CursorShape.BLINKING_BLOCK) + prompt(">", cursor=CursorShape.BLINKING_UNDERLINE) + prompt(">", cursor=CursorShape.BLINKING_BEAM) + prompt(">", cursor=ModalCursorShapeConfig()) Prompt in an `asyncio` application @@ -989,8 +1025,8 @@ returns a coroutines and is awaitable. session = PromptSession() while True: with patch_stdout(): - result = await session.prompt_async('Say something: ') - print('You said: %s' % result) + result = await session.prompt_async("Say something: ") + print(f"You said: {result}") The :func:`~prompt_toolkit.patch_stdout.patch_stdout` context manager is optional, but it's recommended, because other coroutines could print to stdout. diff --git a/docs/pages/getting_started.rst b/docs/pages/getting_started.rst index 06287a080..2c7bcc15d 100644 --- a/docs/pages/getting_started.rst +++ b/docs/pages/getting_started.rst @@ -56,8 +56,8 @@ and returns the text. Just like ``(raw_)input``. from prompt_toolkit import prompt - text = prompt('Give me some input: ') - print('You said: %s' % text) + text = prompt("Give me some input: ") + print(f"You said: {text}") Learning `prompt_toolkit` From 81db0eacfabf0147994243adfed9085ab5ce6399 Mon Sep 17 00:00:00 2001 From: Jonathan Slenders <jonathan@slenders.be> Date: Sat, 9 Nov 2024 21:02:05 +0000 Subject: [PATCH 14/30] Use f-strings in examples where possible. Thanks: @tahadnan --- examples/progress-bar/a-lot-of-parallel-tasks.py | 2 +- examples/prompts/asyncio-prompt.py | 2 +- examples/prompts/patch-stdout.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/examples/progress-bar/a-lot-of-parallel-tasks.py b/examples/progress-bar/a-lot-of-parallel-tasks.py index a20982789..007bdb6ff 100755 --- a/examples/progress-bar/a-lot-of-parallel-tasks.py +++ b/examples/progress-bar/a-lot-of-parallel-tasks.py @@ -39,7 +39,7 @@ def stop_task(label, total, sleep_time): threads = [] for i in range(160): - label = "Task %i" % i + label = f"Task {i}" total = random.randrange(50, 200) sleep_time = random.randrange(5, 20) / 100.0 diff --git a/examples/prompts/asyncio-prompt.py b/examples/prompts/asyncio-prompt.py index 32a1481d9..bd97fb3a9 100755 --- a/examples/prompts/asyncio-prompt.py +++ b/examples/prompts/asyncio-prompt.py @@ -26,7 +26,7 @@ async def print_counter(): try: i = 0 while True: - print("Counter: %i" % i) + print(f"Counter: {i}") i += 1 await asyncio.sleep(3) except asyncio.CancelledError: diff --git a/examples/prompts/patch-stdout.py b/examples/prompts/patch-stdout.py index 3a89f3650..7a283b601 100755 --- a/examples/prompts/patch-stdout.py +++ b/examples/prompts/patch-stdout.py @@ -21,7 +21,7 @@ def thread(): i = 0 while running: i += 1 - print("i=%i" % i) + print(f"i={i}") time.sleep(1) t = threading.Thread(target=thread) From 7bbfb8f561ab3c0b7ff915fdc3f520acd58e479f Mon Sep 17 00:00:00 2001 From: Jonathan Slenders <jonathan@slenders.be> Date: Sat, 9 Nov 2024 21:03:00 +0000 Subject: [PATCH 15/30] Fix rst syntax error in PROJECTS.rst. Thanks: @tahadnan --- PROJECTS.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/PROJECTS.rst b/PROJECTS.rst index eabfe13ab..5f862b3d4 100644 --- a/PROJECTS.rst +++ b/PROJECTS.rst @@ -52,7 +52,7 @@ Full screen applications: - `sanctuary-zero <https://github.com/t0xic0der/sanctuary-zero>`_: A secure chatroom with zero logging and total transience. - `Hummingbot <https://github.com/CoinAlpha/hummingbot>`_: A Cryptocurrency Algorithmic Trading Platform - `git-bbb <https://github.com/MrMino/git-bbb>`_: A `git blame` browser. -- `ass <https://github.com/mlang/ass`_: An OpenAI Assistants API client. +- `ass <https://github.com/mlang/ass>`_: An OpenAI Assistants API client. Libraries: From e02b1712b3fc7d7b5f6b003850b668a07eb2b341 Mon Sep 17 00:00:00 2001 From: Jonathan Slenders <jonathan@slenders.be> Date: Sat, 9 Nov 2024 21:12:30 +0000 Subject: [PATCH 16/30] Add 'Vertica' to typos config. --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index 1bff4baec..caba4fd11 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -50,6 +50,7 @@ extend-ignore-re = [ "ret", "rouble", "x1b\\[4m", + "Vertica", # Database. # Deliberate spelling mistakes in autocorrection.py "wolrd", "impotr", From a2a12300c635ab3c051566e363ed27d853af4b21 Mon Sep 17 00:00:00 2001 From: Daniela Rus Morales <157103+danirus@users.noreply.github.com> Date: Sun, 1 Dec 2024 21:24:35 +0100 Subject: [PATCH 17/30] Update sphinx theme (#1937) * Docs: update theme, resolve warnings * Remove sphinx-colorschemed-images (unused) * Ruff changes to docs/conf.py * ruff lint ignore UP031 in examples/* * ruff lint ignore UP031 in src/* * Extend typos ignore to test_buffer.py --- docs/conf.py | 151 ++++++++++++++++------ docs/pages/reference.rst | 4 +- docs/requirements.txt | 6 +- pyproject.toml | 7 +- src/prompt_toolkit/application/current.py | 4 +- 5 files changed, 126 insertions(+), 46 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index a5d1f1608..13c50e804 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -25,13 +25,17 @@ # Add any Sphinx extension module names here, as strings. They can be # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom # ones. -extensions = ["sphinx.ext.autodoc", "sphinx.ext.graphviz", "sphinx_copybutton"] +extensions = [ + "sphinx.ext.autodoc", + "sphinx.ext.graphviz", + "sphinx_copybutton", +] # Add any paths that contain templates here, relative to this directory. # templates_path = ["_templates"] # The suffix of source filenames. -source_suffix = ".rst" +source_suffix = {".rst": "restructuredtext"} # The encoding of source files. # source_encoding = 'utf-8-sig' @@ -47,10 +51,21 @@ # |version| and |release|, also used in various other places throughout the # built documents. # +# --------------------------------------------------------------------- +# Versions. # The short X.Y version. version = "3.0.48" # The full version, including alpha/beta/rc tags. release = "3.0.48" +# The URL pattern to match releases to ReadTheDocs URLs. +docs_fmt_url = "https://python-prompt-toolkit.readthedocs.io/en/{release}/" +# The list of releases to include in the dropdown. +releases = [ + "latest", + release, + "2.0.9", + "1.0.15", +] # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. @@ -82,8 +97,7 @@ # show_authors = False # The name of the Pygments (syntax highlighting) style to use. -pygments_style = "pastie" -pygments_dark_style = "dracula" +# pygments_style = "pastie" # Provided as a theme option below. # A list of ignored prefixes for module index sorting. # modindex_common_prefix = [] @@ -107,38 +121,103 @@ # on_rtd = os.environ.get("READTHEDOCS", None) == "True" -try: - import sphinx_nefertiti - - html_theme = "sphinx_nefertiti" - html_theme_path = [sphinx_nefertiti.get_html_theme_path()] - html_theme_options = { - # "style" can take the following values: "blue", "indigo", "purple", - # "pink", "red", "orange", "yellow", "green", "tail", and "default". - "style": "default", - # Fonts are customizable (and are not retrieved online). - # https://sphinx-nefertiti.readthedocs.io/en/latest/users-guide/customization/fonts.html - # "documentation_font": "Open Sans", - # "monospace_font": "Ubuntu Mono", - # "monospace_font_size": "1.1rem", - "logo": "logo_400px.png", - "logo_alt": "python-prompt-toolkit", - "logo_width": "36", - "logo_height": "36", - "repository_url": "https://github.com/prompt-toolkit/python-prompt-toolkit", - "repository_name": "python-prompt-toolkit", - "footer_links": ",".join( - [ - "Documentation|https://python-prompt-toolkit.readthedocs.io/", - "Package|https://pypi.org/project/prompt-toolkit/", - "Repository|https://github.com/prompt-toolkit/python-prompt-toolkit", - "Issues|https://github.com/prompt-toolkit/python-prompt-toolkit/issues", - ] - ), - } - -except ImportError: - html_theme = "pyramid" +html_theme = "sphinx_nefertiti" +# html_theme_path = [sphinx_nefertiti.get_html_theme_path()] +html_theme_options = { + "documentation_font": "Open Sans", + "monospace_font": "Ubuntu Mono", + "monospace_font_size": "1.1rem", + # "style" can take the following values: "blue", "indigo", "purple", + # "pink", "red", "orange", "yellow", "green", "tail", and "default". + "style": "blue", + "pygments_light_style": "pastie", + "pygments_dark_style": "dracula", + # Fonts are customizable (and are not retrieved online). + # https://sphinx-nefertiti.readthedocs.io/en/latest/users-guide/customization/fonts.html + "logo": "logo_400px.png", + "logo_alt": "python-prompt-toolkit", + "logo_width": "36", + "logo_height": "36", + "repository_url": "https://github.com/prompt-toolkit/python-prompt-toolkit", + "repository_name": "python-prompt-toolkit", + "current_version": "latest", + "versions": [(item, docs_fmt_url.format(release=item)) for item in releases], + "header_links": [ + {"text": "Getting started", "link": "pages/getting_started"}, + { + "text": "Tutorials", + "match": "/tutorials/*", + "dropdown": ( + {"text": "Build an SQLite REPL", "link": "pages/tutorials/repl"}, + ), + }, + { + "text": "Advanced", + "link": "pages/advanced_topics/index", + "match": "/advanced_topics/*", + "dropdown": ( + { + "text": "More about key bindings", + "link": "pages/advanced_topics/key_bindings", + }, + { + "text": "More about styling", + "link": "pages/advanced_topics/styling", + }, + { + "text": "Filters", + "link": "pages/advanced_topics/filters", + }, + { + "text": "The rendering flow", + "link": "pages/advanced_topics/rendering_flow", + }, + { + "text": "Running on top of the asyncio event loop", + "link": "pages/advanced_topics/asyncio", + }, + { + "text": "Unit testing", + "link": "pages/advanced_topics/unit_testing", + }, + { + "text": "Input hooks", + "link": "pages/advanced_topics/input_hooks", + }, + { + "text": "Architecture", + "link": "pages/advanced_topics/architecture", + }, + { + "text": "The rendering pipeline", + "link": "pages/advanced_topics/rendering_pipeline", + }, + ), + }, + { + "text": "Reference", + "link": "pages/reference", + }, + ], + "footer_links": [ + { + "text": "Documentation", + "link": "https://python-prompt-toolkit.readthedocs.io/", + }, + { + "text": "Package", + "link": "https://pypi.org/project/prompt-toolkit/", + }, + { + "text": "Repository", + "link": "https://github.com/prompt-toolkit/python-prompt-toolkit", + }, + { + "text": "Issues", + "link": "https://github.com/prompt-toolkit/python-prompt-toolkit/issues", + }, + ], +} # Theme options are theme-specific and customize the look and feel of a theme diff --git a/docs/pages/reference.rst b/docs/pages/reference.rst index d8a705ea4..a8c777535 100644 --- a/docs/pages/reference.rst +++ b/docs/pages/reference.rst @@ -301,9 +301,11 @@ Filters .. autoclass:: prompt_toolkit.filters.Filter :members: + :no-index: .. autoclass:: prompt_toolkit.filters.Condition :members: + :no-index: .. automodule:: prompt_toolkit.filters.utils :members: @@ -334,7 +336,7 @@ Eventloop .. automodule:: prompt_toolkit.eventloop :members: run_in_executor_with_context, call_soon_threadsafe, - get_traceback_from_context, get_event_loop + get_traceback_from_context .. automodule:: prompt_toolkit.eventloop.inputhook :members: diff --git a/docs/requirements.txt b/docs/requirements.txt index beb1c314b..844bca89d 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1,5 +1,5 @@ -Sphinx<7 +Sphinx>=8,<9 wcwidth<1 pyperclip<2 -sphinx_copybutton>=0.5.0,<1.0.0 -sphinx-nefertiti>=0.2.1 +sphinx_copybutton>=0.5.2,<1.0.0 +sphinx-nefertiti>=0.6.0 \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index caba4fd11..57fc2d9f2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,9 +20,8 @@ lint.ignore = [ "E741", # Ambiguous variable name. ] - [tool.ruff.lint.per-file-ignores] -"examples/*" = ["T201"] # Print allowed in examples. +"examples/*" = ["UP031", "T201"] # Print allowed in examples. "src/prompt_toolkit/application/application.py" = ["T100", "T201", "F821"] # pdb and print allowed. "src/prompt_toolkit/contrib/telnet/server.py" = ["T201"] # Print allowed. "src/prompt_toolkit/key_binding/bindings/named_commands.py" = ["T201"] # Print allowed. @@ -31,8 +30,7 @@ lint.ignore = [ "src/prompt_toolkit/filters/__init__.py" = ["F403", "F405"] # Possibly undefined due to star import. "src/prompt_toolkit/filters/cli.py" = ["F403", "F405"] # Possibly undefined due to star import. "src/prompt_toolkit/shortcuts/progress_bar/formatters.py" = ["UP031"] # %-style formatting. -"src/*" = ["UP032"] # f-strings instead of format calls. - +"src/*" = ["UP031", "UP032"] # f-strings instead of format calls. [tool.ruff.lint.isort] known-first-party = ["prompt_toolkit"] @@ -63,6 +61,7 @@ locale = 'en-us' # US English. [tool.typos.files] extend-exclude = [ + "tests/test_buffer.py", "tests/test_cli.py", "tests/test_regular_languages.py", ] diff --git a/src/prompt_toolkit/application/current.py b/src/prompt_toolkit/application/current.py index 7e2cf480b..3f7eb4bd4 100644 --- a/src/prompt_toolkit/application/current.py +++ b/src/prompt_toolkit/application/current.py @@ -143,8 +143,8 @@ def create_app_session( """ Create a separate AppSession. - This is useful if there can be multiple individual `AppSession`s going on. - Like in the case of an Telnet/SSH server. + This is useful if there can be multiple individual ``AppSession``'s going + on. Like in the case of a Telnet/SSH server. """ # If no input/output is specified, fall back to the current input/output, # if there was one that was set/created for the current session. From 8ea330640c8f7c82c299fb49325f52f38cc84797 Mon Sep 17 00:00:00 2001 From: M Bussonnier <bussonniermatthias@gmail.com> Date: Wed, 1 Jan 2025 21:36:32 +0100 Subject: [PATCH 18/30] Allow multiline suggestions. (#1948) Added `TransformationInput.get_line` for multiline suggestions. --- src/prompt_toolkit/layout/controls.py | 17 ++++++++++++++--- src/prompt_toolkit/layout/processors.py | 6 ++++++ 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/src/prompt_toolkit/layout/controls.py b/src/prompt_toolkit/layout/controls.py index 222e471c5..5083c8286 100644 --- a/src/prompt_toolkit/layout/controls.py +++ b/src/prompt_toolkit/layout/controls.py @@ -667,7 +667,11 @@ def _create_get_processed_line_func( merged_processor = merge_processors(input_processors) - def transform(lineno: int, fragments: StyleAndTextTuples) -> _ProcessedLine: + def transform( + lineno: int, + fragments: StyleAndTextTuples, + get_line: Callable[[int], StyleAndTextTuples], + ) -> _ProcessedLine: "Transform the fragments for a given line number." # Get cursor position at this line. @@ -679,7 +683,14 @@ def source_to_display(i: int) -> int: transformation = merged_processor.apply_transformation( TransformationInput( - self, document, lineno, source_to_display, fragments, width, height + self, + document, + lineno, + source_to_display, + fragments, + width, + height, + get_line, ) ) @@ -697,7 +708,7 @@ def get_processed_line(i: int) -> _ProcessedLine: try: return cache[i] except KeyError: - processed_line = transform(i, get_line(i)) + processed_line = transform(i, get_line(i), get_line) cache[i] = processed_line return processed_line diff --git a/src/prompt_toolkit/layout/processors.py b/src/prompt_toolkit/layout/processors.py index b10ecf718..8208018d0 100644 --- a/src/prompt_toolkit/layout/processors.py +++ b/src/prompt_toolkit/layout/processors.py @@ -86,6 +86,9 @@ class TransformationInput: previous processors into account.) :param fragments: List of fragments that we can transform. (Received from the previous processor.) + :param get_line: Optional ; a callable that returns the fragments of another + line in the current buffer; This can be used to create processors capable + of affecting transforms across multiple lines. """ def __init__( @@ -97,6 +100,7 @@ def __init__( fragments: StyleAndTextTuples, width: int, height: int, + get_line: Callable[[int], StyleAndTextTuples] | None = None, ) -> None: self.buffer_control = buffer_control self.document = document @@ -105,6 +109,7 @@ def __init__( self.fragments = fragments self.width = width self.height = height + self.get_line = get_line def unpack( self, @@ -987,6 +992,7 @@ def source_to_display(i: int) -> int: fragments, ti.width, ti.height, + ti.get_line, ) ) fragments = transformation.fragments From cd7c6a2bce4ec50fe4355f0575c066175fa93674 Mon Sep 17 00:00:00 2001 From: M Bussonnier <bussonniermatthias@gmail.com> Date: Wed, 1 Jan 2025 21:37:08 +0100 Subject: [PATCH 19/30] Start testing on 3.13 (#1949) --- .github/workflows/test.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index c153c2b18..ea44f3dae 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -14,7 +14,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["3.7", "3.8", "3.9", "3.10", "3.11", "3.12"] + python-version: ["3.7", "3.8", "3.9", "3.10", "3.11", "3.12", "3.13"] steps: - uses: actions/checkout@v4 From c3c25bf11607960ca2f37605cfa554efd6b52d53 Mon Sep 17 00:00:00 2001 From: Jonathan Slenders <jonathan@slenders.be> Date: Fri, 17 Jan 2025 12:25:19 -0800 Subject: [PATCH 20/30] Fix code formatting (ruff). (#1959) --- examples/dialogs/progress_dialog.py | 3 +-- examples/prompts/colored-prompt.py | 7 +------ src/prompt_toolkit/filters/base.py | 3 +-- src/prompt_toolkit/layout/processors.py | 6 +++--- tests/test_cli.py | 4 ++-- 5 files changed, 8 insertions(+), 15 deletions(-) diff --git a/examples/dialogs/progress_dialog.py b/examples/dialogs/progress_dialog.py index 7a29f1bb9..3d5227bc6 100755 --- a/examples/dialogs/progress_dialog.py +++ b/examples/dialogs/progress_dialog.py @@ -38,8 +38,7 @@ def worker(set_percentage, log_text): def main(): progress_dialog( title="Progress dialog example", - text="As an examples, we walk through the filesystem and print " - "all directories", + text="As an examples, we walk through the filesystem and print all directories", run_callback=worker, ).run() diff --git a/examples/prompts/colored-prompt.py b/examples/prompts/colored-prompt.py index 428ff1dd0..883b3cf20 100755 --- a/examples/prompts/colored-prompt.py +++ b/examples/prompts/colored-prompt.py @@ -66,12 +66,7 @@ def example_3(): Using ANSI for the formatting. """ answer = prompt( - ANSI( - "\x1b[31mjohn\x1b[0m@" - "\x1b[44mlocalhost\x1b[0m:" - "\x1b[4m/user/john\x1b[0m" - "# " - ) + ANSI("\x1b[31mjohn\x1b[0m@\x1b[44mlocalhost\x1b[0m:\x1b[4m/user/john\x1b[0m# ") ) print(f"You said: {answer}") diff --git a/src/prompt_toolkit/filters/base.py b/src/prompt_toolkit/filters/base.py index 410749db4..cd95424dc 100644 --- a/src/prompt_toolkit/filters/base.py +++ b/src/prompt_toolkit/filters/base.py @@ -81,8 +81,7 @@ def __bool__(self) -> None: instead of for instance ``filter1 or Always()``. """ raise ValueError( - "The truth value of a Filter is ambiguous. " - "Instead, call it as a function." + "The truth value of a Filter is ambiguous. Instead, call it as a function." ) diff --git a/src/prompt_toolkit/layout/processors.py b/src/prompt_toolkit/layout/processors.py index 8208018d0..666e79c66 100644 --- a/src/prompt_toolkit/layout/processors.py +++ b/src/prompt_toolkit/layout/processors.py @@ -847,9 +847,9 @@ def filter_processor(item: Processor) -> Processor | None: def apply_transformation(self, ti: TransformationInput) -> Transformation: from .controls import SearchBufferControl - assert isinstance( - ti.buffer_control, SearchBufferControl - ), "`ReverseSearchProcessor` should be applied to a `SearchBufferControl` only." + assert isinstance(ti.buffer_control, SearchBufferControl), ( + "`ReverseSearchProcessor` should be applied to a `SearchBufferControl` only." + ) source_to_display: SourceToDisplay | None display_to_source: DisplayToSource | None diff --git a/tests/test_cli.py b/tests/test_cli.py index c155325f9..a876f2993 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -870,11 +870,11 @@ def test_vi_temp_navigation_mode(): """ feed = partial(_feed_cli_with_input, editing_mode=EditingMode.VI) - result, cli = feed("abcde" "\x0f" "3h" "x\r") # c-o # 3 times to the left. + result, cli = feed("abcde\x0f3hx\r") # c-o # 3 times to the left. assert result.text == "axbcde" assert result.cursor_position == 2 - result, cli = feed("abcde" "\x0f" "b" "x\r") # c-o # One word backwards. + result, cli = feed("abcde\x0fbx\r") # c-o # One word backwards. assert result.text == "xabcde" assert result.cursor_position == 1 From 92b3a9579849d5162c1c7dd00a6ee8ee4df0f215 Mon Sep 17 00:00:00 2001 From: Jonathan Slenders <jonathan@slenders.be> Date: Fri, 17 Jan 2025 12:45:49 -0800 Subject: [PATCH 21/30] Drop Python 3.7 (#1960) --- .github/workflows/test.yaml | 5 ++--- setup.py | 7 ++----- 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index ea44f3dae..6b391bacf 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -14,7 +14,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["3.7", "3.8", "3.9", "3.10", "3.11", "3.12", "3.13"] + python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13"] steps: - uses: actions/checkout@v4 @@ -37,8 +37,7 @@ jobs: - name: Tests run: | coverage run -m pytest - - if: "matrix.python-version != '3.7'" - name: Mypy + - name: Mypy # Check whether the imports were sorted correctly. # When this fails, please run ./tools/sort-imports.sh run: | diff --git a/setup.py b/setup.py index ca2170ce4..2febdf1de 100755 --- a/setup.py +++ b/setup.py @@ -30,22 +30,19 @@ def get_version(package): package_dir={"": "src"}, package_data={"prompt_toolkit": ["py.typed"]}, install_requires=["wcwidth"], - # We require Python 3.7, because we need: - # - Context variables - PEP 567 - # - `asyncio.run()` - python_requires=">=3.7.0", + python_requires=">=3.8.0", classifiers=[ "Development Status :: 5 - Production/Stable", "Intended Audience :: Developers", "License :: OSI Approved :: BSD License", "Operating System :: OS Independent", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python", "Topic :: Software Development", From 02b32fa5ef8b8f08d17915a8b59318fbdb04d35f Mon Sep 17 00:00:00 2001 From: Jonathan Slenders <jonathan@slenders.be> Date: Fri, 17 Jan 2025 14:12:45 -0800 Subject: [PATCH 22/30] Use virtual terminal input on Windows when available. (#1958) For output on Windows, we were already using Vt100 ANSI escape sequences. This change will also use ANSI sequences for input, whenever possible. This simplifies the reading of input and will be more accurate in some edge cases. --- src/prompt_toolkit/input/win32.py | 153 ++++++++++++++++++++++++++++-- 1 file changed, 145 insertions(+), 8 deletions(-) diff --git a/src/prompt_toolkit/input/win32.py b/src/prompt_toolkit/input/win32.py index 322d7c0d7..1ff3234a3 100644 --- a/src/prompt_toolkit/input/win32.py +++ b/src/prompt_toolkit/input/win32.py @@ -16,7 +16,7 @@ import msvcrt from ctypes import windll -from ctypes import Array, pointer +from ctypes import Array, byref, pointer from ctypes.wintypes import DWORD, HANDLE from typing import Callable, ContextManager, Iterable, Iterator, TextIO @@ -35,6 +35,7 @@ from .ansi_escape_sequences import REVERSE_ANSI_SEQUENCES from .base import Input +from .vt100_parser import Vt100Parser __all__ = [ "Win32Input", @@ -52,6 +53,9 @@ MOUSE_MOVED = 0x0001 MOUSE_WHEELED = 0x0004 +# See: https://msdn.microsoft.com/pl-pl/library/windows/desktop/ms686033(v=vs.85).aspx +ENABLE_VIRTUAL_TERMINAL_INPUT = 0x0200 + class _Win32InputBase(Input): """ @@ -74,7 +78,14 @@ class Win32Input(_Win32InputBase): def __init__(self, stdin: TextIO | None = None) -> None: super().__init__() - self.console_input_reader = ConsoleInputReader() + self._use_virtual_terminal_input = _is_win_vt100_input_enabled() + + self.console_input_reader: Vt100ConsoleInputReader | ConsoleInputReader + + if self._use_virtual_terminal_input: + self.console_input_reader = Vt100ConsoleInputReader() + else: + self.console_input_reader = ConsoleInputReader() def attach(self, input_ready_callback: Callable[[], None]) -> ContextManager[None]: """ @@ -101,7 +112,9 @@ def closed(self) -> bool: return False def raw_mode(self) -> ContextManager[None]: - return raw_mode() + return raw_mode( + use_win10_virtual_terminal_input=self._use_virtual_terminal_input + ) def cooked_mode(self) -> ContextManager[None]: return cooked_mode() @@ -555,6 +568,102 @@ def _handle_mouse(self, ev: MOUSE_EVENT_RECORD) -> list[KeyPress]: return [KeyPress(Keys.WindowsMouseEvent, data)] +class Vt100ConsoleInputReader: + """ + Similar to `ConsoleInputReader`, but for usage when + `ENABLE_VIRTUAL_TERMINAL_INPUT` is enabled. This assumes that Windows sends + us the right vt100 escape sequences and we parse those with our vt100 + parser. + + (Using this instead of `ConsoleInputReader` results in the "data" attribute + from the `KeyPress` instances to be more correct in edge cases, because + this responds to for instance the terminal being in application cursor keys + mode.) + """ + + def __init__(self) -> None: + self._fdcon = None + + self._buffer: list[KeyPress] = [] # Buffer to collect the Key objects. + self._vt100_parser = Vt100Parser( + lambda key_press: self._buffer.append(key_press) + ) + + # When stdin is a tty, use that handle, otherwise, create a handle from + # CONIN$. + self.handle: HANDLE + if sys.stdin.isatty(): + self.handle = HANDLE(windll.kernel32.GetStdHandle(STD_INPUT_HANDLE)) + else: + self._fdcon = os.open("CONIN$", os.O_RDWR | os.O_BINARY) + self.handle = HANDLE(msvcrt.get_osfhandle(self._fdcon)) + + def close(self) -> None: + "Close fdcon." + if self._fdcon is not None: + os.close(self._fdcon) + + def read(self) -> Iterable[KeyPress]: + """ + Return a list of `KeyPress` instances. It won't return anything when + there was nothing to read. (This function doesn't block.) + + http://msdn.microsoft.com/en-us/library/windows/desktop/ms684961(v=vs.85).aspx + """ + max_count = 2048 # Max events to read at the same time. + + read = DWORD(0) + arrtype = INPUT_RECORD * max_count + input_records = arrtype() + + # Check whether there is some input to read. `ReadConsoleInputW` would + # block otherwise. + # (Actually, the event loop is responsible to make sure that this + # function is only called when there is something to read, but for some + # reason this happened in the asyncio_win32 loop, and it's better to be + # safe anyway.) + if not wait_for_handles([self.handle], timeout=0): + return [] + + # Get next batch of input event. + windll.kernel32.ReadConsoleInputW( + self.handle, pointer(input_records), max_count, pointer(read) + ) + + # First, get all the keys from the input buffer, in order to determine + # whether we should consider this a paste event or not. + for key_data in self._get_keys(read, input_records): + self._vt100_parser.feed(key_data) + + # Return result. + result = self._buffer + self._buffer = [] + return result + + def _get_keys( + self, read: DWORD, input_records: Array[INPUT_RECORD] + ) -> Iterator[str]: + """ + Generator that yields `KeyPress` objects from the input records. + """ + for i in range(read.value): + ir = input_records[i] + + # Get the right EventType from the EVENT_RECORD. + # (For some reason the Windows console application 'cmder' + # [http://gooseberrycreative.com/cmder/] can return '0' for + # ir.EventType. -- Just ignore that.) + if ir.EventType in EventTypes: + ev = getattr(ir.Event, EventTypes[ir.EventType]) + + # Process if this is a key event. (We also have mouse, menu and + # focus events.) + if isinstance(ev, KEY_EVENT_RECORD) and ev.KeyDown: + u_char = ev.uChar.UnicodeChar + if u_char != "\x00": + yield u_char + + class _Win32Handles: """ Utility to keep track of which handles are connectod to which callbacks. @@ -700,8 +809,11 @@ class raw_mode: `raw_input` method of `.vt100_input`. """ - def __init__(self, fileno: int | None = None) -> None: + def __init__( + self, fileno: int | None = None, use_win10_virtual_terminal_input: bool = False + ) -> None: self.handle = HANDLE(windll.kernel32.GetStdHandle(STD_INPUT_HANDLE)) + self.use_win10_virtual_terminal_input = use_win10_virtual_terminal_input def __enter__(self) -> None: # Remember original mode. @@ -717,12 +829,15 @@ def _patch(self) -> None: ENABLE_LINE_INPUT = 0x0002 ENABLE_PROCESSED_INPUT = 0x0001 - windll.kernel32.SetConsoleMode( - self.handle, - self.original_mode.value - & ~(ENABLE_ECHO_INPUT | ENABLE_LINE_INPUT | ENABLE_PROCESSED_INPUT), + new_mode = self.original_mode.value & ~( + ENABLE_ECHO_INPUT | ENABLE_LINE_INPUT | ENABLE_PROCESSED_INPUT ) + if self.use_win10_virtual_terminal_input: + new_mode |= ENABLE_VIRTUAL_TERMINAL_INPUT + + windll.kernel32.SetConsoleMode(self.handle, new_mode) + def __exit__(self, *a: object) -> None: # Restore original mode windll.kernel32.SetConsoleMode(self.handle, self.original_mode) @@ -747,3 +862,25 @@ def _patch(self) -> None: self.original_mode.value | (ENABLE_ECHO_INPUT | ENABLE_LINE_INPUT | ENABLE_PROCESSED_INPUT), ) + + +def _is_win_vt100_input_enabled() -> bool: + """ + Returns True when we're running Windows and VT100 escape sequences are + supported. + """ + hconsole = HANDLE(windll.kernel32.GetStdHandle(STD_INPUT_HANDLE)) + + # Get original console mode. + original_mode = DWORD(0) + windll.kernel32.GetConsoleMode(hconsole, byref(original_mode)) + + try: + # Try to enable VT100 sequences. + result: int = windll.kernel32.SetConsoleMode( + hconsole, DWORD(ENABLE_VIRTUAL_TERMINAL_INPUT) + ) + + return result == 1 + finally: + windll.kernel32.SetConsoleMode(hconsole, original_mode) From e55cd1b0d58680ebb06b576ae607ff17a2c4c836 Mon Sep 17 00:00:00 2001 From: Jonathan Slenders <jonathan@slenders.be> Date: Fri, 17 Jan 2025 14:31:28 -0800 Subject: [PATCH 23/30] Use ANSI based mouse processing and bracketed paste on Windows. (#1961) Follow-up of https://github.com/prompt-toolkit/python-prompt-toolkit/pull/1958 On Windows, we should now enable bracketed paste if vt100 support is available. --- src/prompt_toolkit/output/windows10.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/src/prompt_toolkit/output/windows10.py b/src/prompt_toolkit/output/windows10.py index c39f3ecfd..2b7e596e4 100644 --- a/src/prompt_toolkit/output/windows10.py +++ b/src/prompt_toolkit/output/windows10.py @@ -66,15 +66,20 @@ def responds_to_cpr(self) -> bool: return False # We don't need this on Windows. def __getattr__(self, name: str) -> Any: + # NOTE: Now that we use "virtual terminal input" on + # Windows, both input and output are done through + # ANSI escape sequences on Windows. This means, we + # should enable bracketed paste like on Linux, and + # enable mouse support by calling the vt100_output. if name in ( "get_size", "get_rows_below_cursor_position", - "enable_mouse_support", - "disable_mouse_support", "scroll_buffer_to_prompt", "get_win32_screen_buffer_info", - "enable_bracketed_paste", - "disable_bracketed_paste", + # "enable_mouse_support", + # "disable_mouse_support", + # "enable_bracketed_paste", + # "disable_bracketed_paste", ): return getattr(self.win32_output, name) else: From 1b0efa1b1922b9f92457f3a0bdfa7099f0fc757e Mon Sep 17 00:00:00 2001 From: M Bussonnier <bussonniermatthias@gmail.com> Date: Fri, 17 Jan 2025 14:33:15 -0800 Subject: [PATCH 24/30] Basic multiline suggestion example (#1953) --- examples/prompts/multiline-autosuggest.py | 154 ++++++++++++++++++++++ pyproject.toml | 2 + 2 files changed, 156 insertions(+) create mode 100755 examples/prompts/multiline-autosuggest.py diff --git a/examples/prompts/multiline-autosuggest.py b/examples/prompts/multiline-autosuggest.py new file mode 100755 index 000000000..87f975a02 --- /dev/null +++ b/examples/prompts/multiline-autosuggest.py @@ -0,0 +1,154 @@ +#!/usr/bin/env python +""" +A more complex example of a CLI that demonstrates fish-style auto suggestion +across multiple lines. + +This can typically be used for LLM that may return multi-line responses. + +Note that unlike simple autosuggest, using multiline autosuggest requires more +care as it may shift the buffer layout, and care must taken ton consider the +various case when the number iof suggestions lines is longer than the number of +lines in the buffer, what happens to the existing text (is it pushed down, or +hidden until the suggestion is accepted) Etc. + +So generally multiline autosuggest will require a custom processor to handle the +different use case and user experience. + +We also have not hooked any keys to accept the suggestion, so it will be up to you +to decide how and when to accept the suggestion, accept it as a whole, like by line, or +token by token. +""" + +from prompt_toolkit import PromptSession +from prompt_toolkit.auto_suggest import AutoSuggest, Suggestion +from prompt_toolkit.enums import DEFAULT_BUFFER +from prompt_toolkit.filters import HasFocus, IsDone +from prompt_toolkit.layout.processors import ( + ConditionalProcessor, + Processor, + Transformation, + TransformationInput, +) + +universal_declaration_of_human_rights = """ +All human beings are born free and equal in dignity and rights. +They are endowed with reason and conscience and should act towards one another +in a spirit of brotherhood +Everyone is entitled to all the rights and freedoms set forth in this +Declaration, without distinction of any kind, such as race, colour, sex, +language, religion, political or other opinion, national or social origin, +property, birth or other status. Furthermore, no distinction shall be made on +the basis of the political, jurisdictional or international status of the +country or territory to which a person belongs, whether it be independent, +trust, non-self-governing or under any other limitation of sovereignty.""".strip().splitlines() + + +class FakeLLMAutoSuggest(AutoSuggest): + def get_suggestion(self, buffer, document): + if document.line_count == 1: + return Suggestion(" (Add a few new lines to see multiline completion)") + cursor_line = document.cursor_position_row + text = document.text.split("\n")[cursor_line] + if not text.strip(): + return None + index = None + for i, l in enumerate(universal_declaration_of_human_rights): + if l.startswith(text): + index = i + break + if index is None: + return None + return Suggestion( + universal_declaration_of_human_rights[index][len(text) :] + + "\n" + + "\n".join(universal_declaration_of_human_rights[index + 1 :]) + ) + + +class AppendMultilineAutoSuggestionInAnyLine(Processor): + def __init__(self, style: str = "class:auto-suggestion") -> None: + self.style = style + + def apply_transformation(self, ti: TransformationInput) -> Transformation: + # a convenient noop transformation that does nothing. + noop = Transformation(fragments=ti.fragments) + + # We get out of the way if the prompt is only one line, and let prompt_toolkit handle the rest. + if ti.document.line_count == 1: + return noop + + # first everything before the current line is unchanged. + if ti.lineno < ti.document.cursor_position_row: + return noop + + buffer = ti.buffer_control.buffer + if not buffer.suggestion or not ti.document.is_cursor_at_the_end_of_line: + return noop + + # compute the number delta between the current cursor line and line we are transforming + # transformed line can either be suggestions, or an existing line that is shifted. + delta = ti.lineno - ti.document.cursor_position_row + + # convert the suggestion into a list of lines + suggestions = buffer.suggestion.text.splitlines() + if not suggestions: + return noop + + if delta == 0: + # append suggestion to current line + suggestion = suggestions[0] + return Transformation(fragments=ti.fragments + [(self.style, suggestion)]) + elif delta < len(suggestions): + # append a line with the nth line of the suggestion + suggestion = suggestions[delta] + assert "\n" not in suggestion + return Transformation([(self.style, suggestion)]) + else: + # return the line that is by delta-1 suggestion (first suggestion does not shifts) + shift = ti.lineno - len(suggestions) + 1 + return Transformation(ti.get_line(shift)) + + +def main(): + # Create some history first. (Easy for testing.) + + autosuggest = FakeLLMAutoSuggest() + # Print help. + print("This CLI has fish-style auto-suggestion enabled across multiple lines.") + print("This will try to complete the universal declaration of human rights.") + print("") + print(" " + "\n ".join(universal_declaration_of_human_rights)) + print("") + print("Add a few new lines to see multiline completion, and start typing.") + print("Press Control-C to retry. Control-D to exit.") + print() + + session = PromptSession( + auto_suggest=autosuggest, + enable_history_search=False, + reserve_space_for_menu=5, + multiline=True, + prompt_continuation="... ", + input_processors=[ + ConditionalProcessor( + processor=AppendMultilineAutoSuggestionInAnyLine(), + filter=HasFocus(DEFAULT_BUFFER) & ~IsDone(), + ), + ], + ) + + while True: + try: + text = session.prompt( + "Say something (Esc-enter : accept, enter : new line): " + ) + except KeyboardInterrupt: + pass # Ctrl-C pressed. Try again. + else: + break + + print(f"You said: {text}") + + +if __name__ == "__main__": + main() diff --git a/pyproject.toml b/pyproject.toml index 57fc2d9f2..630f124f1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -64,4 +64,6 @@ extend-exclude = [ "tests/test_buffer.py", "tests/test_cli.py", "tests/test_regular_languages.py", + # complains about some spelling in human right declaration. + "examples/prompts/multiline-autosuggest.py", ] From a1950cd94a04be4b0a798bc91eac7ca5bb3a1599 Mon Sep 17 00:00:00 2001 From: M Bussonnier <bussonniermatthias@gmail.com> Date: Fri, 17 Jan 2025 14:45:59 -0800 Subject: [PATCH 25/30] Try to run ruff and typo only on 3.13 (#1954) --- .github/workflows/test.yaml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 6b391bacf..19f678447 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -30,9 +30,13 @@ jobs: python -m pip install . ruff typos coverage codecov mypy pytest readme_renderer types-contextvars asyncssh pip list - name: Ruff + if: ${{ matrix.python-version == '3.13' }} run: | ruff check . ruff format --check . + - name: Typos + if: ${{ matrix.python-version == '3.13'" && always() }} + run: | typos . - name: Tests run: | From 45713d469c6a6b3c2b457fd2f1cf7d7085071a70 Mon Sep 17 00:00:00 2001 From: Tapple Gao <tapplek@gmail.com> Date: Fri, 17 Jan 2025 14:47:54 -0800 Subject: [PATCH 26/30] Fix dead readline link in doc (#1941) --- docs/index.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/index.rst b/docs/index.rst index 569e14ccf..feba68fcc 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -6,7 +6,7 @@ and terminal applications in Python. It can be a very advanced pure Python replacement for `GNU readline -<http://cnswww.cns.cwru.edu/php/chet/readline/rltop.html>`_, but it can also be +<https://tiswww.case.edu/php/chet/readline/rltop.html>`_, but it can also be used for building full screen applications. .. image:: images/ptpython-2.png From c6ce1a5ff05a4b2574e1bb73a0c432db5d7866ae Mon Sep 17 00:00:00 2001 From: Jonathan Slenders <jonathan@slenders.be> Date: Mon, 20 Jan 2025 01:25:29 -0800 Subject: [PATCH 27/30] Fix Github Actions. (#1965) --- .github/workflows/test.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 19f678447..bff91c148 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -35,7 +35,7 @@ jobs: ruff check . ruff format --check . - name: Typos - if: ${{ matrix.python-version == '3.13'" && always() }} + if: ${{ matrix.python-version == '3.13' }} run: | typos . - name: Tests From ace74dbc7b0c1e3e6fb4f3a238ed0b7f120f2c06 Mon Sep 17 00:00:00 2001 From: Jonathan Slenders <jonathan@slenders.be> Date: Mon, 20 Jan 2025 01:43:47 -0800 Subject: [PATCH 28/30] Release 3.0.49 (#1964) --- CHANGELOG | 19 +++++++++++++++++++ docs/conf.py | 4 ++-- src/prompt_toolkit/__init__.py | 2 +- 3 files changed, 22 insertions(+), 3 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index b58229c3d..7da38c1e4 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,6 +1,25 @@ CHANGELOG ========= +3.0.49: 2025-01-20 +------------------ + +New features: +- On Windows, use virtual terminal input when available. +- Support for multiline suggestions. + +Fixes: +- Handle `InvalidStateError` during termination when using + `run_in_terminal`/`patch_stdout`. This can happen in some cases during + cancellation, probably when using anyio. +- Fix cursor that remains in hidden state when the application exits. This can + happen when the application doesn't show the cursor and `erase_when_done` is + being used. + +Breaking changes: +- Drop support for Python 3.7: + + 3.0.48: 2024-09-25 ------------------ diff --git a/docs/conf.py b/docs/conf.py index 13c50e804..e1b47cab9 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -54,9 +54,9 @@ # --------------------------------------------------------------------- # Versions. # The short X.Y version. -version = "3.0.48" +version = "3.0.49" # The full version, including alpha/beta/rc tags. -release = "3.0.48" +release = "3.0.49" # The URL pattern to match releases to ReadTheDocs URLs. docs_fmt_url = "https://python-prompt-toolkit.readthedocs.io/en/{release}/" # The list of releases to include in the dropdown. diff --git a/src/prompt_toolkit/__init__.py b/src/prompt_toolkit/__init__.py index 80da72d1e..43603eba1 100644 --- a/src/prompt_toolkit/__init__.py +++ b/src/prompt_toolkit/__init__.py @@ -28,7 +28,7 @@ from .shortcuts import PromptSession, print_formatted_text, prompt # Don't forget to update in `docs/conf.py`! -__version__ = "3.0.48" +__version__ = "3.0.49" assert pep440.match(__version__) From 76957c8571702309e211a7fa925f34ba468015e9 Mon Sep 17 00:00:00 2001 From: Jonathan Slenders <jonathan@slenders.be> Date: Mon, 20 Jan 2025 07:48:31 -0800 Subject: [PATCH 29/30] Optimize rendering. Don't output escape codes for hiding/showing the cursor if not needed. (#1968) This fixes a regression that was introduced in https://github.com/prompt-toolkit/python-prompt-toolkit/pull/1925 --- src/prompt_toolkit/output/vt100.py | 13 +++++++++++-- src/prompt_toolkit/renderer.py | 5 +++++ 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/src/prompt_toolkit/output/vt100.py b/src/prompt_toolkit/output/vt100.py index 069636b8c..90df21e55 100644 --- a/src/prompt_toolkit/output/vt100.py +++ b/src/prompt_toolkit/output/vt100.py @@ -436,6 +436,11 @@ def __init__( # default, we don't change them.) self._cursor_shape_changed = False + # Don't hide/show the cursor when this was already done. + # (`None` means that we don't know whether the cursor is visible or + # not.) + self._cursor_visible: bool | None = None + @classmethod def from_pty( cls, @@ -651,10 +656,14 @@ def cursor_backward(self, amount: int) -> None: self.write_raw("\x1b[%iD" % amount) def hide_cursor(self) -> None: - self.write_raw("\x1b[?25l") + if self._cursor_visible in (True, None): + self._cursor_visible = False + self.write_raw("\x1b[?25l") def show_cursor(self) -> None: - self.write_raw("\x1b[?12l\x1b[?25h") # Stop blinking cursor and show. + if self._cursor_visible in (False, None): + self._cursor_visible = True + self.write_raw("\x1b[?12l\x1b[?25h") # Stop blinking cursor and show. def set_cursor_shape(self, cursor_shape: CursorShape) -> None: if cursor_shape == CursorShape._NEVER_CHANGE: diff --git a/src/prompt_toolkit/renderer.py b/src/prompt_toolkit/renderer.py index d0590830d..8d5e03c19 100644 --- a/src/prompt_toolkit/renderer.py +++ b/src/prompt_toolkit/renderer.py @@ -353,6 +353,11 @@ def __init__( self.mouse_support = to_filter(mouse_support) self.cpr_not_supported_callback = cpr_not_supported_callback + # TODO: Move following state flags into `Vt100_Output`, similar to + # `_cursor_shape_changed` and `_cursor_visible`. But then also + # adjust the `Win32Output` to not call win32 APIs if nothing has + # to be changed. + self._in_alternate_screen = False self._mouse_support_enabled = False self._bracketed_paste_enabled = False From 165258d2f3ae594b50f16c7b50ffb06627476269 Mon Sep 17 00:00:00 2001 From: Jonathan Slenders <jonathan@slenders.be> Date: Mon, 20 Jan 2025 07:55:13 -0800 Subject: [PATCH 30/30] Release 3.0.50 (#1969) --- CHANGELOG | 8 ++++++++ docs/conf.py | 4 ++-- src/prompt_toolkit/__init__.py | 2 +- 3 files changed, 11 insertions(+), 3 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index 7da38c1e4..14e3f311b 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,6 +1,14 @@ CHANGELOG ========= +3.0.50: 2025-01-20 +------------------ + +Fixes: +- Fixes non user impacting regression on the output rendering. Don't render + cursor hide/show ANSI escape codes if not needed. + + 3.0.49: 2025-01-20 ------------------ diff --git a/docs/conf.py b/docs/conf.py index e1b47cab9..56c27487b 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -54,9 +54,9 @@ # --------------------------------------------------------------------- # Versions. # The short X.Y version. -version = "3.0.49" +version = "3.0.50" # The full version, including alpha/beta/rc tags. -release = "3.0.49" +release = "3.0.50" # The URL pattern to match releases to ReadTheDocs URLs. docs_fmt_url = "https://python-prompt-toolkit.readthedocs.io/en/{release}/" # The list of releases to include in the dropdown. diff --git a/src/prompt_toolkit/__init__.py b/src/prompt_toolkit/__init__.py index 43603eba1..94727e7cb 100644 --- a/src/prompt_toolkit/__init__.py +++ b/src/prompt_toolkit/__init__.py @@ -28,7 +28,7 @@ from .shortcuts import PromptSession, print_formatted_text, prompt # Don't forget to update in `docs/conf.py`! -__version__ = "3.0.49" +__version__ = "3.0.50" assert pep440.match(__version__)