From 84fa6f121b20397c651f13b03c7919d040b66e7a Mon Sep 17 00:00:00 2001 From: Tal Einat <532281+taleinat@users.noreply.github.com> Date: Wed, 7 Aug 2019 01:17:04 +0300 Subject: [PATCH] IDLE: add completion of dict keys of type str Signed-off-by: Tal Einat <532281+taleinat@users.noreply.github.com> --- Doc/library/idle.rst | 12 +- Lib/idlelib/autocomplete.py | 217 ++++++- Lib/idlelib/autocomplete_w.py | 166 ++++- Lib/idlelib/editor.py | 3 +- Lib/idlelib/idle_test/mock_tk.py | 364 +++++++++-- Lib/idlelib/idle_test/test_autocomplete.py | 567 +++++++++++++++--- Lib/idlelib/idle_test/test_text.py | 8 +- .../2019-08-07-23-27-54.bpo-21261.sqNQCD.rst | 2 + 8 files changed, 1160 insertions(+), 179 deletions(-) create mode 100644 Misc/NEWS.d/next/IDLE/2019-08-07-23-27-54.bpo-21261.sqNQCD.rst diff --git a/Doc/library/idle.rst b/Doc/library/idle.rst index 3c302115b5f408..1673bf0ee88d6e 100644 --- a/Doc/library/idle.rst +++ b/Doc/library/idle.rst @@ -475,10 +475,10 @@ Completions ^^^^^^^^^^^ Completions are supplied, when requested and available, for module -names, attributes of classes or functions, or filenames. Each request -method displays a completion box with existing names. (See tab -completions below for an exception.) For any box, change the name -being completed and the item highlighted in the box by +names, attributes of classes or functions, dict keys or filenames. +Each request method displays a completion box with existing names. +(See tab completions below for an exception.) For any box, change +the name being completed and the item highlighted in the box by typing and deleting characters; by hitting :kbd:`Up`, :kbd:`Down`, :kbd:`PageUp`, :kbd:`PageDown`, :kbd:`Home`, and :kbd:`End` keys; and by a single click within the box. Close the box with :kbd:`Escape`, @@ -489,8 +489,8 @@ One way to open a box is to type a key character and wait for a predefined interval. This defaults to 2 seconds; customize it in the settings dialog. (To prevent auto popups, set the delay to a large number of milliseconds, such as 100000000.) For imported module -names or class or function attributes, type '.'. -For filenames in the root directory, type :data:`os.sep` or +names or class or function attributes, type '.'. For dict keys, type +'['. For filenames in the root directory, type :data:`os.sep` or :data:`os.altsep` immediately after an opening quote. (On Windows, one can specify a drive first.) Move into subdirectories by typing a directory name and a separator. diff --git a/Lib/idlelib/autocomplete.py b/Lib/idlelib/autocomplete.py index bb7ee035c4fefb..d1676a2e1a0a52 100644 --- a/Lib/idlelib/autocomplete.py +++ b/Lib/idlelib/autocomplete.py @@ -5,29 +5,32 @@ """ import __main__ import keyword +import itertools import os +import re import string import sys -# Two types of completions; defined here for autocomplete_w import below. -ATTRS, FILES = 0, 1 +# Three types of completions; defined here for autocomplete_w import below. +ATTRS, FILES, DICTKEYS = 0, 1, 2 from idlelib import autocomplete_w from idlelib.config import idleConf from idlelib.hyperparser import HyperParser # Tuples passed to open_completions. -# EvalFunc, Complete, WantWin, Mode -FORCE = True, False, True, None # Control-Space. -TAB = False, True, True, None # Tab. -TRY_A = False, False, False, ATTRS # '.' for attributes. -TRY_F = False, False, False, FILES # '/' in quotes for file name. +# EvalFunc, Complete, Mode +FORCE = True, False, None # Control-Space. +TAB = False, True, None # Tab. +TRY_A = False, False, ATTRS # '.' for attributes. +TRY_D = False, False, DICTKEYS # '[' for dict keys. +TRY_F = False, False, FILES # '/' in quotes for file name. # This string includes all chars that may be in an identifier. # TODO Update this here and elsewhere. ID_CHARS = string.ascii_letters + string.digits + "_" SEPS = f"{os.sep}{os.altsep if os.altsep else ''}" -TRIGGERS = f".{SEPS}" +TRIGGERS = f".[{SEPS}" class AutoComplete: @@ -43,6 +46,8 @@ def __init__(self, editwin=None, tags=None): self._delayed_completion_id = None self._delayed_completion_index = None + self._make_dict_key_reprs = self._make_dict_key_repr_func() + @classmethod def reload(cls): cls.popupwait = idleConf.GetOption( @@ -76,10 +81,10 @@ def autocomplete_event(self, event): return "break" if opened else None def try_open_completions_event(self, event=None): - "(./) Open completion list after pause with no movement." + r"""(./[) Open completion list after pause with no movement.""" lastchar = self.text.get("insert-1c") if lastchar in TRIGGERS: - args = TRY_A if lastchar == "." else TRY_F + args = {".": TRY_A, "[": TRY_D}.get(lastchar, TRY_F) self._delayed_completion_index = self.text.index("insert") if self._delayed_completion_id is not None: self.text.after_cancel(self._delayed_completion_id) @@ -92,6 +97,154 @@ def _delayed_open_completions(self, args): if self.text.index("insert") == self._delayed_completion_index: self.open_completions(args) + _dict_str_key_prefix_re = re.compile(r'\s*(fr|rf|f|r|u|)', re.I) + + @classmethod + def _is_completing_dict_key(cls, hyper_parser): + hp = hyper_parser + if hp.is_in_string(): + # hp.indexbracket is an opening quote; + # check if the bracket before it is '['. + if hp.indexbracket == 0: + return None + opening_bracket_idx = hp.bracketing[hp.indexbracket - 1][0] + if hp.rawtext[opening_bracket_idx] != '[': + return None + # The completion start is everything from the '[', except initial + # whitespace. Check that it's a valid beginning of a str/bytes + # literal. + opening_quote_idx = hp.bracketing[hp.indexbracket][0] + m = cls._dict_str_key_prefix_re.fullmatch( + hp.rawtext[opening_bracket_idx+1:opening_quote_idx]) + if m is None: + return None + comp_start = \ + m.group(1) + hp.rawtext[opening_quote_idx:hp.indexinrawtext] + elif hp.is_in_code(): + # This checks the the last bracket is an opener, + # and also avoids an edge-case exception. + if not hp.isopener[hp.indexbracket]: + return None + # Check if the last opening bracket a '['. + opening_bracket_idx = hp.bracketing[hp.indexbracket][0] + if hp.rawtext[opening_bracket_idx] != '[': + return None + # We want to complete str/bytes dict keys only if the existing + # completion start could be the beginning of a literal. Note + # that an empty or all-whitespace completion start is acceptable. + m = cls._dict_str_key_prefix_re.fullmatch( + hp.rawtext[opening_bracket_idx + 1:hp.indexinrawtext]) + if m is None: + return None + comp_start = m.group(1) + else: + return None + + hp.set_index("insert-%dc" % (hp.indexinrawtext - opening_bracket_idx)) + comp_what = hp.get_expression() + return comp_what, comp_start + + @classmethod + def _make_dict_key_repr_func(cls): + # Prepare escaping utilities. + escape_re = re.compile(r'[\0-\15]') + escapes = {ord_: f'\\x{ord_:02x}' for ord_ in range(14)} + escapes.update({0: '\\0', 9: '\\t', 10: '\\n', 13: '\\r'}) + def control_char_escape(match): + """escaping for ASCII control characters 0-13""" + # This is needed to avoid potential visual artifacts displaying + # these characters, and even more importantly to be clear to users. + # + # The characters '\0', '\t', '\n' and '\r' are displayed as done + # here rather than as hex escape codes, which is nicer and clearer. + return escapes[ord(match.group())] + if sys.maxunicode >= 0x10000: + non_bmp_re = re.compile(f'[\\U00010000-\\U{sys.maxunicode:08x}]') + else: + non_bmp_re = re.compile(r'(?!)') # never matches anything + def non_bmp_sub(match): + """escaping for non-BMP unicode code points""" + # This is needed since Tk doesn't support displaying non-BMP + # unicode code points. + return f'\\U{ord(match.group()):08x}' + + def str_reprs(str_comps, prefix, is_raw, quote_type): + repr_prefix = prefix.replace('b', '').replace('B', '') + repr_template = f"{repr_prefix}{quote_type}{{}}{quote_type}" + if not is_raw: + # Escape back-slashes. + str_comps = [x.replace('\\', '\\\\') for x in str_comps] + # Escape quotes. + str_comps = [x.replace(quote_type, '\\' + quote_type) for x in + str_comps] + if len(quote_type) == 3: + # With triple-quotes, we need to escape the final character if + # it is a single quote of the same kind. + str_comps = [ + (x[:-1] + '\\' + x[-1]) if x[-1:] == quote_type[0] else x + for x in str_comps + ] + if not is_raw: + # Escape control characters. + str_comps = [escape_re.sub(control_char_escape, x) + for x in str_comps] + str_comps = [non_bmp_re.sub(non_bmp_sub, x) for x in str_comps] + str_comps = [repr_template.format(x) for x in str_comps] + else: + # Format as raw literals (r"...") except when there are control + # characters which must be escaped. + non_raw_prefix = repr_prefix.replace('r', '').replace('R', '') + non_raw_template = \ + f"{non_raw_prefix}{quote_type}{{}}{quote_type}" + str_comps = [ + repr_template.format(x) + if escape_re.search(x) is None and non_bmp_re.search( + x) is None + else non_raw_template.format( + non_bmp_re.sub(non_bmp_sub, + escape_re.sub(control_char_escape, + x))) + for x in str_comps + ] + return str_comps + + def analyze_prefix_and_quote_type(comp_start): + """Analyze existing prefix and quote type of a completion start""" + is_raw = False + quote_type = '"' + prefix_match = cls._dict_str_key_prefix_re.match(comp_start) + if prefix_match is not None: + prefix = prefix_match.group(1) + post_prefix = comp_start[prefix_match.end():] + + is_raw = 'r' in prefix.lower() + if post_prefix: + quote_type_candidate = post_prefix[0] + if quote_type_candidate not in "'\"": + pass # use the default quote type + elif post_prefix[1:2] == quote_type_candidate: + quote_type = quote_type_candidate * 3 + else: + quote_type = quote_type_candidate + else: + prefix = '' + + return prefix, is_raw, quote_type + + def _dict_key_reprs(comp_start, comp_list): + """Customized repr() for str. + + This retains the prefix and quote type of the completed partial + str literal, if they exist. + """ + prefix, is_raw, quote_type = \ + analyze_prefix_and_quote_type(comp_start) + + str_comps = [c for c in comp_list if type(c) == str] + return str_reprs(str_comps, prefix, is_raw, quote_type) + + return _dict_key_reprs + def open_completions(self, args): """Find the completions and create the AutoCompleteWindow. Return True if successful (no syntax error or so found). @@ -99,7 +252,7 @@ def open_completions(self, args): start of completion, won't open completions and return False. If mode is given, will open a completion list only in this mode. """ - evalfuncs, complete, wantwin, mode = args + evalfuncs, complete, mode = args # Cancel another delayed call, if it exists. if self._delayed_completion_id is not None: self.text.after_cancel(self._delayed_completion_id) @@ -108,7 +261,22 @@ def open_completions(self, args): hp = HyperParser(self.editwin, "insert") curline = self.text.get("insert linestart", "insert") i = j = len(curline) - if hp.is_in_string() and (not mode or mode==FILES): + + comp_lists = None + if mode in (None, DICTKEYS): + comp_what_and_start = self._is_completing_dict_key(hp) + if comp_what_and_start is not None: + comp_what, comp_start = comp_what_and_start + if comp_what and (evalfuncs or '(' not in comp_what): + comp_lists = self.fetch_completions(comp_what, DICTKEYS) + if comp_lists[0]: + self._remove_autocomplete_window() + mode = DICTKEYS + else: + comp_lists = None + if mode == DICTKEYS and comp_lists is None: + return None + if mode in (None, FILES) and hp.is_in_string(): # Find the beginning of the string. # fetch_completions will look at the file system to determine # whether the string value constitutes an actual file name @@ -125,7 +293,7 @@ def open_completions(self, args): while i and curline[i-1] not in "'\"": i -= 1 comp_what = curline[i:j] - elif hp.is_in_code() and (not mode or mode==ATTRS): + elif mode in (None, ATTRS) and hp.is_in_code(): self._remove_autocomplete_window() mode = ATTRS while i and (curline[i-1] in ID_CHARS or ord(curline[i-1]) > 127): @@ -139,18 +307,28 @@ def open_completions(self, args): return None else: comp_what = "" + elif mode == DICTKEYS: + pass else: return None if complete and not comp_what and not comp_start: return None - comp_lists = self.fetch_completions(comp_what, mode) + if comp_lists is None: + comp_lists = self.fetch_completions(comp_what, mode) + + if mode == DICTKEYS: + assert comp_lists[0] == comp_lists[1] + reprs = self._make_dict_key_reprs(comp_start, comp_lists[0]) + reprs.sort() + comp_lists = (reprs, list(reprs)) + if not comp_lists[0]: return None self.autocompletewindow = self._make_autocomplete_window() return not self.autocompletewindow.show_window( comp_lists, "insert-%dc" % len(comp_start), - complete, mode, wantwin) + complete, mode) def fetch_completions(self, what, mode): """Return a pair of lists of completions for something. The first list @@ -208,6 +386,15 @@ def fetch_completions(self, what, mode): except OSError: return [], [] + elif mode == DICTKEYS: + try: + entity = self.get_entity(what) + keys = entity.keys() if isinstance(entity, dict) else [] + except: + return [], [] + smalll = bigl = [k for k in keys if type(k) == str] + smalll.sort() + if not smalll: smalll = bigl return smalll, bigl diff --git a/Lib/idlelib/autocomplete_w.py b/Lib/idlelib/autocomplete_w.py index d3d1e6982bfb2e..a5e28e34931736 100644 --- a/Lib/idlelib/autocomplete_w.py +++ b/Lib/idlelib/autocomplete_w.py @@ -2,16 +2,22 @@ An auto-completion window for IDLE, used by the autocomplete extension """ import platform +import re -from tkinter import * +from tkinter import EventType, Listbox, TclError, Toplevel +from tkinter import BOTH, END, LEFT, RIGHT, VERTICAL, Y from tkinter.ttk import Scrollbar -from idlelib.autocomplete import FILES, ATTRS +from idlelib.autocomplete import FILES, ATTRS, DICTKEYS from idlelib.multicall import MC_SHIFT + +__all__ = ['AutoCompleteWindow'] + + HIDE_VIRTUAL_EVENT_NAME = "<>" HIDE_FOCUS_OUT_SEQUENCE = "" -HIDE_SEQUENCES = (HIDE_FOCUS_OUT_SEQUENCE, "") +HIDE_SEQUENCES = (HIDE_FOCUS_OUT_SEQUENCE, "", "") KEYPRESS_VIRTUAL_EVENT_NAME = "<>" # We need to bind event beyond so that the function will be called # before the default specific IDLE function @@ -24,6 +30,19 @@ WINCONFIG_SEQUENCE = "" DOUBLECLICK_SEQUENCE = "" + +_quote_re = re.compile(r"""["']""") + + +def _find_first_quote(string_): + """Return the index of the first quote in a string. + + If no quotes are found, returns -1. + """ + match = _quote_re.search(string_) + return match.start() if match is not None else -1 + + class AutoCompleteWindow: def __init__(self, widget, tags): @@ -50,9 +69,6 @@ def __init__(self, widget, tags): # The last typed start, used so that when the selection changes, # the new start will be as close as possible to the last typed one. self.lasttypedstart = None - # Do we have an indication that the user wants the completion window - # (for example, he clicked the list) - self.userwantswindow = None # event ids self.hideid = self.keypressid = self.listupdateid = \ self.winconfigid = self.keyreleaseid = self.doubleclickid = None @@ -92,11 +108,13 @@ def _complete_string(self, s): """Assuming that s is the prefix of a string in self.completions, return the longest string which is a prefix of all the strings which s is a prefix of them. If s is not a prefix of a string, return s. + + Returns the completed string and the number of possible completions. """ first = self._binary_search(s) if self.completions[first][:len(s)] != s: # There is not even one completion which s is a prefix of. - return s + return s, 0 # Find the end of the range of completions where s is a prefix of. i = first + 1 j = len(self.completions) @@ -108,8 +126,9 @@ def _complete_string(self, s): i = m + 1 last = i-1 - if first == last: # only one possible completion - return self.completions[first] + if first == last: # only one possible completion + completed = self.completions[first] + return completed, 1 # We should return the maximum prefix of first and last first_comp = self.completions[first] @@ -118,7 +137,12 @@ def _complete_string(self, s): i = len(s) while i < min_len and first_comp[i] == last_comp[i]: i += 1 - return first_comp[:i] + return first_comp[:i], last - first + 1 + + def _finalize_completion(self, completion): + if self.mode == DICTKEYS and _find_first_quote(completion) >= 0: + return completion + ']' + return completion def _selection_changed(self): """Call when the selection of the Listbox has changed. @@ -158,7 +182,40 @@ def _selection_changed(self): self.listbox.select_set(self._binary_search(self.start)) self._selection_changed() - def show_window(self, comp_lists, index, complete, mode, userWantsWin): + @classmethod + def _quote_closes_literal(cls, start, quotechar): + """Check whether the typed quote character closes an str literal. + + Note that start doesn't include the typed character. + """ + quote_index = _find_first_quote(start) + if not (quote_index >= 0 and start[quote_index] == quotechar): + return False + + # Recognize triple-quote literals. + if start[quote_index:quote_index + 3] == quotechar * 3: + # The start string must end with two such quotes. + if start[-2:] != quotechar * 2: + return False + quote = quotechar * 3 + else: + quote = quotechar + + # The start string must be long enough. + if len(start) - quote_index < len(quote) * 2 - 1: + return False + + # If there was an odd number of back-slashes just before + # the closing quote(s), then the quote hasn't been closed. + i = len(start) - (len(quote) - 1) + while ( + i >= quote_index + len(quote) + 2 and + start[i - 2:i] == '\\\\' + ): + i -= 2 + return start[i - 1] != '\\' + + def show_window(self, comp_lists, index, complete, mode): """Show the autocomplete list, bind events. If complete is True, complete the text, and if there is exactly @@ -170,16 +227,11 @@ def show_window(self, comp_lists, index, complete, mode, userWantsWin): self.startindex = self.widget.index(index) self.start = self.widget.get(self.startindex, "insert") if complete: - completed = self._complete_string(self.start) + completion, n_possible = self._complete_string(self.start) start = self.start - self._change_start(completed) - i = self._binary_search(completed) - if self.completions[i] == completed and \ - (i == len(self.completions)-1 or - self.completions[i+1][:len(completed)] != completed): - # There is exactly one matching completion - return completed == start - self.userwantswindow = userWantsWin + if n_possible == 1: + self._change_start(self._finalize_completion(completion)) + return completion == start self.lasttypedstart = self.start # Put widgets in place @@ -295,17 +347,20 @@ def hide_event(self, event): elif event.type == EventType.ButtonPress: # ButtonPress event only bind to self.widget self.hide_window() + elif event.type == EventType.KeyPress and event.keysym == 'Escape': + self.hide_window() + return "break" def listselect_event(self, event): if self.is_active(): - self.userwantswindow = True cursel = int(self.listbox.curselection()[0]) self._change_start(self.completions[cursel]) def doubleclick_event(self, event): # Put the selected completion in the text, and close the list cursel = int(self.listbox.curselection()[0]) - self._change_start(self.completions[cursel]) + completion = self.completions[cursel] + self._change_start(self._finalize_completion(completion)) self.hide_window() def keypress_event(self, event): @@ -352,22 +407,70 @@ def keypress_event(self, event): ("period", "space", "parenleft", "parenright", "bracketleft", "bracketright")) or \ (self.mode == FILES and keysym in - ("slash", "backslash", "quotedbl", "apostrophe")) \ + ("slash", "backslash", "quotedbl", + "quoteright", "apostrophe")) \ and not (state & ~MC_SHIFT): # If start is a prefix of the selection, but is not '' when # completing file names, put the whole # selected completion. Anyway, close the list. cursel = int(self.listbox.curselection()[0]) - if self.completions[cursel][:len(self.start)] == self.start \ - and (self.mode == ATTRS or self.start): - self._change_start(self.completions[cursel]) + completion = self.completions[cursel] + if ( + completion.startswith(self.start) and + not (self.mode == FILES and not self.start) + ): + self._change_start(completion) self.hide_window() return None + elif ( + self.mode == DICTKEYS and + keysym in ( + "quotedbl", "quoteright", "apostrophe", "bracketright" + ) + ): + # Close the completion window if completing a dict key and a + # str/bytes literal closing quote has been typed. + keysym2char = { + "quotedbl": '"', + "quoteright": "'", + "apostrophe": "'", + "bracketright": "]", + } + char = keysym2char[keysym] + if char in "'\"": + if self._quote_closes_literal(self.start, char): + # We'll let the event through, so the final quote char + # will be added by the Text widget receiving the event. + self.hide_window() + return None + elif char == "]": + # Close the completion list unless the closing bracket is + # typed inside a string literal. + has_quote = _find_first_quote(self.start) >= 0 + if (not has_quote) or self.start in self.completions: + if not has_quote: + cursel = int(self.listbox.curselection()[0]) + completion = self.completions[cursel] + if completion.startswith(self.start): + self._change_start(completion) + + # We'll let the event through, so the closing bracket char + # will be added by the Text widget receiving the event. + self.hide_window() + return None + + # This is normal editing of text. + self._change_start(self.start + char) + self.lasttypedstart = self.start + self.listbox.select_clear(0, int(self.listbox.curselection()[0])) + self.listbox.select_set(self._binary_search(self.start)) + self._selection_changed() + return "break" + elif keysym in ("Home", "End", "Prior", "Next", "Up", "Down") and \ not state: # Move the selection in the listbox - self.userwantswindow = True cursel = int(self.listbox.curselection()[0]) if keysym == "Home": newsel = 0 @@ -396,12 +499,12 @@ def keypress_event(self, event): if self.lastkey_was_tab: # two tabs in a row; insert current selection and close acw cursel = int(self.listbox.curselection()[0]) - self._change_start(self.completions[cursel]) + completion = self.completions[cursel] + self._change_start(self._finalize_completion(completion)) self.hide_window() return "break" else: # first tab; let AutoComplete handle the completion - self.userwantswindow = True self.lastkey_was_tab = True return None @@ -436,7 +539,10 @@ def is_active(self): return self.autocompletewindow is not None def complete(self): - self._change_start(self._complete_string(self.start)) + completion, n_possible = self._complete_string(self.start) + if n_possible == 1: + completion = self._finalize_completion(completion) + self._change_start(completion) # The selection doesn't change. def hide_window(self): diff --git a/Lib/idlelib/editor.py b/Lib/idlelib/editor.py index fcc8a3f08ccfe3..d53b7b5a042fba 100644 --- a/Lib/idlelib/editor.py +++ b/Lib/idlelib/editor.py @@ -303,7 +303,8 @@ def __init__(self, flist=None, filename=None, key=None, root=None): # (This probably needs to be done once in the process.) text.event_add('<>', '') text.event_add('<>', '', - '', '') + '', '', + '') text.event_add('<>', '') text.event_add('<>', '') text.event_add('<>', '', diff --git a/Lib/idlelib/idle_test/mock_tk.py b/Lib/idlelib/idle_test/mock_tk.py index db583553838fb3..a70865dc5790bb 100644 --- a/Lib/idlelib/idle_test/mock_tk.py +++ b/Lib/idlelib/idle_test/mock_tk.py @@ -3,6 +3,7 @@ A gui object is anything with a master or parent parameter, which is typically required in spite of what the doc strings say. """ +from bisect import bisect_left, bisect_right import re from _tkinter import TclError @@ -91,7 +92,91 @@ def tearDownClass(cls): showwarning = Mbox_func() # None -class Text: +class Misc: + def destroy(self): + pass + + def configure(self, cnf=None, **kw): + pass + + config = configure + + def cget(self, key): + pass + + __getitem__ = cget + + def __setitem__(self, key, value): + self.configure({key: value}) + + def bind(sequence=None, func=None, add=None): + "Bind to this widget at event sequence a call to function func." + pass + + def unbind(self, sequence, funcid=None): + """Unbind for this widget for event SEQUENCE the + function identified with FUNCID.""" + pass + + def event_add(self, virtual, *sequences): + """Bind a virtual event VIRTUAL (of the form <>) + to an event SEQUENCE such that the virtual event is triggered + whenever SEQUENCE occurs.""" + pass + + def event_delete(self, virtual, *sequences): + """Unbind a virtual event VIRTUAL from SEQUENCE.""" + pass + + def event_generate(self, sequence, **kw): + """Generate an event SEQUENCE. Additional + keyword arguments specify parameter of the event + (e.g. x, y, rootx, rooty).""" + pass + + def event_info(self, virtual=None): + """Return a list of all virtual events or the information + about the SEQUENCE bound to the virtual event VIRTUAL.""" + pass + + def focus_set(self): + pass + + +class Widget(Misc): + def pack_configure(self, cnf={}, **kw): + pass + pack = configure = config = pack_configure + + def pack_forget(self): + pass + forget = pack_forget + + def pack_info(self): + pass + info = pack_info + + +class YView: + """Mix-in class for querying and changing the vertical position + of a widget's window.""" + + def yview(self, *args): + """Query and change the vertical position of the view.""" + pass + + def yview_moveto(self, fraction): + """Adjusts the view in the window so that FRACTION of the + total height of the canvas is off-screen to the top.""" + pass + + def yview_scroll(self, number, what): + """Shift the y-view according to NUMBER which is measured in + "units" or "pages" (WHAT).""" + pass + + +class Text(Widget, YView): """A semi-functional non-gui replacement for tkinter.Text text editors. The mock's data model is that a text is a list of \n-terminated lines. @@ -111,6 +196,7 @@ def __init__(self, master=None, cnf={}, **kw): There are just a few Text-only options that affect text behavior. ''' self.data = ['', '\n'] + self.marks = {'insert': (1, 0)} def index(self, index): "Return string version of index decoded according to current text." @@ -129,53 +215,99 @@ def _decode(self, index, endflag=0): * 'line.char lineend', where lineend='lineend' (and char is ignored); * 'line.end', where end='end' (same as above); * 'insert', the positions before terminal \n; - * 'end', whose meaning depends on the endflag passed to ._endex. + * 'end', whose meaning depends on the endflag passed to ._index. * 'sel.first' or 'sel.last', where sel is a tag -- not implemented. """ if isinstance(index, (float, bytes)): index = str(index) try: - index=index.lower() + index = index.lower().strip() except AttributeError: raise TclError('bad text index "%s"' % index) from None - lastline = len(self.data) - 1 # same as number of text lines - if index == 'insert': - return lastline, len(self.data[lastline]) - 1 - elif index == 'end': - return self._endex(endflag) - - line, char = index.split('.') - line = int(line) - - # Out of bounds line becomes first or last ('end') index - if line < 1: - return 1, 0 - elif line > lastline: - return self._endex(endflag) - - linelength = len(self.data[line]) -1 # position before/at \n - if char.endswith(' lineend') or char == 'end': - return line, linelength - # Tk requires that ignored chars before ' lineend' be valid int - if m := re.fullmatch(r'end-(\d*)c', char, re.A): # Used by hyperparser. - return line, linelength - int(m.group(1)) - - # Out of bounds char becomes first or last index of line - char = int(char) - if char < 0: - char = 0 - elif char > linelength: - char = linelength + def clamp(value, min_val, max_val): + return max(min_val, min(max_val, value)) + + lastline = len(self.data) - 1 # same as number of text lines + + first_part = re.match(r'^[^-+\s]+', index).group(0) + + if first_part in self.marks: + line, char = self.marks[first_part] + elif first_part == 'end': + line, char = self._endex(endflag) + else: + line_str, char_str = first_part.split('.') + line = int(line_str) + + # Out of bounds line becomes first or last ('end') index + if line < 1: + line, char = 1, 0 + elif line > lastline: + line, char = self._endex(endflag) + else: + linelength = len(self.data[line]) + if char_str == 'end': + char = linelength - 1 + else: + char = clamp(int(char_str), 0, linelength - 1) + + part_matches = list(re.finditer(r''' + (?: + ([-+]\s*[1-9][0-9]*) + \s* + (?:(display|any)\s+)? + (chars|char|cha|ch|c| + indices|indice|indic|indi|ind|in|i| + lines|line|lin|li|l) + | + linestart|lineend|wordstart|wordend + ) + (?=[-+]|\s|$) + ''', index[len(first_part):], re.VERBOSE)) + + for m in part_matches: + part = m.group(0) + + if part == 'lineend': + linelength = len(self.data[line]) - 1 + char = linelength + elif part == 'linestart': + char = 0 + elif part == 'wordstart': + raise NotImplementedError + elif part == 'wordend': + raise NotImplementedError + else: + number, submod, type = m.groups() + delta = int(number) + if type[0] in ('c', 'i'): + # chars / indices + char += delta + if char < 0: + while line > 0: + line -= 1 + char += len(self.data[line]) + elif char >= len(self.data[line]): + while line < lastline: + char -= len(self.data[line]) + else: + assert type[0] == 'l' + # lines + line += delta + line = clamp(line, 1, lastline) + linelength = len(self.data[line]) + char = clamp(char, 0, linelength - 1) + return line, char def _endex(self, endflag): - '''Return position for 'end' or line overflow corresponding to endflag. + """Return position for 'end' or line overflow corresponding to endflag. - -1: position before terminal \n; for .insert(), .delete - 0: position after terminal \n; for .get, .delete index 1 - 1: same viewed as beginning of non-existent next line (for .index) - ''' + -1: position before terminal \n; for .insert(), .delete + 0: position after terminal \n; for .get, .delete index 1 + 1: same viewed as beginning of non-existent next line (for .index) + """ n = len(self.data) if endflag == 1: return n, 0 @@ -183,7 +315,7 @@ def _endex(self, endflag): n -= 1 return n, len(self.data[n]) + endflag - def insert(self, index, chars): + def insert(self, index, chars, tags=None): "Insert chars before the character at index." if not chars: # ''.splitlines() is [], not [''] @@ -198,6 +330,18 @@ def insert(self, index, chars): self.data[line+1:line+1] = chars[1:] self.data[line+len(chars)-1] += after + for mark in list(self.marks): + mark_line, mark_char = self.marks[mark] + if ( + (mark_line, mark_char) > (line, char) or + mark == 'insert' and (mark_line, mark_char) == (line, char) + ): + new_line = mark_line + len(chars) - 1 + new_char = mark_char + len(chars[-1]) + if mark_line > line: + new_char -= char + self.marks[mark] = (new_line, new_char) + def get(self, index1, index2=None): "Return slice from index1 to index2 (default is 'index1+1')." @@ -243,9 +387,20 @@ def delete(self, index1, index2=None): elif startline < endline: self.data[startline] = self.data[startline][:startchar] + \ self.data[endline][endchar:] - startline += 1 - for i in range(startline, endline+1): - del self.data[startline] + del self.data[startline+1:endline+1] + + for mark in list(self.marks): + mark_line, mark_char = self.marks[mark] + if (mark_line, mark_char) > (startline, startchar): + if (mark_line, mark_char) <= (endline, endchar): + (new_line, new_char) = (startline, startchar) + elif mark_line == endline: + new_line = startline + new_char = startchar + (mark_char - endchar) + else: # mark_line > endline + new_line = mark_line - (endline - startline) + new_char = mark_char + self.marks[mark] = (new_line, new_char) def compare(self, index1, op, index2): line1, char1 = self._decode(index1) @@ -271,15 +426,22 @@ def compare(self, index1, op, index2): def mark_set(self, name, index): "Set mark *name* before the character at index." - pass + self.marks[name] = self._decode(index) def mark_unset(self, *markNames): "Delete all marks in markNames." + for name in markNames: + if name == 'end' or '.' in name: + raise ValueError(f"Invalid mark name: {name}") + del self.marks[name] def tag_remove(self, tagName, index1, index2=None): "Remove tag tagName from all characters between index1 and index2." pass + def tag_prevrange(self, tagName, index1, index2=None): + return () + # The following Text methods affect the graphics screen and return None. # Doing nothing should always be sufficient for tests. @@ -293,15 +455,123 @@ def see(self, index): "Scroll screen to make the character at INDEX is visible." pass - # The following is a Misc method inherited by Text. - # It should properly go in a Misc mock, but is included here for now. - - def bind(sequence=None, func=None, add=None): - "Bind to this widget at event sequence a call to function func." - pass class Entry: "Mock for tkinter.Entry." def focus_set(self): pass + + +class Listbox(Widget, YView): + def __init__(self, master=None, cnf={}, **kw): + self._items = [] + self._selection = [] + + def _normalize_first_last(self, first, last): + first = self._index(first) + last = first if last is None else self._index(last) + if not (0 <= first < len(self._items)): + raise IndexError() + if not (0 <= last < len(self._items)): + raise IndexError() + return first, last + + def _index(self, index, end_after_last=False): + if index == 'end': + index = len(self._items) - (0 if end_after_last else 1) + elif index in ('active', 'anchor'): + raise NotImplementedError() + elif isinstance(index, str) and index.startswith('@'): + raise NotImplementedError() + else: + if not isinstance(index, int): + raise ValueError() + return index + + def curselection(self): + """Return the indices of currently selected item.""" + return list(self._selection) + + def delete(self, first, last=None): + """Delete items from FIRST to LAST (included).""" + first, last = self._normalize_first_last(first, last) + + if last < first: + return + self.selection_clear(first, last) + self._items[first:last+1] = [] + sel_idx = bisect_left(self._selection, first) + for i in range(sel_idx, len(self._selection)): + self._selection[i] -= (last - first + 1) + + def get(self, first, last=None): + """Get list of items from FIRST to LAST (included).""" + first, last = self._normalize_first_last(first, last) + + if last < first: + return [] + return self._items[first:last + 1] + + def index(self, index): + """Return index of item identified with INDEX.""" + index = self._index(index, end_after_last=True) + if not index >= 0: + raise IndexError + if index > len(self._items): + index = len(self._items) + return index + + def insert(self, index, *elements): + """Insert ELEMENTS at INDEX.""" + index = self._index(index, end_after_last=True) + if not index >= 0: + raise IndexError + self._items[index:index] = list(elements) + sel_index = bisect_left(self._selection, index) + for i in range(sel_index, len(self._selection)): + self._selection[i] += len(elements) + return "" + + def see(self, index): + """Scroll such that INDEX is visible.""" + index = self._index(index) + pass + + def selection_clear(self, first, last=None): + """Clear the selection from FIRST to LAST (included).""" + first, last = self._normalize_first_last(first, last) + + if last < first: + return [] + first_sel_idx = bisect_left(self._selection, first) + last_sel_idx = bisect_right(self._selection, last) + self._selection[first_sel_idx:last_sel_idx] = [] + + select_clear = selection_clear + + def selection_includes(self, index): + """Return 1 if INDEX is part of the selection.""" + index = self._index(index) + if not (0 <= index < len(self._items)): + raise IndexError() + return index in self._selection + + select_includes = selection_includes + + def selection_set(self, first, last=None): + """Set the selection from FIRST to LAST (included) without + changing the currently selected elements.""" + first, last = self._normalize_first_last(first, last) + + if last < first: + return [] + first_sel_idx = bisect_left(self._selection, first) + last_sel_idx = bisect_right(self._selection, last) + self._selection[first_sel_idx:last_sel_idx] = list(range(first, last+1)) + + select_set = selection_set + + def size(self): + """Return the number of elements in the listbox.""" + return len(self._items) diff --git a/Lib/idlelib/idle_test/test_autocomplete.py b/Lib/idlelib/idle_test/test_autocomplete.py index 642bb5db64dc34..dce0e85d32e775 100644 --- a/Lib/idlelib/idle_test/test_autocomplete.py +++ b/Lib/idlelib/idle_test/test_autocomplete.py @@ -1,7 +1,8 @@ "Test autocomplete, coverage 93%." +import itertools import unittest -from unittest.mock import Mock, patch +from unittest.mock import Mock, patch, DEFAULT from test.support import requires from tkinter import Tk, Text import os @@ -10,7 +11,7 @@ import idlelib.autocomplete as ac import idlelib.autocomplete_w as acw from idlelib.idle_test.mock_idle import Func -from idlelib.idle_test.mock_tk import Event +import idlelib.idle_test.mock_tk as mock_tk class DummyEditwin: @@ -71,7 +72,7 @@ def test_autocomplete_event(self): acp = self.autocomplete # Result of autocomplete event: If modified tab, None. - ev = Event(mc_state=True) + ev = mock_tk.Event(keysym='Tab', mc_state=True) self.assertIsNone(acp.autocomplete_event(ev)) del ev.mc_state @@ -104,6 +105,8 @@ def test_try_open_completions_event(self): trycompletions = acp.try_open_completions_event after = Func(result='after1') acp.text.after = after + cancel = Func() + acp.text.after_cancel = cancel # If no text or trigger, after not called. trycompletions() @@ -113,6 +116,7 @@ def test_try_open_completions_event(self): Equal(after.called, 0) # Attribute needed, no existing callback. + text.delete('1.0', 'end') text.insert('insert', ' re.') acp._delayed_completion_id = None trycompletions() @@ -123,16 +127,28 @@ def test_try_open_completions_event(self): Equal(cb1, 'after1') # File needed, existing callback cancelled. + text.delete('1.0', 'end') text.insert('insert', ' "./Lib/') after.result = 'after2' - cancel = Func() - acp.text.after_cancel = cancel trycompletions() Equal(acp._delayed_completion_index, text.index('insert')) Equal(cancel.args, (cb1,)) Equal(after.args, (acp.popupwait, acp._delayed_open_completions, ac.TRY_F)) - Equal(acp._delayed_completion_id, 'after2') + cb2 = acp._delayed_completion_id + Equal(cb2, 'after2') + + # Dict key needed, existing callback cancelled. + text.delete('1.0', 'end') + text.insert('insert', '{"a": 1}[') + after.result = 'after3' + trycompletions() + Equal(acp._delayed_completion_index, text.index('insert')) + Equal(cancel.args, (cb2,)) + Equal(after.args, + (acp.popupwait, acp._delayed_open_completions, ac.TRY_D)) + cb3 = acp._delayed_completion_id + Equal(cb3, 'after3') def test_delayed_open_completions(self): Equal = self.assertEqual @@ -162,81 +178,36 @@ def test_oc_cancel_comment(self): acp._delayed_completion_id = 'after' after = Func(result='after') acp.text.after_cancel = after - self.text.insert(1.0, '# comment') + self.text.insert('1.0', '# comment') none(acp.open_completions(ac.TAB)) # From 'else' after 'elif'. none(acp._delayed_completion_id) - def test_oc_no_list(self): - acp = self.autocomplete - fetch = Func(result=([],[])) - acp.fetch_completions = fetch - self.text.insert('1.0', 'object') - self.assertIsNone(acp.open_completions(ac.TAB)) - self.text.insert('insert', '.') - self.assertIsNone(acp.open_completions(ac.TAB)) - self.assertEqual(fetch.called, 2) - - - def test_open_completions_none(self): - # Test other two None returns. - none = self.assertIsNone - acp = self.autocomplete - - # No object for attributes or need call not allowed. - self.text.insert(1.0, '.') - none(acp.open_completions(ac.TAB)) - self.text.insert('insert', ' int().') - none(acp.open_completions(ac.TAB)) - - # Blank or quote trigger 'if complete ...'. - self.text.delete(1.0, 'end') - self.assertFalse(acp.open_completions(ac.TAB)) - self.text.insert('1.0', '"') - self.assertFalse(acp.open_completions(ac.TAB)) - self.text.delete('1.0', 'end') - - class dummy_acw: - __init__ = Func() - show_window = Func(result=False) - hide_window = Func() - - def test_open_completions(self): - # Test completions of files and attributes. - acp = self.autocomplete - fetch = Func(result=(['tem'],['tem', '_tem'])) - acp.fetch_completions = fetch - def make_acw(): return self.dummy_acw() - acp._make_autocomplete_window = make_acw - self.text.insert('1.0', 'int.') - acp.open_completions(ac.TAB) - self.assertIsInstance(acp.autocompletewindow, self.dummy_acw) - self.text.delete('1.0', 'end') +class FetchCompletionsTest(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.root = Mock() + cls.text = mock_tk.Text(cls.root) + cls.editor = DummyEditwin(cls.root, cls.text) - # Test files. - self.text.insert('1.0', '"t') - self.assertTrue(acp.open_completions(ac.TAB)) - self.text.delete('1.0', 'end') + def setUp(self): + self.autocomplete = ac.AutoComplete(self.editor) - def test_fetch_completions(self): + def test_attrs(self): # Test that fetch_completions returns 2 lists: - # For attribute completion, a large list containing all variables, and - # a small list containing non-private variables. - # For file completion, a large list containing all files in the path, - # and a small list containing files that do not start with '.'. + # 1. a small list containing all non-private variables + # 2. a big list containing all variables acp = self.autocomplete - small, large = acp.fetch_completions( - '', ac.ATTRS) - if hasattr(__main__, '__file__') and __main__.__file__ != ac.__file__: - self.assertNotIn('AutoComplete', small) # See issue 36405. # Test attributes s, b = acp.fetch_completions('', ac.ATTRS) - self.assertLess(len(small), len(large)) - self.assertTrue(all(filter(lambda x: x.startswith('_'), s))) - self.assertTrue(any(filter(lambda x: x.startswith('_'), b))) + self.assertLess(set(s), set(b)) + self.assertTrue(all([not x.startswith('_') for x in s])) - # Test smalll should respect to __all__. + if hasattr(__main__, '__file__') and __main__.__file__ != ac.__file__: + self.assertNotIn('AutoComplete', s) # See issue 36405. + + # Test smalll should respect __all__. with patch.dict('__main__.__dict__', {'__all__': ['a', 'b']}): s, b = acp.fetch_completions('', ac.ATTRS) self.assertEqual(s, ['a', 'b']) @@ -253,10 +224,15 @@ def test_fetch_completions(self): s, b = acp.fetch_completions('foo', ac.ATTRS) self.assertNotIn('_private', s) self.assertIn('_private', b) - self.assertEqual(s, [i for i in sorted(dir(mock)) if i[:1] != '_']) + self.assertEqual(s, [i for i in sorted(dir(mock)) if i[0] != '_']) self.assertEqual(b, sorted(dir(mock))) - # Test files + def test_files(self): + # Test that fetch_completions returns 2 lists: + # 1. a small list containing files that do not start with '.'. + # 2. a big list containing all files in the path + acp = self.autocomplete + def _listdir(path): # This will be patch and used in fetch_completions. if path == '.': @@ -272,28 +248,467 @@ def _listdir(path): self.assertEqual(s, ['monty', 'python']) self.assertEqual(b, ['.hidden', 'monty', 'python']) + def test_dict_keys(self): + # Test that fetch_completions returns 2 identical lists, containing all + # keys of the dict of type str. + acp = self.autocomplete + + # Test attributes with name entity. + mock = Mock() + mock._private = Mock() + test_dict = {'one': 1, b'two': 2, 3: 3, fr'four': 4} + with patch.dict('__main__.__dict__', {'test_dict': test_dict}): + s, b = acp.fetch_completions('test_dict', ac.DICTKEYS) + self.assertEqual(s, b) + self.assertEqual(s, ['four', 'one']) + def test_get_entity(self): # Test that a name is in the namespace of sys.modules and # __main__.__dict__. acp = self.autocomplete - Equal = self.assertEqual - Equal(acp.get_entity('int'), int) + self.assertEqual(acp.get_entity('int'), int) # Test name from sys.modules. mock = Mock() with patch.dict('sys.modules', {'tempfile': mock}): - Equal(acp.get_entity('tempfile'), mock) + self.assertEqual(acp.get_entity('tempfile'), mock) # Test name from __main__.__dict__. di = {'foo': 10, 'bar': 20} with patch.dict('__main__.__dict__', {'d': di}): - Equal(acp.get_entity('d'), di) + self.assertEqual(acp.get_entity('d'), di) # Test name not in namespace. with patch.dict('__main__.__dict__', {}): with self.assertRaises(NameError): - acp.get_entity('not_exist') + acp.get_entity('doesnt_exist') + + +class OpenCompletionsTest(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.root = Mock() + cls.text = mock_tk.Text(cls.root) + cls.editor = DummyEditwin(cls.root, cls.text) + + def setUp(self): + self.autocomplete = ac.AutoComplete(self.editor) + self.mock_acw = None + + def tearDown(self): + self.text.delete('1.0', 'end') + + def make_acw(self): + self.mock_acw = Mock() + self.mock_acw.show_window = Mock( + return_value=False, spec=acw.AutoCompleteWindow.show_window) + return self.mock_acw + + def open_completions(self, args): + acp = self.autocomplete + with patch.object(acp, '_make_autocomplete_window', self.make_acw): + return acp.open_completions(args) + + def test_open_completions_files(self): + acp = self.autocomplete + + self.text.insert('1.0', 'int.') + self.open_completions(ac.TAB) + mock_acw = self.mock_acw + + self.assertIs(acp.autocompletewindow, mock_acw) + mock_acw.show_window.assert_called_once() + comp_lists, index, complete, mode = \ + mock_acw.show_window.call_args[0] + self.assertEqual(mode, ac.ATTRS) + self.assertIn('bit_length', comp_lists[0]) + self.assertIn('bit_length', comp_lists[1]) + self.assertNotIn('__index__', comp_lists[0]) + self.assertIn('__index__', comp_lists[1]) + + def test_open_completions_attrs(self): + acp = self.autocomplete + + self.text.insert('1.0', '"t') + def _listdir(path): + return ['.hidden', 'monty', 'python'] + with patch('os.listdir', _listdir): + self.open_completions(ac.TAB) + mock_acw = self.mock_acw + + mock_acw.show_window.assert_called_once() + comp_lists, index, complete, mode = \ + mock_acw.show_window.call_args[0] + self.assertEqual(mode, ac.FILES) + self.assertEqual(comp_lists[0], ['monty', 'python']) + self.assertEqual(comp_lists[1], ['.hidden', 'monty', 'python']) + + def test_open_completions_dict_keys_only_opening_quote(self): + test_dict = {'one': 1, b'two': 2, 3: 3, fr'four': 4} + + for quote in ['"', "'", '"""', "'''"]: + with self.subTest(quote=quote): + self.text.delete('1.0', 'end') + self.text.insert('1.0', f'test_dict[{quote}') + with patch.dict('__main__.__dict__', + {'test_dict': test_dict}): + self.open_completions(ac.TAB) + mock_acw = self.mock_acw + + mock_acw.show_window.assert_called_once() + comp_lists, index, complete, mode = \ + mock_acw.show_window.call_args[0] + self.assertEqual(mode, ac.DICTKEYS) + expected = [f'{quote}four{quote}', f'{quote}one{quote}'] + self.assertEqual(expected, comp_lists[0]) + self.assertEqual(expected, comp_lists[1]) + + def test_open_completions_dict_keys_no_opening_quote(self): + test_dict = {'one': 1, b'two': 2, 3: 3, fr'four': 4} + + self.text.insert('1.0', f'test_dict[') + with patch.dict('__main__.__dict__', + {'test_dict': test_dict}): + self.open_completions(ac.TAB) + mock_acw = self.mock_acw + + mock_acw.show_window.assert_called_once() + comp_lists, index, complete, mode = \ + mock_acw.show_window.call_args[0] + self.assertEqual(mode, ac.DICTKEYS) + expected = [f'"four"', f'"one"'] + self.assertEqual(expected, comp_lists[0]) + self.assertEqual(expected, comp_lists[1]) + + def test_open_completions_dict_keys_in_middle_of_string(self): + test_dict = {'one': 1, b'two': 2, 3: 3, fr'four': 4} + + self.text.insert('1.0', f'test_dict["one"]') + for insert_pos in range(len('test_dict['), len('test_dict["one') + 1): + for oc_args in (ac.TAB, ac.FORCE, ac.TRY_D): + with self.subTest(insert_pos=insert_pos, oc_args=oc_args): + self.text.mark_set('insert', f'1.{insert_pos}') + with patch.dict('__main__.__dict__', + {'test_dict': test_dict}): + self.open_completions(oc_args) + mock_acw = self.mock_acw + + mock_acw.show_window.assert_called_once() + comp_lists, index, complete, mode = \ + mock_acw.show_window.call_args[0] + self.assertEqual(mode, ac.DICTKEYS) + expected = [f'"four"', f'"one"'] + self.assertEqual(expected, comp_lists[0]) + self.assertEqual(expected, comp_lists[1]) + + def test_open_completions_bracket_not_after_dict(self): + for code in ['[', 'a[', '{}[', '{1: 1}[', 'globals()[[']: + for oc_args in (ac.TAB, ac.FORCE, ac.TRY_D): + with self.subTest(code=code, oc_args=oc_args): + self.text.delete('1.0', 'end') + self.text.insert('1.0', code) + + self.mock_acw = None + self.open_completions(oc_args) + + if oc_args == ac.FORCE: + self.mock_acw.show_window.assert_called_once() + comp_lists, index, complete, mode = \ + self.mock_acw.show_window.call_args[0] + self.assertEqual(mode, ac.ATTRS) + else: + self.assertIsNone(self.mock_acw) + + def test_open_completions_bracket_in_string(self): + def _listdir(path): + # This will be patched and used in fetch_completions. + return ['monty', 'python', '.hidden'] + + for code in ['"[', '"a[', '"globals()[']: + for oc_args in (ac.TAB, ac.FORCE, ac.TRY_D): + with self.subTest(code=code, oc_args=oc_args): + self.text.delete('1.0', 'end') + self.text.insert('1.0', code) + + self.mock_acw = None + with patch.object(os, 'listdir', _listdir): + self.open_completions(oc_args) + + if oc_args in (ac.TAB, ac.FORCE): + self.mock_acw.show_window.assert_called_once() + comp_lists, index, complete, mode = \ + self.mock_acw.show_window.call_args[0] + self.assertEqual(mode, ac.FILES) + self.assertCountEqual(comp_lists[1], _listdir('.')) + else: + self.assertIsNone(self.mock_acw) + + def test_no_list(self): + acp = self.autocomplete + fetch = Func(result=([],[])) + acp.fetch_completions = fetch + none = self.assertIsNone + oc = self.open_completions + self.text.insert('1.0', 'object') + none(oc(ac.TAB)) + self.text.insert('insert', '.') + none(oc(ac.TAB)) + self.assertEqual(fetch.called, 2) + + def test_none(self): + # Test other two None returns. + none = self.assertIsNone + acp = self.autocomplete + oc = self.open_completions + + # No object for attributes or need call not allowed. + self.text.delete('1.0', 'end') + self.text.insert('1.0', '.') + none(oc(ac.TAB)) + none(oc(ac.TRY_A)) + self.text.insert('insert', ' int().') + none(oc(ac.TAB)) + none(oc(ac.TRY_A)) + + # Dict keys: No object for keys or need call not allowed. + self.text.delete('1.0', 'end') + self.text.insert('1.0', '[') + none(oc(ac.TRY_D)) + self.text.insert('insert', ' globals()[') + none(oc(ac.TRY_D)) + + # Blank or quote trigger 'if complete ...'. + self.text.delete('1.0', 'end') + self.assertFalse(oc(ac.TAB)) + self.text.insert('1.0', '"') + self.assertFalse(oc(ac.TAB)) + + +class ShowWindowTest(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.root = Mock() + cls.text = mock_tk.Text(cls.root) + cls.editor = DummyEditwin(cls.root, cls.text) + + def setUp(self): + self.autocomplete = ac.AutoComplete(self.editor) + + patcher = patch.multiple(acw, + Toplevel=DEFAULT, + Scrollbar=DEFAULT, + Listbox=mock_tk.Listbox) + patcher.start() + self.addCleanup(patcher.stop) + + def test_complete_dict_key_single_match(self): + acp = self.autocomplete + + test_dict = {'one': 1, b'two': 2, 3: 3} + + for quote, args in itertools.product( + ['"', "'", '"""', "'''"], + [ac.TAB, ac.FORCE] + ): + for text in [f'test_dict[{quote}', f'test_dict[{quote}o']: + with self.subTest(quote=quote, args=args, text=text): + self.text.delete('1.0', 'end') + self.text.insert('1.0', text) + with patch.dict('__main__.__dict__', + {'test_dict': test_dict}): + result = acp.open_completions(args) + + self.assertEqual(result, True) + # With one possible completion, the text should be updated + # only if the 'complete' flag is true. + expected_text = \ + text if not args[1] else f'test_dict[{quote}one{quote}]' + self.assertEqual(self.text.get('1.0', '1.end'), + expected_text) + + def test_complete_dict_key_multiple_matches(self): + acp = self.autocomplete + + test_dict = {'twelve': 12, 'two': 2} + + for quote, args in itertools.product( + ['"', "'", '"""', "'''"], + [ac.TAB, ac.FORCE] + ): + for text in [f'test_dict[{quote}', f'test_dict[{quote}t']: + with self.subTest(quote=quote, text=text, args=args): + self.text.delete('1.0', 'end') + self.text.insert('1.0', text) + with patch.dict('__main__.__dict__', + {'test_dict': test_dict}): + result = acp.open_completions(args) + + self.assertEqual(result, True) + # With more than one possible completion, the text + # should not be changed. + self.assertEqual(self.text.get('1.0', '1.end'), text) + + def test_complete_dict_key_no_matches(self): + acp = self.autocomplete + + test_dict = {'one': 12, 'four': 4} + + for quote, args in itertools.product( + ['"', "'", '"""', "'''"], + [ac.TAB, ac.FORCE] + ): + for text in [f'test_dict[{quote}t', f'test_dict[{quote}1']: + with self.subTest(quote=quote, text=text, args=args): + self.text.delete('1.0', 'end') + self.text.insert('1.0', text) + with patch.dict('__main__.__dict__', + {'test_dict': test_dict}): + # import pdb; pdb.set_trace() + result = acp.open_completions(args) + + self.assertEqual(result, True) + # With no possible completions, the text + # should not be changed. + self.assertEqual(self.text.get('1.0', '1.end'), text) + + +class TestQuoteClosesLiteral(unittest.TestCase): + def check(self, start, quotechar): + return acw.AutoCompleteWindow._quote_closes_literal(start, quotechar) + + def test_true_cases(self): + true_cases = [ + # (start, quotechar) + (f'{prefix}{quote}{content}{quote[:-1]}', quote[0]) + for prefix in ('', 'b', 'rb', 'u') + for quote in ('"', "'", '"""', "'''") + for content in ('', 'a', 'abc', '\\'*2, f'\\{quote[0]}', '\\n') + ] + + for (start, quotechar) in true_cases: + with self.subTest(start=start, quotechar=quotechar): + self.assertTrue(self.check(start, quotechar)) + + def test_false_cases(self): + false_cases = [ + # (start, quotechar) + (f'{prefix}{quote}{content}', quote[0]) + for prefix in ('', 'b', 'rb', 'u') + for quote in ('"', "'", '"""', "'''") + for content in ('\\', 'a\\', 'ab\\', '\\'*3, '\\'*5, 'ab\\\\\\') + ] + + # test different quote char + false_cases.extend([ + ('"', "'"), + ('"abc', "'"), + ('"""', "'"), + ('"""abc', "'"), + ("'", '"'), + ("'abc", '"'), + ("'''", '"'), + ("'''abc", '"'), + ]) + + # test not yet closed triple-quotes + for q in ['"', "'"]: + false_cases.extend([ + (f"{q*3}", q), + (f"{q*4}", q), + (f"{q*3}\\{q}", q), + (f"{q*3}\\{q*2}", q), + (f"{q*3}\\\\\\{q*2}", q), + (f"{q*3}abc", q), + (f"{q*3}abc{q}", q), + (f"{q*3}abc\\{q}", q), + (f"{q*3}abc\\{q*2}", q), + (f"{q*3}abc\\\\\\{q*2}", q), + ]) + + for (start, quotechar) in false_cases: + with self.subTest(start=start, quotechar=quotechar): + self.assertFalse(self.check(start, quotechar)) + + +class TestDictKeyReprs(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls._dict_key_reprs = staticmethod( + ac.AutoComplete._make_dict_key_repr_func() + ) + + def call(self, comp_start, comp_list): + return self._dict_key_reprs(comp_start, comp_list) + + def check(self, comp_start, comp_list, expected): + self.assertEqual(self.call(comp_start, comp_list), expected) + + def test_empty_strings(self): + self.check('', [''], ['""']) + self.check('', [b''], []) + self.check('', ['', b''], ['""']) + + def test_empty_strings_with_only_prefix(self): + self.check('b', [''], ['""']) + self.check('b', [b''], []) + + self.check('r', [''], ['r""']) + self.check('br', [''], ['""']) + + self.check('u', [''], ['u""']) + + def test_empty_strings_with_only_quotes(self): + self.check('"', [''], ['""']) + self.check("'", [''], ["''"]) + self.check('"""', [''], ['""""""']) + self.check("'''", [''], ["''''''"]) + + def test_backslash_escape(self): + self.check('"', ['ab\\c'], [r'"ab\\c"']) + self.check('"', ['ab\\\\c'], [r'"ab\\\\c"']) + self.check('"', ['ab\\nc'], [r'"ab\\nc"']) + + def test_quote_escape(self): + self.check('"', ['"', "'"], [r'"\""', '"\'"']) + self.check("'", ['"', "'"], ["'\"'", r"'\''"]) + self.check('"""', ['"', "'"], [r'"""\""""', '"""\'"""']) + self.check("'''", ['"', "'"], ["'''\"'''", r"'''\''''"]) + + # With triple quotes, only a final single quote should be escaped. + self.check('"""', ['"ab'], ['""""ab"""']) + self.check('"""', ['a"b'], ['"""a"b"""']) + self.check('"""', ['ab"'], ['"""ab\\""""']) + self.check('"""', ['ab""'], ['"""ab"\\""""']) + + self.check("'''", ["'ab"], ["''''ab'''"]) + self.check("'''", ["a'b"], ["'''a'b'''"]) + self.check("'''", ["ab'"], ["'''ab\\''''"]) + self.check("'''", ["ab''"], ["'''ab'\\''''"]) + + # Identical single quotes should be escaped. + self.check('"', ['ab"c'], [r'"ab\"c"']) + self.check("'", ["ab'c"], [r"'ab\'c'"]) + + # Different single quotes shouldn't be escaped. + self.check('"', ["ab'c"], ['"ab\'c"']) + self.check("'", ['ab"c'], ["'ab\"c'"]) + + def test_control_char_escape(self): + custom_escapes = {0: '\\0', 9: '\\t', 10: '\\n', 13: '\\r'} + for ord_ in range(14): + with self.subTest(ord_=ord_): + escape = custom_escapes.get(ord_, f'\\x{ord_:02x}') + self.check('"', [chr(ord_), bytes([ord_])], + [f'"{escape}"']) + + def test_non_bmp_escape(self): + self.check('"', ['\U00010000'], [r'"\U00010000"']) + self.check('"', ['\U00011111'], [r'"\U00011111"']) + self.check('"', ['ab\U00011111ba'], [r'"ab\U00011111ba"']) + + self.check('r"', ['\U00010000'], [r'"\U00010000"']) + self.check('r"', ['ab\U00011111ba'], [r'"ab\U00011111ba"']) if __name__ == '__main__': diff --git a/Lib/idlelib/idle_test/test_text.py b/Lib/idlelib/idle_test/test_text.py index 0f31179e04b28f..4628806af8c141 100644 --- a/Lib/idlelib/idle_test/test_text.py +++ b/Lib/idlelib/idle_test/test_text.py @@ -22,24 +22,24 @@ def test_init(self): def test_index_empty(self): index = self.text.index - for dex in (-1.0, 0.3, '1.-1', '1.0', '1.0 lineend', '1.end', '1.33', + for dex in ('0.0', '0.3', '1.0', '1.0 lineend', '1.end', '1.33', 'insert'): self.assertEqual(index(dex), '1.0') - for dex in 'end', 2.0, '2.1', '33.44': + for dex in 'end', '2.0', '2.1', '33.44': self.assertEqual(index(dex), '2.0') def test_index_data(self): index = self.text.index self.text.insert('1.0', self.hw) - for dex in -1.0, 0.3, '1.-1', '1.0': + for dex in '0.0', '0.3', '1.0': self.assertEqual(index(dex), '1.0') for dex in '1.0 lineend', '1.end', '1.33': self.assertEqual(index(dex), '1.5') - for dex in 'end', '33.44': + for dex in 'end', '33.44': self.assertEqual(index(dex), '3.0') def test_get(self): diff --git a/Misc/NEWS.d/next/IDLE/2019-08-07-23-27-54.bpo-21261.sqNQCD.rst b/Misc/NEWS.d/next/IDLE/2019-08-07-23-27-54.bpo-21261.sqNQCD.rst new file mode 100644 index 00000000000000..54d08f6e07d6c9 --- /dev/null +++ b/Misc/NEWS.d/next/IDLE/2019-08-07-23-27-54.bpo-21261.sqNQCD.rst @@ -0,0 +1,2 @@ +IDLE's shell auto-completion works for dict keys of types :class:`str` +and :class:`bytes`. \ No newline at end of file