diff --git a/Lib/idlelib/config.py b/Lib/idlelib/config.py index d10c88a43f9231..1cabe479450015 100644 --- a/Lib/idlelib/config.py +++ b/Lib/idlelib/config.py @@ -476,34 +476,58 @@ def GetExtensionKeys(self, extensionName): Keybindings come from GetCurrentKeySet() active key dict, where previously used bindings are disabled. """ - keysName = extensionName + '_cfgBindings' - activeKeys = self.GetCurrentKeySet() - extKeys = {} - if self.defaultCfg['extensions'].has_section(keysName): - eventNames = self.defaultCfg['extensions'].GetOptionList(keysName) - for eventName in eventNames: - event = '<<' + eventName + '>>' - binding = activeKeys[event] - extKeys[event] = binding - return extKeys - - def __GetRawExtensionKeys(self,extensionName): + bindings_section = f'{extensionName}_cfgBindings' + current_keyset = self.GetCurrentKeySet() + extension_keys = {} + + event_names = set() + if self.userCfg['extensions'].has_section(bindings_section): + event_names |= set( + self.userCfg['extensions'].GetOptionList(bindings_section) + ) + if self.defaultCfg['extensions'].has_section(bindings_section): + event_names |= set( + self.defaultCfg['extensions'].GetOptionList(bindings_section) + ) + + for event_name in event_names: + event = f'<<{event_name}>>' + binding = current_keyset.get(event, None) + if binding is None: + continue + extension_keys[event] = binding + return extension_keys + + def __GetRawExtensionKeys(self, extension_name): """Return dict {configurable extensionName event : keybinding list}. Events come from default config extension_cfgBindings section. Keybindings list come from the splitting of GetOption, which tries user config before default config. """ - keysName = extensionName+'_cfgBindings' - extKeys = {} - if self.defaultCfg['extensions'].has_section(keysName): - eventNames = self.defaultCfg['extensions'].GetOptionList(keysName) - for eventName in eventNames: - binding = self.GetOption( - 'extensions', keysName, eventName, default='').split() - event = '<<' + eventName + '>>' - extKeys[event] = binding - return extKeys + bindings_section = f'{extension_name}_cfgBindings' + extension_keys = {} + + event_names = set() + if self.userCfg['extensions'].has_section(bindings_section): + event_names |= set( + self.userCfg['extensions'].GetOptionList(bindings_section) + ) + if self.defaultCfg['extensions'].has_section(bindings_section): + event_names |= set( + self.defaultCfg['extensions'].GetOptionList(bindings_section) + ) + + for event_name in event_names: + binding = self.GetOption( + 'extensions', + bindings_section, + event_name, + default='', + ).split() + event = f'<<{event_name}>>' + extension_keys[event] = binding + return extension_keys def GetExtensionBindings(self, extensionName): """Return dict {extensionName event : active or defined keybinding}. @@ -512,18 +536,30 @@ def GetExtensionBindings(self, extensionName): configurable events (from default config) to GetOption splits, as in self.__GetRawExtensionKeys. """ - bindsName = extensionName + '_bindings' - extBinds = self.GetExtensionKeys(extensionName) - #add the non-configurable bindings - if self.defaultCfg['extensions'].has_section(bindsName): - eventNames = self.defaultCfg['extensions'].GetOptionList(bindsName) - for eventName in eventNames: - binding = self.GetOption( - 'extensions', bindsName, eventName, default='').split() - event = '<<' + eventName + '>>' - extBinds[event] = binding - - return extBinds + bindings_section = f'{extensionName}_bindings' + extension_keys = self.GetExtensionKeys(extensionName) + + # add the non-configurable bindings + event_names = set() + if self.userCfg['extensions'].has_section(bindings_section): + event_names |= set( + self.userCfg['extensions'].GetOptionList(bindings_section) + ) + if self.defaultCfg['extensions'].has_section(bindings_section): + event_names |= set( + self.defaultCfg['extensions'].GetOptionList(bindings_section) + ) + + for event_name in event_names: + binding = self.GetOption( + 'extensions', + bindings_section, + event_name, + default='' + ).split() + event = f'<<{event_name}>>' + extension_keys[event] = binding + return extension_keys def GetKeyBinding(self, keySetName, eventStr): """Return the keybinding list for keySetName eventStr. diff --git a/Lib/idlelib/configdialog.py b/Lib/idlelib/configdialog.py index e618ef07a90271..10bd3c23450821 100644 --- a/Lib/idlelib/configdialog.py +++ b/Lib/idlelib/configdialog.py @@ -1960,12 +1960,15 @@ def create_page_extensions(self): def load_extensions(self): "Fill self.extensions with data from the default and user configs." self.extensions = {} + for ext_name in idleConf.GetExtensions(active_only=False): # Former built-in extensions are already filtered out. self.extensions[ext_name] = [] for ext_name in self.extensions: - opt_list = sorted(self.ext_defaultCfg.GetOptionList(ext_name)) + default = set(self.ext_defaultCfg.GetOptionList(ext_name)) + user = set(self.ext_userCfg.GetOptionList(ext_name)) + opt_list = sorted(default | user) # Bring 'enable' options to the beginning of the list. enables = [opt_name for opt_name in opt_list @@ -1975,8 +1978,12 @@ def load_extensions(self): opt_list = enables + opt_list for opt_name in opt_list: - def_str = self.ext_defaultCfg.Get( - ext_name, opt_name, raw=True) + if opt_name in default: + def_str = self.ext_defaultCfg.Get( + ext_name, opt_name, raw=True) + else: + def_str = self.ext_userCfg.Get( + ext_name, opt_name, raw=True) try: def_obj = {'True':True, 'False':False}[def_str] opt_type = 'bool' @@ -2054,10 +2061,11 @@ def set_extension_value(self, section, opt): default = opt['default'] value = opt['var'].get().strip() or default opt['var'].set(value) - # if self.defaultCfg.has_section(section): - # Currently, always true; if not, indent to return. - if (value == default): + + # Only save option in user config if it differs from the default + if self.ext_defaultCfg.has_section(section) and value == default: return self.ext_userCfg.RemoveOption(section, name) + # Set the option. return self.ext_userCfg.SetOption(section, name, value) diff --git a/Lib/idlelib/editor.py b/Lib/idlelib/editor.py index 932b6bf70ac9fc..239bf5af470567 100644 --- a/Lib/idlelib/editor.py +++ b/Lib/idlelib/editor.py @@ -860,9 +860,8 @@ def RemoveKeybindings(self): self.text.event_delete(event, *keylist) for extensionName in self.get_standard_extension_names(): xkeydefs = idleConf.GetExtensionBindings(extensionName) - if xkeydefs: - for event, keylist in xkeydefs.items(): - self.text.event_delete(event, *keylist) + for event, keylist in xkeydefs.items(): + self.text.event_delete(event, *keylist) def ApplyKeybindings(self): """Apply the virtual, configurable keybindings. diff --git a/Lib/idlelib/idle_test/test_zzdummy.py b/Lib/idlelib/idle_test/test_zzdummy.py index 209d8564da0664..14c343cf9b3087 100644 --- a/Lib/idlelib/idle_test/test_zzdummy.py +++ b/Lib/idlelib/idle_test/test_zzdummy.py @@ -38,38 +38,8 @@ def __init__(self, root, text): self.text.undo_block_stop = mock.Mock() -class ZZDummyTest(unittest.TestCase): - - @classmethod - def setUpClass(cls): - requires('gui') - root = cls.root = Tk() - root.withdraw() - text = cls.text = Text(cls.root) - cls.editor = DummyEditwin(root, text) - zzdummy.idleConf.userCfg = testcfg - - @classmethod - def tearDownClass(cls): - zzdummy.idleConf.userCfg = usercfg - del cls.editor, cls.text - cls.root.update_idletasks() - for id in cls.root.tk.call('after', 'info'): - cls.root.after_cancel(id) # Need for EditorWindow. - cls.root.destroy() - del cls.root - - def setUp(self): - text = self.text - text.insert('1.0', code_sample) - text.undo_block_start.reset_mock() - text.undo_block_stop.reset_mock() - zz = self.zz = zzdummy.ZzDummy(self.editor) - zzdummy.ZzDummy.ztext = '# ignore #' - - def tearDown(self): - self.text.delete('1.0', 'end') - del self.zz +class ZZDummyMixin: + """Shared tests for ZzDummy with default and user configs.""" def checklines(self, text, value): # Verify that there are lines being checked. @@ -89,7 +59,8 @@ def test_init(self): def test_reload(self): self.assertEqual(self.zz.ztext, '# ignore #') - testcfg['extensions'].SetOption('ZzDummy', 'z-text', 'spam') + zzdummy.idleConf.userCfg['extensions'].SetOption( + 'ZzDummy', 'z-text', 'spam') zzdummy.ZzDummy.reload() self.assertEqual(self.zz.ztext, 'spam') @@ -148,5 +119,75 @@ def test_roundtrip(self): self.assertEqual(text.get('1.0', 'end-1c'), code_sample) +class ZZDummyTest(ZZDummyMixin, unittest.TestCase): + + @classmethod + def setUpClass(cls): + requires('gui') + root = cls.root = Tk() + root.withdraw() + text = cls.text = Text(cls.root) + cls.editor = DummyEditwin(root, text) + zzdummy.idleConf.userCfg = testcfg + + @classmethod + def tearDownClass(cls): + zzdummy.idleConf.userCfg = usercfg + del cls.editor, cls.text + cls.root.update_idletasks() + for id in cls.root.tk.call('after', 'info'): + cls.root.after_cancel(id) # Need for EditorWindow. + cls.root.destroy() + del cls.root + + def setUp(self): + text = self.text + text.insert('1.0', code_sample) + text.undo_block_start.reset_mock() + text.undo_block_stop.reset_mock() + zz = self.zz = zzdummy.ZzDummy(self.editor) + zzdummy.ZzDummy.ztext = '# ignore #' + + def tearDown(self): + self.text.delete('1.0', 'end') + del self.zz + + def test_exists(self): + conf = zzdummy.idleConf + self.assertEqual( + conf.GetSectionList('user', 'extensions'), []) + self.assertEqual( + conf.GetSectionList('default', 'extensions'), + ['AutoComplete', 'CodeContext', 'FormatParagraph', + 'ParenMatch', 'ZzDummy', 'ZzDummy_cfgBindings', + 'ZzDummy_bindings']) + self.assertIn("ZzDummy", conf.GetExtensions(False)) + self.assertNotIn("ZzDummy", conf.GetExtensions()) + self.assertEqual( + conf.GetExtensionKeys("ZzDummy"), {}) + self.assertEqual( + conf.GetExtensionBindings("ZzDummy"), + {'<>': ['']}) + + def test_exists_user(self): + conf = zzdummy.idleConf + conf.userCfg["extensions"].read_dict({ + "ZzDummy": {'enable': 'True'} + }) + self.assertEqual( + conf.GetSectionList('user', 'extensions'), + ["ZzDummy"]) + self.assertIn("ZzDummy", conf.GetExtensions()) + self.assertEqual( + conf.GetExtensionKeys("ZzDummy"), + {'<>': ['']}) + self.assertEqual( + conf.GetExtensionBindings("ZzDummy"), + {'<>': [''], + '<>': ['']}) + # Restore + conf.userCfg["extensions"].remove_section("ZzDummy") + + if __name__ == '__main__': unittest.main(verbosity=2) diff --git a/Lib/idlelib/idle_test/test_zzdummy_user.py b/Lib/idlelib/idle_test/test_zzdummy_user.py new file mode 100644 index 00000000000000..1d3f2ac3096fb0 --- /dev/null +++ b/Lib/idlelib/idle_test/test_zzdummy_user.py @@ -0,0 +1,108 @@ +"Test zzdummy with user config, coverage 100%." + +from idlelib import zzdummy +import unittest +from test.support import requires +from tkinter import Tk, Text +from idlelib import config + +from idlelib.idle_test.test_zzdummy import ( + ZZDummyMixin, DummyEditwin, code_sample, +) + + +real_usercfg = zzdummy.idleConf.userCfg +test_usercfg = { + 'main': config.IdleUserConfParser(''), + 'highlight': config.IdleUserConfParser(''), + 'keys': config.IdleUserConfParser(''), + 'extensions': config.IdleUserConfParser(''), +} +test_usercfg["extensions"].read_dict({ + "ZzDummy": {'enable': 'True', 'enable_shell': 'False', + 'enable_editor': 'True', 'z-text': 'Z'}, + "ZzDummy_cfgBindings": { + 'z-in': ''}, + "ZzDummy_bindings": { + 'z-out': ''}, +}) +real_defaultcfg = zzdummy.idleConf.defaultCfg +test_defaultcfg = { + 'main': config.IdleUserConfParser(''), + 'highlight': config.IdleUserConfParser(''), + 'keys': config.IdleUserConfParser(''), + 'extensions': config.IdleUserConfParser(''), +} +test_defaultcfg["extensions"].read_dict({ + "AutoComplete": {'popupwait': '2000'}, + "CodeContext": {'maxlines': '15'}, + "FormatParagraph": {'max-width': '72'}, + "ParenMatch": {'style': 'expression', + 'flash-delay': '500', 'bell': 'True'}, +}) +test_defaultcfg["main"].read_dict({ + "Theme": {"default": 1, "name": "IDLE Classic", "name2": ""}, + "Keys": {"default": 1, "name": "IDLE Classic", "name2": ""}, +}) +for key in ("keys",): + real_default = real_defaultcfg[key] + value = {name: dict(real_default[name]) for name in real_default} + test_defaultcfg[key].read_dict(value) + + +class ZZDummyTest(ZZDummyMixin, unittest.TestCase): + + @classmethod + def setUpClass(cls): + requires('gui') + root = cls.root = Tk() + root.withdraw() + text = cls.text = Text(cls.root) + cls.editor = DummyEditwin(root, text) + zzdummy.idleConf.userCfg = test_usercfg + zzdummy.idleConf.defaultCfg = test_defaultcfg + + @classmethod + def tearDownClass(cls): + zzdummy.idleConf.defaultCfg = real_defaultcfg + zzdummy.idleConf.userCfg = real_usercfg + del cls.editor, cls.text + cls.root.update_idletasks() + for id in cls.root.tk.call('after', 'info'): + cls.root.after_cancel(id) # Need for EditorWindow. + cls.root.destroy() + del cls.root + + def setUp(self): + text = self.text + text.insert('1.0', code_sample) + text.undo_block_start.reset_mock() + text.undo_block_stop.reset_mock() + zz = self.zz = zzdummy.ZzDummy(self.editor) + zzdummy.ZzDummy.ztext = '# ignore #' + + def tearDown(self): + self.text.delete('1.0', 'end') + del self.zz + + def test_exists(self): + self.assertEqual( + zzdummy.idleConf.GetSectionList('user', 'extensions'), + ['ZzDummy', 'ZzDummy_cfgBindings', 'ZzDummy_bindings']) + self.assertEqual( + zzdummy.idleConf.GetSectionList('default', 'extensions'), + ['AutoComplete', 'CodeContext', 'FormatParagraph', + 'ParenMatch']) + self.assertIn("ZzDummy", + zzdummy.idleConf.GetExtensions()) + self.assertEqual( + zzdummy.idleConf.GetExtensionKeys("ZzDummy"), + {'<>': ['']}) + self.assertEqual( + zzdummy.idleConf.GetExtensionBindings("ZzDummy"), + {'<>': [''], + '<>': ['']}) + + +if __name__ == '__main__': + unittest.main(verbosity=2) diff --git a/Misc/NEWS.d/next/IDLE/2021-10-03-21-55-34.gh-issue-89520.etEExa.rst b/Misc/NEWS.d/next/IDLE/2021-10-03-21-55-34.gh-issue-89520.etEExa.rst new file mode 100644 index 00000000000000..e8e181cac21358 --- /dev/null +++ b/Misc/NEWS.d/next/IDLE/2021-10-03-21-55-34.gh-issue-89520.etEExa.rst @@ -0,0 +1,3 @@ +Make IDLE extension configuration look at user config files, allowing +user-installed extensions to have settings and key bindings defined in +~/.idlerc.