]> git.ipfire.org Git - thirdparty/Python/cpython.git/commitdiff
gh-89520: Load extension settings and keybindings from user config (GH-28713)
authorCoolCat467 <52022020+CoolCat467@users.noreply.github.com>
Sun, 12 Apr 2026 04:44:33 +0000 (23:44 -0500)
committerGitHub <noreply@github.com>
Sun, 12 Apr 2026 04:44:33 +0000 (04:44 +0000)
Extension keybindings defined in ~/.idlerc/config-extensions.cfg were silently ignored because GetExtensionKeys, __GetRawExtensionKeys, and GetExtensionBindings only checked default config. Fix these to check user config as well, and update the extensions config dialog to handle user-only extensions correctly.

---------

Co-authored-by: blurb-it[bot] <43283697+blurb-it[bot]@users.noreply.github.com>
Co-authored-by: Jelle Zijlstra <jelle.zijlstra@gmail.com>
Co-authored-by: Gregory P. Smith <68491+gpshead@users.noreply.github.com>
Co-authored-by: Gregory P. Smith <greg@krypto.org>
Lib/idlelib/config.py
Lib/idlelib/configdialog.py
Lib/idlelib/editor.py
Lib/idlelib/idle_test/test_zzdummy.py
Lib/idlelib/idle_test/test_zzdummy_user.py [new file with mode: 0644]
Misc/NEWS.d/next/IDLE/2021-10-03-21-55-34.gh-issue-89520.etEExa.rst [new file with mode: 0644]

index d10c88a43f9231d30927f1ed49512d1c401874f5..1cabe4794500151ae381f8d41691cddac7d461a0 100644 (file)
@@ -476,34 +476,58 @@ class IdleConf:
         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 @@ class IdleConf:
         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.
index e618ef07a90271c29e5bb1c90e626fa754737761..10bd3c23450821410861c7a58b7ad3788f1e61db 100644 (file)
@@ -1960,12 +1960,15 @@ class ExtPage(Frame):
     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 @@ class ExtPage(Frame):
             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 @@ class ExtPage(Frame):
         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)
 
index 932b6bf70ac9fc8a4e8289c52cc6d884c528e3db..239bf5af4705674cb57a2fa06fdee99c85628021 100644 (file)
@@ -860,9 +860,8 @@ class EditorWindow:
             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.
index 209d8564da06641f38e352846a24592636186c77..14c343cf9b30872bd375a3c8eaa5bdbe42df6b41 100644 (file)
@@ -38,38 +38,8 @@ class DummyEditwin:
         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 @@ class ZZDummyTest(unittest.TestCase):
 
     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 @@ class ZZDummyTest(unittest.TestCase):
         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"),
+            {'<<z-out>>': ['<Control-Shift-KeyRelease-Delete>']})
+
+    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"),
+            {'<<z-in>>': ['<Control-Shift-KeyRelease-Insert>']})
+        self.assertEqual(
+            conf.GetExtensionBindings("ZzDummy"),
+            {'<<z-in>>': ['<Control-Shift-KeyRelease-Insert>'],
+             '<<z-out>>': ['<Control-Shift-KeyRelease-Delete>']})
+        # 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 (file)
index 0000000..1d3f2ac
--- /dev/null
@@ -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': '<Control-Shift-KeyRelease-Insert>'},
+    "ZzDummy_bindings": {
+        'z-out': '<Control-Shift-KeyRelease-Delete>'},
+})
+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"),
+            {'<<z-in>>': ['<Control-Shift-KeyRelease-Insert>']})
+        self.assertEqual(
+            zzdummy.idleConf.GetExtensionBindings("ZzDummy"),
+            {'<<z-in>>': ['<Control-Shift-KeyRelease-Insert>'],
+             '<<z-out>>': ['<Control-Shift-KeyRelease-Delete>']})
+
+
+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 (file)
index 0000000..e8e181c
--- /dev/null
@@ -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.