From f509295953955b197eca6b35477295ca60640b05 Mon Sep 17 00:00:00 2001 From: Eugene Triguba Date: Tue, 21 May 2024 22:36:01 -0400 Subject: [PATCH] [3.13] gh-119357: Increase test coverage for keymap in _pyrepl (GH-119358) (cherry picked from commit 73ab83b27f105a4509046ce26e35f20d66625195) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Eugene Triguba Co-authored-by: Łukasz Langa --- Lib/_pyrepl/completing_reader.py | 2 +- Lib/_pyrepl/keymap.py | 63 ++++++++++------------ Lib/test/test_pyrepl/test_keymap.py | 82 ++++++++++++++++++++++------- 3 files changed, 94 insertions(+), 53 deletions(-) diff --git a/Lib/_pyrepl/completing_reader.py b/Lib/_pyrepl/completing_reader.py index 3f8506bfe8f8f8..c11d2dabdd2792 100644 --- a/Lib/_pyrepl/completing_reader.py +++ b/Lib/_pyrepl/completing_reader.py @@ -30,7 +30,7 @@ # types Command = commands.Command if False: - from .types import Callback, SimpleContextManager, KeySpec, CommandName + from .types import KeySpec, CommandName def prefix(wordlist: list[str], j: int = 0) -> str: diff --git a/Lib/_pyrepl/keymap.py b/Lib/_pyrepl/keymap.py index 9f1aff88e06f6a..2fb03d1952382f 100644 --- a/Lib/_pyrepl/keymap.py +++ b/Lib/_pyrepl/keymap.py @@ -19,38 +19,32 @@ # CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. """ -functions for parsing keyspecs +Keymap contains functions for parsing keyspecs and turning keyspecs into +appropriate sequences. -Support for turning keyspecs into appropriate sequences. +A keyspec is a string representing a sequence of key presses that can +be bound to a command. All characters other than the backslash represent +themselves. In the traditional manner, a backslash introduces an escape +sequence. -pyrepl uses it's own bastardized keyspec format, which is meant to be -a strict superset of readline's \"KEYSEQ\" format (which is to say -that if you can come up with a spec readline accepts that this -doesn't, you've found a bug and should tell me about it). - -Note that this is the `\\C-o' style of readline keyspec, not the -`Control-o' sort. - -A keyspec is a string representing a sequence of keypresses that can -be bound to a command. - -All characters other than the backslash represent themselves. In the -traditional manner, a backslash introduces a escape sequence. +pyrepl uses its own keyspec format that is meant to be a strict superset of +readline's KEYSEQ format. This means that if a spec is found that readline +accepts that this doesn't, it should be logged as a bug. Note that this means +we're using the `\\C-o' style of readline's keyspec, not the `Control-o' sort. The extension to readline is that the sequence \\ denotes the -sequence of charaters produced by hitting KEY. +sequence of characters produced by hitting KEY. Examples: - -`a' - what you get when you hit the `a' key +`a' - what you get when you hit the `a' key `\\EOA' - Escape - O - A (up, on my terminal) `\\' - the up arrow key -`\\' - ditto (keynames are case insensitive) +`\\' - ditto (keynames are case-insensitive) `\\C-o', `\\c-o' - control-o `\\M-.' - meta-period `\\E.' - ditto (that's how meta works for pyrepl) `\\', `\\', `\\t', `\\011', '\\x09', '\\X09', '\\C-i', '\\C-I' - - all of these are the tab character. Can you think of any more? + - all of these are the tab character. """ _escapes = { @@ -111,7 +105,17 @@ class KeySpecError(Exception): pass -def _parse_key1(key, s): +def parse_keys(keys: str) -> list[str]: + """Parse keys in keyspec format to a sequence of keys.""" + s = 0 + r: list[str] = [] + while s < len(keys): + k, s = _parse_single_key_sequence(keys, s) + r.extend(k) + return r + + +def _parse_single_key_sequence(key: str, s: int) -> tuple[list[str], int]: ctrl = 0 meta = 0 ret = "" @@ -183,20 +187,11 @@ def _parse_key1(key, s): ret = f"ctrl {ret}" else: raise KeySpecError("\\C- followed by invalid key") - if meta: - ret = ["\033", ret] - else: - ret = [ret] - return ret, s - -def parse_keys(key): - s = 0 - r = [] - while s < len(key): - k, s = _parse_key1(key, s) - r.extend(k) - return r + result = [ret], s + if meta: + result[0].insert(0, "\033") + return result def compile_keymap(keymap, empty=b""): diff --git a/Lib/test/test_pyrepl/test_keymap.py b/Lib/test/test_pyrepl/test_keymap.py index 419f1643030b9d..2c97066b2c7043 100644 --- a/Lib/test/test_pyrepl/test_keymap.py +++ b/Lib/test/test_pyrepl/test_keymap.py @@ -1,41 +1,78 @@ +import string import unittest -from _pyrepl.keymap import parse_keys, compile_keymap +from _pyrepl.keymap import _keynames, _escapes, parse_keys, compile_keymap, KeySpecError class TestParseKeys(unittest.TestCase): def test_single_character(self): - self.assertEqual(parse_keys("a"), ["a"]) - self.assertEqual(parse_keys("b"), ["b"]) - self.assertEqual(parse_keys("1"), ["1"]) + """Ensure that single ascii characters or single digits are parsed as single characters.""" + test_cases = [(key, [key]) for key in string.ascii_letters + string.digits] + for test_key, expected_keys in test_cases: + with self.subTest(f"{test_key} should be parsed as {expected_keys}"): + self.assertEqual(parse_keys(test_key), expected_keys) + + def test_keynames(self): + """Ensure that keynames are parsed to their corresponding mapping. + + A keyname is expected to be of the following form: \\ such as \\ + which would get parsed as "left". + """ + test_cases = [(f"\\<{keyname}>", [parsed_keyname]) for keyname, parsed_keyname in _keynames.items()] + for test_key, expected_keys in test_cases: + with self.subTest(f"{test_key} should be parsed as {expected_keys}"): + self.assertEqual(parse_keys(test_key), expected_keys) def test_escape_sequences(self): - self.assertEqual(parse_keys("\\n"), ["\n"]) - self.assertEqual(parse_keys("\\t"), ["\t"]) - self.assertEqual(parse_keys("\\\\"), ["\\"]) - self.assertEqual(parse_keys("\\'"), ["'"]) - self.assertEqual(parse_keys('\\"'), ['"']) + """Ensure that escaping sequences are parsed to their corresponding mapping.""" + test_cases = [(f"\\{escape}", [parsed_escape]) for escape, parsed_escape in _escapes.items()] + for test_key, expected_keys in test_cases: + with self.subTest(f"{test_key} should be parsed as {expected_keys}"): + self.assertEqual(parse_keys(test_key), expected_keys) def test_control_sequences(self): - self.assertEqual(parse_keys("\\C-a"), ["\x01"]) - self.assertEqual(parse_keys("\\C-b"), ["\x02"]) - self.assertEqual(parse_keys("\\C-c"), ["\x03"]) + """Ensure that supported control sequences are parsed successfully.""" + keys = ["@", "[", "]", "\\", "^", "_", "\\", "\\"] + keys.extend(string.ascii_letters) + test_cases = [(f"\\C-{key}", chr(ord(key) & 0x1F)) for key in []] + for test_key, expected_keys in test_cases: + with self.subTest(f"{test_key} should be parsed as {expected_keys}"): + self.assertEqual(parse_keys(test_key), expected_keys) def test_meta_sequences(self): self.assertEqual(parse_keys("\\M-a"), ["\033", "a"]) self.assertEqual(parse_keys("\\M-b"), ["\033", "b"]) self.assertEqual(parse_keys("\\M-c"), ["\033", "c"]) - def test_keynames(self): - self.assertEqual(parse_keys("\\"), ["up"]) - self.assertEqual(parse_keys("\\"), ["down"]) - self.assertEqual(parse_keys("\\"), ["left"]) - self.assertEqual(parse_keys("\\"), ["right"]) - def test_combinations(self): self.assertEqual(parse_keys("\\C-a\\n\\"), ["\x01", "\n", "up"]) self.assertEqual(parse_keys("\\M-a\\t\\"), ["\033", "a", "\t", "down"]) + def test_keyspec_errors(self): + cases = [ + ("\\Ca", "\\C must be followed by `-'"), + ("\\ca", "\\C must be followed by `-'"), + ("\\C-\\C-", "doubled \\C-"), + ("\\Ma", "\\M must be followed by `-'"), + ("\\ma", "\\M must be followed by `-'"), + ("\\M-\\M-", "doubled \\M-"), + ("\\", "unrecognised keyname"), + ("\\大", "unknown backslash escape"), + ("\\C-\\", "\\C- followed by invalid key") + ] + for test_keys, expected_err in cases: + with self.subTest(f"{test_keys} should give error {expected_err}"): + with self.assertRaises(KeySpecError) as e: + parse_keys(test_keys) + self.assertIn(expected_err, str(e.exception)) + + def test_index_errors(self): + test_cases = ["\\", "\\C", "\\C-\\C"] + for test_keys in test_cases: + with self.assertRaises(IndexError): + parse_keys(test_keys) + class TestCompileKeymap(unittest.TestCase): def test_empty_keymap(self): @@ -72,3 +109,12 @@ def test_nested_multiple_keymaps(self): keymap = {b"a": {b"b": {b"c": "action"}}} result = compile_keymap(keymap) self.assertEqual(result, {b"a": {b"b": {b"c": "action"}}}) + + def test_clashing_definitions(self): + km = {b'a': 'c', b'a' + b'b': 'd'} + with self.assertRaises(KeySpecError): + compile_keymap(km) + + def test_non_bytes_key(self): + with self.assertRaises(TypeError): + compile_keymap({123: 'a'})