diff --git a/Lib/_pyrepl/base_eventqueue.py b/Lib/_pyrepl/base_eventqueue.py index f520ffd7d8ad11d..cb823ae50693049 100644 --- a/Lib/_pyrepl/base_eventqueue.py +++ b/Lib/_pyrepl/base_eventqueue.py @@ -30,8 +30,13 @@ from .console import Event from .trace import trace +PASTE_KEYCODES = { + b'\033[200~': 'bracketed paste', +} + class BaseEventQueue: def __init__(self, encoding: str, keymap_dict: dict[bytes, str]) -> None: + keymap_dict.update(PASTE_KEYCODES) self.compiled_keymap = keymap.compile_keymap(keymap_dict) self.keymap = self.compiled_keymap trace("keymap {k!r}", k=self.keymap) diff --git a/Lib/_pyrepl/historical_reader.py b/Lib/_pyrepl/historical_reader.py index 77cd12e66e03c62..4a2c3ef19ba9350 100644 --- a/Lib/_pyrepl/historical_reader.py +++ b/Lib/_pyrepl/historical_reader.py @@ -45,6 +45,7 @@ (r"\C-c", "isearch-cancel"), (r"\C-g", "isearch-cancel"), (r"\", "isearch-backspace"), + (r"\", "isearch-bracketed-paste"), ] ) @@ -211,6 +212,22 @@ def do(self) -> None: r.invalidate_prompt() +class isearch_bracketed_paste(commands.Command): + def do(self) -> None: + r = self.reader + b = r.buffer + done = "\x1b[201~" + data = "" + while done not in data: + ev = r.console.getpending() + data += ev.data + paste_content = data.replace(done, "") + r.isearch_term += paste_content + r.invalidate_prompt() + if "".join(b[r.pos:r.pos+len(r.isearch_term)]) != r.isearch_term: + r.isearch_next() + + @dataclass class HistoricalReader(Reader): """Adds history support (with incremental history searching) to the @@ -245,6 +262,7 @@ def __post_init__(self) -> None: isearch_backspace, isearch_forwards, isearch_backwards, + isearch_bracketed_paste, operate_and_get_next, history_search_backward, history_search_forward, diff --git a/Lib/_pyrepl/keymap.py b/Lib/_pyrepl/keymap.py index d11df4b5164696e..aebf8b0fe579856 100644 --- a/Lib/_pyrepl/keymap.py +++ b/Lib/_pyrepl/keymap.py @@ -98,6 +98,7 @@ "space": " ", "tab": "\t", "up": "up", + "bracketed paste": "bracketed paste", } diff --git a/Lib/_pyrepl/reader.py b/Lib/_pyrepl/reader.py index 832e67b534f2969..1b4ccf308f769af 100644 --- a/Lib/_pyrepl/reader.py +++ b/Lib/_pyrepl/reader.py @@ -128,7 +128,7 @@ def make_default_commands() -> dict[CommandName, CommandClass]: (r"\M-9", "digit-arg"), (r"\M-\n", "accept"), ("\\\\", "self-insert"), - (r"\x1b[200~", "perform-bracketed-paste"), + (r"\", "perform-bracketed-paste"), (r"\x03", "ctrl-c"), ] + [(c, "self-insert") for c in map(chr, range(32, 127)) if c != "\\"] diff --git a/Lib/test/test_pyrepl/test_eventqueue.py b/Lib/test/test_pyrepl/test_eventqueue.py index 56ab43211847a49..6d395fb52b09c59 100644 --- a/Lib/test/test_pyrepl/test_eventqueue.py +++ b/Lib/test/test_pyrepl/test_eventqueue.py @@ -95,6 +95,16 @@ def test_push_with_keymap_in_keymap_and_escape(self, mock_keymap): self.assertEqual(eq.events[1].evt, "key") self.assertEqual(eq.events[1].data, "b") + def test_push_bracketed_paste(self): + eq = self.make_eventqueue() + for byte in b"\x1b[200~": + eq.push(byte) + event = eq.get() + self.assertIsNotNone(event) + self.assertEqual(event.evt, "key") + self.assertEqual(event.data, "bracketed paste") + self.assertEqual(event.raw, b"\x1b[200~") + def test_push_special_key(self): eq = self.make_eventqueue() eq.keymap = {} diff --git a/Lib/test/test_pyrepl/test_pyrepl.py b/Lib/test/test_pyrepl/test_pyrepl.py index 04a7a1b7f56751e..00d6ae9a2c5c32d 100644 --- a/Lib/test/test_pyrepl/test_pyrepl.py +++ b/Lib/test/test_pyrepl/test_pyrepl.py @@ -1953,11 +1953,11 @@ def test_bracketed_paste(self): ) # fmt: on - paste_start = "\x1b[200~" + paste_start = [Event(evt="key", data="bracketed paste", raw=bytearray(b"\x1b[200~"))] paste_end = "\x1b[201~" events = itertools.chain( - code_to_events(paste_start), + paste_start, code_to_events(input_code), code_to_events(paste_end), code_to_events("\n"), @@ -1969,11 +1969,11 @@ def test_bracketed_paste(self): def test_bracketed_paste_single_line(self): input_code = "oneline" - paste_start = "\x1b[200~" + paste_start = [Event(evt="key", data="bracketed paste", raw=bytearray(b"\x1b[200~"))] paste_end = "\x1b[201~" events = itertools.chain( - code_to_events(paste_start), + paste_start, code_to_events(input_code), code_to_events(paste_end), code_to_events("\n"), @@ -1982,6 +1982,48 @@ def test_bracketed_paste_single_line(self): output = multiline_input(reader) self.assertEqual(output, input_code) + def test_bracketed_paste_in_isearch(self): + # The bracketed-paste *start* marker is translated into a synthetic + # ``bracketed paste`` event by the real event queue; the fake console + # used here does not run that translation, so we feed the + # ``bracketed paste`` event directly. + paste_start = [Event(evt="key", data="bracketed paste", raw=bytearray(b"\x1b[200~"))] + paste_end = "\x1b[201~" + + events = itertools.chain( + # Add some history + code_to_events("print('hello')\n"), + # Search for 'hello' + [ + Event(evt="key", data="\x12", raw=bytearray(b"\x12")), + ], + paste_start, + code_to_events("hello"), + code_to_events(paste_end), + [ + Event(evt="key", data="\n", raw=bytearray(b"\n")), + Event(evt="key", data="\n", raw=bytearray(b"\n")), + ], + [ + Event(evt="key", data="\x12", raw=bytearray(b"\x12")), + ], + # Search for 'world', which should not be found + paste_start, + code_to_events("world"), + code_to_events(paste_end), + [ + Event(evt="key", data="\n", raw=bytearray(b"\n")), + Event(evt="key", data="\n", raw=bytearray(b"\n")), + ], + ) + + reader = self.prepare_reader(events) + multiline_input(reader) + output = multiline_input(reader) + self.assertEqual(output, "print('hello')") + output = multiline_input(reader) + self.assertEqual(output, "") + @skipUnless(pty, "requires pty") class TestDumbTerminal(ReplTestCase): diff --git a/Misc/NEWS.d/next/Library/2026-03-02-02-51-27.gh-issue-145375.j9r8TS.rst b/Misc/NEWS.d/next/Library/2026-03-02-02-51-27.gh-issue-145375.j9r8TS.rst new file mode 100644 index 000000000000000..4f9eda901db34f2 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-03-02-02-51-27.gh-issue-145375.j9r8TS.rst @@ -0,0 +1 @@ +Handle bracketed paste in :mod:`!_pyrepl` isearch mode.