From cef334fd4c4c24a542ce81ad940b1426b5a7cdbd Mon Sep 17 00:00:00 2001 From: Artem Yarulin Date: Sun, 12 Apr 2026 03:01:18 +0300 Subject: [PATCH 1/8] tests: use errno.EBADF instead of hardcoded number in _close_file() (GH-148345) test_interpreters: use errno.EBADF instead of hardcoded number in _close_file() Replace the hardcoded `9` check in `Lib/test/test_interpreters/utils.py` with `errno.EBADF`. Using `errno.EBADF` makes the helper portable across platforms with different errno numbering while preserving the intended behavior. --- Lib/test/test_interpreters/utils.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Lib/test/test_interpreters/utils.py b/Lib/test/test_interpreters/utils.py index ae09aa457b48c7..bb6da52727c212 100644 --- a/Lib/test/test_interpreters/utils.py +++ b/Lib/test/test_interpreters/utils.py @@ -1,5 +1,6 @@ from collections import namedtuple import contextlib +import errno import json import logging import os @@ -51,7 +52,7 @@ def _close_file(file): else: os.close(file) except OSError as exc: - if exc.errno != 9: + if exc.errno != errno.EBADF: raise # re-raise # It was closed already. From d761f539bdae6090817438ae65c0be8a10c9e4e3 Mon Sep 17 00:00:00 2001 From: "Gregory P. Smith" <68491+gpshead@users.noreply.github.com> Date: Sat, 11 Apr 2026 17:02:56 -0700 Subject: [PATCH 2/8] gh-146287: Fix signed/unsigned mismatch in _hashlib_hmac_digest_size (GH-148407) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * gh-146287: use signed type for HMAC digest size to prevent unsigned wrapping Change _hashlib_hmac_digest_size() return type from unsigned int to int so that a hypothetical negative return from EVP_MD_size() is not silently wrapped to a large positive value. Add an explicit check for negative digest_size in the legacy OpenSSL path, and use SystemError (not ValueError) since these conditions indicate internal invariant violations. Also add debug-build asserts to EVP_get_block_size and EVP_get_digest_size documenting that the hash context is always initialized. Co-authored-by: Bénédikt Tran <10796600+picnixz@users.noreply.github.com> --- Modules/_hashopenssl.c | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/Modules/_hashopenssl.c b/Modules/_hashopenssl.c index 5d86c2e5886afd..fa3eceb74d1694 100644 --- a/Modules/_hashopenssl.c +++ b/Modules/_hashopenssl.c @@ -1006,6 +1006,7 @@ _hashlib_HASH_get_blocksize(PyObject *op, void *Py_UNUSED(closure)) { HASHobject *self = HASHobject_CAST(op); long block_size = EVP_MD_CTX_block_size(self->ctx); + assert(block_size > 0); return PyLong_FromLong(block_size); } @@ -1014,6 +1015,7 @@ _hashlib_HASH_get_digestsize(PyObject *op, void *Py_UNUSED(closure)) { HASHobject *self = HASHobject_CAST(op); long size = EVP_MD_CTX_size(self->ctx); + assert(size > 0); return PyLong_FromLong(size); } @@ -2200,7 +2202,7 @@ _hashlib_hmac_new_impl(PyObject *module, Py_buffer *key, PyObject *msg_obj, * * On error, set an exception and return BAD_DIGEST_SIZE. */ -static unsigned int +static int _hashlib_hmac_digest_size(HMACobject *self) { assert(EVP_MAX_MD_SIZE < INT_MAX); @@ -2215,15 +2217,18 @@ _hashlib_hmac_digest_size(HMACobject *self) } int digest_size = EVP_MD_size(md); /* digest_size < 0 iff EVP_MD context is NULL (which is impossible here) */ - assert(digest_size >= 0); assert(digest_size <= (int)EVP_MAX_MD_SIZE); + if (digest_size < 0) { + raise_ssl_error(PyExc_SystemError, "invalid digest size"); + return BAD_DIGEST_SIZE; + } #endif /* digest_size == 0 means that the context is not entirely initialized */ if (digest_size == 0) { - raise_ssl_error(PyExc_ValueError, "missing digest size"); + raise_ssl_error(PyExc_SystemError, "missing digest size"); return BAD_DIGEST_SIZE; } - return (unsigned int)digest_size; + return (int)digest_size; } static int @@ -2321,7 +2326,7 @@ _hashlib_HMAC_update_impl(HMACobject *self, PyObject *msg) static Py_ssize_t _hmac_digest(HMACobject *self, unsigned char *buf) { - unsigned int digest_size = _hashlib_hmac_digest_size(self); + int digest_size = _hashlib_hmac_digest_size(self); assert(digest_size <= EVP_MAX_MD_SIZE); if (digest_size == BAD_DIGEST_SIZE) { assert(PyErr_Occurred()); @@ -2386,7 +2391,7 @@ static PyObject * _hashlib_hmac_get_digest_size(PyObject *op, void *Py_UNUSED(closure)) { HMACobject *self = HMACobject_CAST(op); - unsigned int size = _hashlib_hmac_digest_size(self); + int size = _hashlib_hmac_digest_size(self); return size == BAD_DIGEST_SIZE ? NULL : PyLong_FromLong(size); } From 235fa7244a0474c492ae98ee444529c7ba2a9047 Mon Sep 17 00:00:00 2001 From: Shamil Date: Sun, 12 Apr 2026 03:14:50 +0300 Subject: [PATCH 3/8] gh-142831: Fix use-after-free in json encoder during re-entrant mutation (gh-142851) Hold strong references to borrowed items unconditionally (not only in free-threading builds) in _encoder_iterate_mapping_lock_held and _encoder_iterate_fast_seq_lock_held. User callbacks invoked during encoding can mutate or clear the underlying container, invalidating borrowed references. The dict iteration path was already fixed by gh-145244. Co-authored-by: Kumar Aditya Co-authored-by: Gregory P. Smith --- Lib/test/test_json/test_speedups.py | 61 +++++++++++++++++++ ...-12-17-04-10-35.gh-issue-142831.ee3t4L.rst | 2 + Modules/_json.c | 28 ++------- 3 files changed, 69 insertions(+), 22 deletions(-) create mode 100644 Misc/NEWS.d/next/Library/2025-12-17-04-10-35.gh-issue-142831.ee3t4L.rst diff --git a/Lib/test/test_json/test_speedups.py b/Lib/test/test_json/test_speedups.py index 4c0aa5f993b30f..0b22a0bf4b9538 100644 --- a/Lib/test/test_json/test_speedups.py +++ b/Lib/test/test_json/test_speedups.py @@ -1,4 +1,5 @@ from test.test_json import CTest +from test.support import gc_collect class BadBool: @@ -111,3 +112,63 @@ def test_current_indent_level(self): self.assertEqual(enc(['spam', {'ham': 'eggs'}], 3)[0], expected2) self.assertRaises(TypeError, enc, ['spam', {'ham': 'eggs'}], 3.0) self.assertRaises(TypeError, enc, ['spam', {'ham': 'eggs'}]) + + def test_mutate_dict_items_during_encode(self): + # gh-142831: Clearing the items list via a re-entrant key encoder + # must not cause a use-after-free. BadDict.items() returns a + # mutable list; encode_str clears it while iterating. + items = None + + class BadDict(dict): + def items(self): + nonlocal items + items = [("boom", object())] + return items + + cleared = False + def encode_str(obj): + nonlocal items, cleared + if items is not None: + items.clear() + items = None + cleared = True + gc_collect() + return '"x"' + + encoder = self.json.encoder.c_make_encoder( + None, lambda o: "null", + encode_str, None, + ": ", ", ", False, + False, True + ) + + # Must not crash (use-after-free under ASan before fix) + encoder(BadDict(real=1), 0) + self.assertTrue(cleared) + + def test_mutate_list_during_encode(self): + # gh-142831: Clearing a list mid-iteration via the default + # callback must not cause a use-after-free. + call_count = 0 + lst = [object() for _ in range(10)] + + def default(obj): + nonlocal call_count + call_count += 1 + if call_count == 3: + lst.clear() + gc_collect() + return None + + encoder = self.json.encoder.c_make_encoder( + None, default, + self.json.encoder.c_encode_basestring, None, + ": ", ", ", False, + False, True + ) + + # Must not crash (use-after-free under ASan before fix) + encoder(lst, 0) + # Verify the mutation path was actually hit and the loop + # stopped iterating after the list was cleared. + self.assertEqual(call_count, 3) diff --git a/Misc/NEWS.d/next/Library/2025-12-17-04-10-35.gh-issue-142831.ee3t4L.rst b/Misc/NEWS.d/next/Library/2025-12-17-04-10-35.gh-issue-142831.ee3t4L.rst new file mode 100644 index 00000000000000..5fa3cd2727a9e5 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2025-12-17-04-10-35.gh-issue-142831.ee3t4L.rst @@ -0,0 +1,2 @@ +Fix a crash in the :mod:`json` module where a use-after-free could occur if +the object being encoded is modified during serialization. diff --git a/Modules/_json.c b/Modules/_json.c index e36e69b09b2030..1f454768355cc0 100644 --- a/Modules/_json.c +++ b/Modules/_json.c @@ -1745,15 +1745,12 @@ _encoder_iterate_mapping_lock_held(PyEncoderObject *s, PyUnicodeWriter *writer, PyObject *key, *value; for (Py_ssize_t i = 0; i < PyList_GET_SIZE(items); i++) { PyObject *item = PyList_GET_ITEM(items, i); -#ifdef Py_GIL_DISABLED - // gh-119438: in the free-threading build the critical section on items can get suspended + // gh-142831: encoder_encode_key_value() can invoke user code + // that mutates the items list, invalidating this borrowed ref. Py_INCREF(item); -#endif if (!PyTuple_Check(item) || PyTuple_GET_SIZE(item) != 2) { PyErr_SetString(PyExc_ValueError, "items must return 2-tuples"); -#ifdef Py_GIL_DISABLED Py_DECREF(item); -#endif return -1; } @@ -1762,14 +1759,10 @@ _encoder_iterate_mapping_lock_held(PyEncoderObject *s, PyUnicodeWriter *writer, if (encoder_encode_key_value(s, writer, first, dct, key, value, indent_level, indent_cache, separator) < 0) { -#ifdef Py_GIL_DISABLED Py_DECREF(item); -#endif return -1; } -#ifdef Py_GIL_DISABLED Py_DECREF(item); -#endif } return 0; @@ -1784,10 +1777,8 @@ _encoder_iterate_dict_lock_held(PyEncoderObject *s, PyUnicodeWriter *writer, PyObject *key, *value; Py_ssize_t pos = 0; while (PyDict_Next(dct, &pos, &key, &value)) { - // gh-119438, gh-145244: key and value are borrowed refs from - // PyDict_Next(). encoder_encode_key_value() may invoke user - // Python code (the 'default' callback) that can mutate or - // clear the dict, so we must hold strong references. + // gh-145244: encoder_encode_key_value() can invoke user code + // that mutates the dict, invalidating these borrowed refs. Py_INCREF(key); Py_INCREF(value); if (encoder_encode_key_value(s, writer, first, dct, key, value, @@ -1902,28 +1893,21 @@ _encoder_iterate_fast_seq_lock_held(PyEncoderObject *s, PyUnicodeWriter *writer, { for (Py_ssize_t i = 0; i < PySequence_Fast_GET_SIZE(s_fast); i++) { PyObject *obj = PySequence_Fast_GET_ITEM(s_fast, i); -#ifdef Py_GIL_DISABLED - // gh-119438: in the free-threading build the critical section on s_fast can get suspended + // gh-142831: encoder_listencode_obj() can invoke user code + // that mutates the sequence, invalidating this borrowed ref. Py_INCREF(obj); -#endif if (i) { if (PyUnicodeWriter_WriteStr(writer, separator) < 0) { -#ifdef Py_GIL_DISABLED Py_DECREF(obj); -#endif return -1; } } if (encoder_listencode_obj(s, writer, obj, indent_level, indent_cache)) { _PyErr_FormatNote("when serializing %T item %zd", seq, i); -#ifdef Py_GIL_DISABLED Py_DECREF(obj); -#endif return -1; } -#ifdef Py_GIL_DISABLED Py_DECREF(obj); -#endif } return 0; } From e2fa10e04d3fed4c248881d69411fc208d05ad6b Mon Sep 17 00:00:00 2001 From: Wulian233 <1055917385@qq.com> Date: Sun, 12 Apr 2026 08:26:18 +0800 Subject: [PATCH 4/8] gh-148208: Fix recursion depth leak in `PyObject_Print` (GH-148209) --- .../2026-04-07-20-21-44.gh-issue-148208.JAxpDU.rst | 1 + Objects/object.c | 2 ++ 2 files changed, 3 insertions(+) create mode 100644 Misc/NEWS.d/next/Core_and_Builtins/2026-04-07-20-21-44.gh-issue-148208.JAxpDU.rst diff --git a/Misc/NEWS.d/next/Core_and_Builtins/2026-04-07-20-21-44.gh-issue-148208.JAxpDU.rst b/Misc/NEWS.d/next/Core_and_Builtins/2026-04-07-20-21-44.gh-issue-148208.JAxpDU.rst new file mode 100644 index 00000000000000..b8ae19f5877a7d --- /dev/null +++ b/Misc/NEWS.d/next/Core_and_Builtins/2026-04-07-20-21-44.gh-issue-148208.JAxpDU.rst @@ -0,0 +1 @@ +Fix recursion depth leak in :c:func:`PyObject_Print` diff --git a/Objects/object.c b/Objects/object.c index 4db22f372ec3f7..3166254f6f640b 100644 --- a/Objects/object.c +++ b/Objects/object.c @@ -688,6 +688,8 @@ PyObject_Print(PyObject *op, FILE *fp, int flags) ret = -1; } } + + _Py_LeaveRecursiveCall(); return ret; } From b216d7b0be725bcf0d25f3d5dade0fb06f59c71a Mon Sep 17 00:00:00 2001 From: Neil Schemenauer Date: Sat, 11 Apr 2026 17:43:04 -0700 Subject: [PATCH 5/8] gh-97032: avoid test_squeezer crash on macOS buildbots (gh-115508) (GH-148141) gh-97032: avoid test_squeezer crash on macOS buildbots (#115508) avoid test_squeezer crash on macOS buildbots Co-authored-by: Ned Deily --- Lib/idlelib/idle_test/test_squeezer.py | 1 + 1 file changed, 1 insertion(+) diff --git a/Lib/idlelib/idle_test/test_squeezer.py b/Lib/idlelib/idle_test/test_squeezer.py index 86c5d41b629719..86c21f00bb8d00 100644 --- a/Lib/idlelib/idle_test/test_squeezer.py +++ b/Lib/idlelib/idle_test/test_squeezer.py @@ -170,6 +170,7 @@ def test_write_not_stdout(self): def test_write_stdout(self): """Test Squeezer's overriding of the EditorWindow's write() method.""" + requires('gui') editwin = self.make_mock_editor_window() for text in ['', 'TEXT']: From 22290ed011a8ac4060390e57f53053ab932fb3f3 Mon Sep 17 00:00:00 2001 From: WYSIATI Date: Sun, 12 Apr 2026 08:46:06 +0800 Subject: [PATCH 6/8] gh-147965: Add shutdown() to multiprocessing.Queue excluded methods (GH-147970) The multiprocessing.Queue documentation states it implements all methods of queue.Queue except task_done() and join(). Since queue.Queue.shutdown() was added in Python 3.13, multiprocessing.Queue also does not implement it. Update the docs to include shutdown() in the list of excluded methods. --- Doc/library/multiprocessing.rst | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Doc/library/multiprocessing.rst b/Doc/library/multiprocessing.rst index 2b67d10d7bf1b7..63bc252e129dfb 100644 --- a/Doc/library/multiprocessing.rst +++ b/Doc/library/multiprocessing.rst @@ -932,7 +932,8 @@ For an example of the usage of queues for interprocess communication see standard library's :mod:`queue` module are raised to signal timeouts. :class:`Queue` implements all the methods of :class:`queue.Queue` except for - :meth:`~queue.Queue.task_done` and :meth:`~queue.Queue.join`. + :meth:`~queue.Queue.task_done`, :meth:`~queue.Queue.join`, and + :meth:`~queue.Queue.shutdown`. .. method:: qsize() From b3b0cef0c2aacdc616fa48674552ab1e34553835 Mon Sep 17 00:00:00 2001 From: Ram Vikram Singh Date: Sun, 12 Apr 2026 08:37:42 +0530 Subject: [PATCH 7/8] gh-100305: Deemphasize that `ast.literal_eval` is safe in `eval` documentation (#100326) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Éric --- Doc/library/functions.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Doc/library/functions.rst b/Doc/library/functions.rst index e8c4605d0578e2..119141d2e6daf3 100644 --- a/Doc/library/functions.rst +++ b/Doc/library/functions.rst @@ -644,7 +644,7 @@ are always available. They are listed here in alphabetical order. If the given source is a string, then leading and trailing spaces and tabs are stripped. - See :func:`ast.literal_eval` for a function that can safely evaluate strings + See :func:`ast.literal_eval` for a function to evaluate strings with expressions containing only literals. .. audit-event:: exec code_object eval From 208195dff4cd19dfd4aeb0eed8a133f2b1a66ec1 Mon Sep 17 00:00:00 2001 From: CoolCat467 <52022020+CoolCat467@users.noreply.github.com> Date: Sat, 11 Apr 2026 23:44:33 -0500 Subject: [PATCH 8/8] gh-89520: Load extension settings and keybindings from user config (GH-28713) 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 Co-authored-by: Gregory P. Smith <68491+gpshead@users.noreply.github.com> Co-authored-by: Gregory P. Smith --- Lib/idlelib/config.py | 104 +++++++++++------ Lib/idlelib/configdialog.py | 20 +++- Lib/idlelib/editor.py | 5 +- Lib/idlelib/idle_test/test_zzdummy.py | 107 +++++++++++------ Lib/idlelib/idle_test/test_zzdummy_user.py | 108 ++++++++++++++++++ ...1-10-03-21-55-34.gh-issue-89520.etEExa.rst | 3 + 6 files changed, 271 insertions(+), 76 deletions(-) create mode 100644 Lib/idlelib/idle_test/test_zzdummy_user.py create mode 100644 Misc/NEWS.d/next/IDLE/2021-10-03-21-55-34.gh-issue-89520.etEExa.rst 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.