From da5a15571c29b73e072da8bb5366a9ecda802e97 Mon Sep 17 00:00:00 2001 From: Serhiy Storchaka Date: Sun, 13 Sep 2026 20:32:59 +0300 Subject: [PATCH 1/6] gh-44376: Write missing namespace declarations in xml.dom.minidom (GH-156674) Element.writexml() now writes the xmlns declarations needed for the namespaces of the element and its attributes, if they are not already declared for an ancestor. A prefix in scope is reused for an attribute in a namespace without a prefix, or a new one is invented, because attributes cannot use the default namespace. The document is not modified by the serialization. --- Doc/library/xml.dom.minidom.rst | 4 + Lib/test/test_minidom.py | 124 +++++++++++++++++ Lib/xml/dom/minidom.py | 125 +++++++++++++++++- ...6-08-30-12-00-00.gh-issue-44376.Kp4Vz9.rst | 4 + 4 files changed, 251 insertions(+), 6 deletions(-) create mode 100644 Misc/NEWS.d/next/Library/2026-08-30-12-00-00.gh-issue-44376.Kp4Vz9.rst diff --git a/Doc/library/xml.dom.minidom.rst b/Doc/library/xml.dom.minidom.rst index 3e1f7a7e12a94e7..39c41cb9a303178 100644 --- a/Doc/library/xml.dom.minidom.rst +++ b/Doc/library/xml.dom.minidom.rst @@ -154,6 +154,10 @@ module documentation. This section lists the differences between the API and .. versionchanged:: 3.9 The *standalone* parameter was added. + .. versionchanged:: next + Namespace declarations missing for the serialized element + and its attributes are now written. + .. method:: Node.toxml(encoding=None, standalone=None) Return a string or byte string containing the XML represented by diff --git a/Lib/test/test_minidom.py b/Lib/test/test_minidom.py index 3cc99e36e8898f3..37829eaef0e5d4d 100644 --- a/Lib/test/test_minidom.py +++ b/Lib/test/test_minidom.py @@ -571,6 +571,130 @@ def testWriteXML(self): dom.unlink() self.assertEqual(str, domstr) + def testWriteXMLNamespaceDeclarations(self): + dom = Document() + root = dom.appendChild( + dom.createElementNS("http://xml.python.org/ns", "p:root")) + child = root.appendChild( + dom.createElementNS("http://xml.python.org/ns", "p:child")) + child.setAttributeNS("http://xml.python.org/ns2", "q:attr", "value") + self.assertEqual(dom.documentElement.toxml(), + '' + '') + dom.unlink() + + def testWriteXMLDefaultNamespace(self): + dom = Document() + root = dom.appendChild( + dom.createElementNS("http://xml.python.org/ns", "root")) + root.appendChild( + dom.createElementNS("http://xml.python.org/ns", "child")) + # An element in no namespace undeclares the default namespace. + root.appendChild(dom.createElement("nons")) + self.assertEqual(dom.documentElement.toxml(), + '' + '') + dom.unlink() + + def testWriteXMLAttributeNamespacePrefix(self): + dom = Document() + root = dom.appendChild(dom.createElement("root")) + # Attributes cannot use the default namespace, a prefix is invented. + root.setAttributeNS("http://xml.python.org/ns", "attr", "value") + root.setAttributeNS("http://xml.python.org/ns2", "attr2", "value2") + self.assertEqual(dom.documentElement.toxml(), + '') + # The same namespace gets the same prefix. + root.setAttributeNS("http://xml.python.org/ns", "attr3", "value3") + self.assertEqual(dom.documentElement.toxml(), + '') + dom.unlink() + + def testWriteXMLAttributeNamespacePrefixReused(self): + # A prefix already bound to the namespace of the attribute is used. + dom = Document() + root = dom.appendChild( + dom.createElementNS("http://xml.python.org/ns", "p:root")) + root.setAttributeNS("http://xml.python.org/ns", "attr", "value") + self.assertEqual(dom.documentElement.toxml(), + '') + # The prefix can be bound for an ancestor. + child = root.appendChild(dom.createElement("child")) + child.setAttributeNS("http://xml.python.org/ns", "attr", "value") + self.assertEqual(child.toxml(), '') + # The prefix bound for a preceding attribute is reused. + root.setAttributeNS("http://xml.python.org/ns3", "q:attr3", "value3") + root.setAttributeNS("http://xml.python.org/ns3", "attr4", "value4") + self.assertEqual(dom.documentElement.toxml(), + '' + '') + root.removeAttributeNS("http://xml.python.org/ns3", "attr3") + root.removeAttributeNS("http://xml.python.org/ns3", "attr4") + # The prefix must not be taken by an explicit declaration. + root.setAttributeNS(xml.dom.XMLNS_NAMESPACE, "xmlns:ns0", "other") + root.setAttributeNS("http://xml.python.org/ns2", "attr2", "value2") + self.assertEqual(dom.documentElement.toxml(), + '' + '') + dom.unlink() + + def testWriteXMLXMLPrefix(self): + dom = Document() + root = dom.appendChild(dom.createElement("root")) + # The "xml" prefix is bound by definition and is never declared. + root.setAttributeNS(xml.dom.XML_NAMESPACE, "xml:lang", "en") + self.assertEqual(dom.documentElement.toxml(), '') + dom.unlink() + + def testWriteXMLExistingNamespaceDeclarations(self): + for str in [ + '', + '', + '' + '', + '', + ]: + with self.subTest(str=str): + dom = parseString(str) + self.assertEqual(dom.documentElement.toxml(), str) + dom.unlink() + + def testWriteXMLNotANamespaceDeclaration(self): + # an attribute whose name only starts with "xmlns" is not one + dom = parseString('' + '') + self.assertEqual(dom.documentElement.toxml(), + '' + '') + dom.unlink() + + dom = Document() + root = dom.appendChild( + dom.createElementNS("http://xml.python.org/ns", "root")) + child = root.appendChild(dom.createElement("child")) + child.setAttribute("xmlnsabc", "v") + self.assertEqual(dom.documentElement.toxml(), + '' + '') + dom.unlink() + + def testWriteXMLDoesNotModifyDocument(self): + dom = Document() + root = dom.appendChild( + dom.createElementNS("http://xml.python.org/ns", "p:root")) + root.toxml() + self.assertEqual(root.attributes.length, 0) + dom.unlink() + def test_toxml_quote_text(self): dom = Document() elem = dom.appendChild(dom.createElement('elem')) diff --git a/Lib/xml/dom/minidom.py b/Lib/xml/dom/minidom.py index 2edd3f438e686da..d62564da3f36c67 100644 --- a/Lib/xml/dom/minidom.py +++ b/Lib/xml/dom/minidom.py @@ -379,6 +379,109 @@ def _write_data(writer, text, attr): text = text.replace("\t", " ") writer.write(text) + +# The "xml" prefix is bound by definition and is never declared. +_ROOT_NSMAP = {"xml": XML_NAMESPACE} + + +def _bind_namespace(nsmap, inherited, prefix, uri): + """Bind *prefix* in *nsmap*, copying it if it is still the inherited one.""" + if nsmap is inherited: + nsmap = dict(inherited) + nsmap[prefix] = uri + return nsmap + + +def _in_scope_namespaces(element): + """Return the namespaces in scope for *element*, as written by writexml.""" + ancestors = [] + node = element.parentNode + while node is not None and node.nodeType == Node.ELEMENT_NODE: + ancestors.append(node) + node = node.parentNode + nsmap = _ROOT_NSMAP + for node in reversed(ancestors): + nsmap, _ = _fixup_namespaces(node, nsmap) + return nsmap + + +def _fixup_namespaces(element, nsmap): + """Compute namespace declarations missing for the serialized element. + + *nsmap* is the mapping of prefixes to namespace URIs in scope for the + element. Return the mapping in scope for its children and the list of + (name, value) pairs of the attributes to be written, starting with the + added namespace declarations. The element and its attributes are not + modified. + """ + attrs = element._attrs + uri = element.namespaceURI + if not attrs and not uri and not nsmap.get(None): + # Neither the element nor its attributes need a declaration. + return nsmap, () + + inherited = nsmap + declarations = [] + # (name, value, namespace URI, attribute) of the attributes to write. + entries = [] + if attrs: + for attr in attrs.values(): + name = attr.name + attr_uri = attr.namespaceURI + if (attr_uri == XMLNS_NAMESPACE or name == "xmlns" + or name.startswith("xmlns:")): + # Declarations already present in the document take precedence. + nsmap = _bind_namespace( + nsmap, inherited, + attr.localName if attr.prefix else None, attr.value) + attr_uri = None + elif attr_uri == XML_NAMESPACE: + # The xml prefix is bound by definition. + attr_uri = None + entries.append((name, attr.value, attr_uri, attr)) + + if uri: + prefix, _, _ = element.tagName.rpartition(':') + prefix = prefix or None + if nsmap.get(prefix) != uri: + nsmap = _bind_namespace(nsmap, inherited, prefix, uri) + declarations.append(("xmlns:" + prefix if prefix else "xmlns", uri)) + elif nsmap.get(None) and ':' not in element.tagName: + # The element is in no namespace, undeclare the default one. + nsmap = _bind_namespace(nsmap, inherited, None, None) + declarations.append(("xmlns", "")) + + items = [] + prefixes = None # namespace URI -> prefix, built only when needed + n = 0 + for name, value, attr_uri, attr in entries: + if attr_uri is not None: + # Unprefixed attributes are in no namespace, so an attribute + # in a namespace always needs a prefix. + prefix, _, _ = name.rpartition(':') + if not prefix: + # Reuse a prefix bound to the namespace, or invent one. + if prefixes is None: + prefixes = {u: p for p, u in nsmap.items() + if p is not None} + prefix = prefixes.get(attr_uri) + if prefix is None: + while nsmap.get("ns%d" % n) is not None: + n += 1 + prefix = "ns%d" % n + name = "%s:%s" % (prefix, attr.localName) + if nsmap.get(prefix) != attr_uri: + nsmap = _bind_namespace(nsmap, inherited, prefix, attr_uri) + declarations.append(("xmlns:" + prefix, attr_uri)) + if prefixes is not None: + prefixes[attr_uri] = prefix + items.append((name, value)) + + if declarations: + return nsmap, declarations + items + return nsmap, items + + def _get_elements_by_tagName_helper(parent, name, rc): for node in parent.childNodes: if node.nodeType == Node.ELEMENT_NODE and \ @@ -944,7 +1047,8 @@ def getElementsByTagNameNS(self, namespaceURI, localName): def __repr__(self): return "" % (self.tagName, id(self)) - def writexml(self, writer, indent="", addindent="", newl=""): + def writexml(self, writer, indent="", addindent="", newl="", *, + _nsmap=None): """Write an XML element to a file-like object Write the element to the writer object that must provide @@ -953,13 +1057,14 @@ def writexml(self, writer, indent="", addindent="", newl=""): # indent = current indentation # addindent = indentation to add to higher levels # newl = newline string + if _nsmap is None: + _nsmap = _in_scope_namespaces(self) writer.write(indent+"<" + self.tagName) - attrs = self._get_attributes() - - for a_name in attrs.keys(): + nsmap, items = _fixup_namespaces(self, _nsmap) + for a_name, value in items: writer.write(" %s=\"" % a_name) - _write_data(writer, attrs[a_name].value, True) + _write_data(writer, value, True) writer.write("\"") if self.childNodes: writer.write(">") @@ -974,7 +1079,15 @@ def writexml(self, writer, indent="", addindent="", newl=""): else: writer.write(newl) for node in self.childNodes: - node.writexml(writer, indent+addindent, addindent, newl) + if type(node).writexml is Element.writexml: + # Pass the namespaces in scope to the standard + # implementation; an overridden writexml() has the + # documented signature and computes them itself. + node.writexml(writer, indent+addindent, addindent, + newl, _nsmap=nsmap) + else: + node.writexml(writer, indent+addindent, addindent, + newl) writer.write(indent) writer.write("%s" % (self.tagName, newl)) else: diff --git a/Misc/NEWS.d/next/Library/2026-08-30-12-00-00.gh-issue-44376.Kp4Vz9.rst b/Misc/NEWS.d/next/Library/2026-08-30-12-00-00.gh-issue-44376.Kp4Vz9.rst new file mode 100644 index 000000000000000..5fce84ec0c52a8d --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-08-30-12-00-00.gh-issue-44376.Kp4Vz9.rst @@ -0,0 +1,4 @@ +:meth:`~xml.dom.minidom.Node.writexml` in :mod:`xml.dom.minidom` now writes +the namespace declarations needed to serialize the namespaces of the element +and its attributes, if they are not already declared for an ancestor. The +document is not modified. From fb46c67d56c16b3235db8a051a0834a146897287 Mon Sep 17 00:00:00 2001 From: Victor Stinner Date: Sun, 13 Sep 2026 20:13:23 +0200 Subject: [PATCH 2/6] gh-128509: Add tests on sys._is_immortal() (#157376) --- Lib/test/test_sys.py | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/Lib/test/test_sys.py b/Lib/test/test_sys.py index 4308de227a46ce4..da1bd381dd182e4 100644 --- a/Lib/test/test_sys.py +++ b/Lib/test/test_sys.py @@ -1374,6 +1374,37 @@ def test_int_max_str_digits(self): with self.assertRaises(TypeError): sys.set_int_max_str_digits(2_048.0) + @test.support.cpython_only + def test_is_immortal(self): + is_immortal = sys._is_immortal + + # Singletons + self.assertTrue(is_immortal(None)) + self.assertTrue(is_immortal(False)) + self.assertTrue(is_immortal(True)) + self.assertTrue(is_immortal(0)) + self.assertTrue(is_immortal(b'')) + self.assertTrue(is_immortal('')) + self.assertTrue(is_immortal(b'x')) + self.assertTrue(is_immortal('x')) + self.assertTrue(is_immortal(())) + + # Static types + self.assertTrue(is_immortal(int)) + self.assertTrue(is_immortal(dict)) + + # Test some mortal objects + class PythonType: + pass + self.assertFalse(is_immortal([1, 2, 3])) + self.assertFalse(is_immortal({'key': 5})) + self.assertFalse(is_immortal(object())) + self.assertFalse(is_immortal(PythonType)) + self.assertFalse(is_immortal(2 ** 100)) + # Use encode/decode to get a fresh object + self.assertFalse(is_immortal(b'abc'.decode())) + self.assertFalse(is_immortal('abc'.encode())) + @test.support.cpython_only @test.support.force_not_colorized_test_class From a4ca6e8d4b5d20b18e0f0eece32bd30968db7470 Mon Sep 17 00:00:00 2001 From: Shamil Date: Sun, 13 Sep 2026 21:57:17 +0300 Subject: [PATCH 3/6] gh-156762: Fix tp_clear slot signature for operator.methodcaller() (#156769) Co-authored-by: Victor Stinner --- Lib/test/test_operator.py | 16 ++++++++++++++++ ...026-09-13-10-43-24.gh-issue-156762.Kbf2mo.rst | 4 ++++ Modules/_operator.c | 3 ++- 3 files changed, 22 insertions(+), 1 deletion(-) create mode 100644 Misc/NEWS.d/next/Core_and_Builtins/2026-09-13-10-43-24.gh-issue-156762.Kbf2mo.rst diff --git a/Lib/test/test_operator.py b/Lib/test/test_operator.py index 1f89986c777ced8..68c8aadeb50823b 100644 --- a/Lib/test/test_operator.py +++ b/Lib/test/test_operator.py @@ -2,6 +2,7 @@ import inspect import pickle import sys +import weakref from decimal import Decimal from fractions import Fraction @@ -511,6 +512,21 @@ def return_arguments(self, *args, **kwds): f = operator.methodcaller('return_arguments', *many_positional_arguments, **many_kw_arguments) self.assertEqual(f(a), (many_positional_arguments, many_kw_arguments)) + def test_methodcaller_cyclic_gc(self): + # gh-156762: Check for undefined behavior on calling methodcaller_clear() + operator = self.module + + class C: + pass + + c = C() + ref = weakref.ref(c) + c.m = operator.methodcaller('foo', c) + del c + + support.gc_collect() + self.assertIsNone(ref()) + def test_inplace(self): operator = self.module class C(object): diff --git a/Misc/NEWS.d/next/Core_and_Builtins/2026-09-13-10-43-24.gh-issue-156762.Kbf2mo.rst b/Misc/NEWS.d/next/Core_and_Builtins/2026-09-13-10-43-24.gh-issue-156762.Kbf2mo.rst new file mode 100644 index 000000000000000..f6f9bb038f4909d --- /dev/null +++ b/Misc/NEWS.d/next/Core_and_Builtins/2026-09-13-10-43-24.gh-issue-156762.Kbf2mo.rst @@ -0,0 +1,4 @@ +Fix undefined behaviour in :class:`operator.methodcaller`: its +:c:member:`~PyTypeObject.tp_clear` slot function returned ``void`` instead of +``int``, so the garbage collector called it through an incompatible function +type. Patched by Shamil Abdulaev. diff --git a/Modules/_operator.c b/Modules/_operator.c index 417403dc4c10c11..a0843971efe13e6 100644 --- a/Modules/_operator.c +++ b/Modules/_operator.c @@ -1740,7 +1740,7 @@ methodcaller_new(PyTypeObject *type, PyObject *args, PyObject *kwds) return (PyObject *)mc; } -static void +static int methodcaller_clear(PyObject *op) { methodcallerobject *mc = methodcallerobject_CAST(op); @@ -1749,6 +1749,7 @@ methodcaller_clear(PyObject *op) Py_CLEAR(mc->kwds); Py_CLEAR(mc->vectorcall_args); Py_CLEAR(mc->vectorcall_kwnames); + return 0; } static void From 9bd9c7461dabddcd2688b0f14ce00722edbdfee3 Mon Sep 17 00:00:00 2001 From: Serhiy Storchaka Date: Sun, 13 Sep 2026 22:04:32 +0300 Subject: [PATCH 4/6] gh-54092: Implement writexml() for DocumentFragment in xml.dom.minidom (GH-156646) toxml() and toprettyxml() now work for document fragments. They write the children of the fragment, without adding a level of indentation. --- Doc/library/xml.dom.minidom.rst | 5 +++++ Doc/whatsnew/3.16.rst | 6 ++++++ Lib/test/test_minidom.py | 15 +++++++++++++++ Lib/xml/dom/minidom.py | 4 ++++ .../2026-08-30-13-37-47.gh-issue-54092.Tq8Wm3.rst | 6 ++++++ 5 files changed, 36 insertions(+) create mode 100644 Misc/NEWS.d/next/Library/2026-08-30-13-37-47.gh-issue-54092.Tq8Wm3.rst diff --git a/Doc/library/xml.dom.minidom.rst b/Doc/library/xml.dom.minidom.rst index 39c41cb9a303178..d9b7bc7356e059f 100644 --- a/Doc/library/xml.dom.minidom.rst +++ b/Doc/library/xml.dom.minidom.rst @@ -157,6 +157,7 @@ module documentation. This section lists the differences between the API and .. versionchanged:: next Namespace declarations missing for the serialized element and its attributes are now written. + It now works for :class:`!DocumentFragment` nodes. .. method:: Node.toxml(encoding=None, standalone=None) @@ -179,6 +180,9 @@ module documentation. This section lists the differences between the API and .. versionchanged:: 3.9 The *standalone* parameter was added. + .. versionchanged:: next + It now works for :class:`!DocumentFragment` nodes. + .. method:: Node.toprettyxml(indent="\t", newl="\n", encoding=None, \ standalone=None) @@ -207,6 +211,7 @@ module documentation. This section lists the differences between the API and .. versionchanged:: next Whitespace is no longer added inside an element with mixed content or marked with ``xml:space="preserve"``. + It now works for :class:`!DocumentFragment` nodes. .. _dom-example: diff --git a/Doc/whatsnew/3.16.rst b/Doc/whatsnew/3.16.rst index aa875362c9b6281..8e1dd93f6ed8731 100644 --- a/Doc/whatsnew/3.16.rst +++ b/Doc/whatsnew/3.16.rst @@ -725,6 +725,12 @@ xml rather than defaulted from the DTD. (Contributed by Jason Orendorff and Serhiy Storchaka in :gh:`44871`.) +* The :meth:`~xml.dom.minidom.Node.writexml`, + :meth:`~xml.dom.minidom.Node.toxml` and + :meth:`~xml.dom.minidom.Node.toprettyxml` methods + now work for :class:`!DocumentFragment` nodes in :mod:`xml.dom.minidom`. + (Contributed by Serhiy Storchaka in :gh:`54092`.) + * :class:`~xml.etree.ElementTree.XMLPullParser` and :func:`~xml.etree.ElementTree.iterparse` now support the *target* parameter. The reported object is the value returned by the corresponding method of diff --git a/Lib/test/test_minidom.py b/Lib/test/test_minidom.py index 37829eaef0e5d4d..446bbe096bd19dc 100644 --- a/Lib/test/test_minidom.py +++ b/Lib/test/test_minidom.py @@ -571,6 +571,21 @@ def testWriteXML(self): dom.unlink() self.assertEqual(str, domstr) + def testWriteXMLDocumentFragment(self): + dom = parseString('text') + frag = dom.createDocumentFragment() + for node in list(dom.documentElement.childNodes): + frag.appendChild(node) + self.assertEqual(frag.toxml(), 'text') + self.assertEqual(frag.toprettyxml(), + '\ntext\n\n') + # the fragment itself does not add a level of indentation + writer = io.StringIO() + frag.writexml(writer, " ", " ", "\n") + self.assertEqual(writer.getvalue(), + ' \n text\n \n') + self.assertEqual(dom.createDocumentFragment().toxml(), '') + def testWriteXMLNamespaceDeclarations(self): dom = Document() root = dom.appendChild( diff --git a/Lib/xml/dom/minidom.py b/Lib/xml/dom/minidom.py index d62564da3f36c67..7cb652a323dcc22 100644 --- a/Lib/xml/dom/minidom.py +++ b/Lib/xml/dom/minidom.py @@ -515,6 +515,10 @@ class DocumentFragment(Node): def __init__(self): self.childNodes = NodeList() + def writexml(self, writer, indent="", addindent="", newl=""): + for node in self.childNodes: + node.writexml(writer, indent, addindent, newl) + class Attr(Node): __slots__=('_name', '_value', 'namespaceURI', diff --git a/Misc/NEWS.d/next/Library/2026-08-30-13-37-47.gh-issue-54092.Tq8Wm3.rst b/Misc/NEWS.d/next/Library/2026-08-30-13-37-47.gh-issue-54092.Tq8Wm3.rst new file mode 100644 index 000000000000000..43ae25552841b1d --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-08-30-13-37-47.gh-issue-54092.Tq8Wm3.rst @@ -0,0 +1,6 @@ +Implement :meth:`~xml.dom.minidom.Node.writexml` for document fragments +in :mod:`xml.dom.minidom`, +so that :meth:`~xml.dom.minidom.Node.toxml` and +:meth:`~xml.dom.minidom.Node.toprettyxml` now work for them. +They write the children of the fragment, +without adding a level of indentation. From ae0d6cc79118114f70afe5f65e5ab3a8d33359cc Mon Sep 17 00:00:00 2001 From: Serhiy Storchaka Date: Sun, 13 Sep 2026 22:46:04 +0300 Subject: [PATCH 5/6] gh-66172: Don't let a corrupt config file prevent IDLE from starting (#152764) If a user configuration file cannot be read, because of either bad unicode or configuration, rename it with a ".bad" suffix, use default settings, and warn the user with a message box. --------- Co-authored-by: Claude Opus 5 --- Lib/idlelib/config.py | 28 ++++++++++-- Lib/idlelib/idle_test/test_config.py | 43 +++++++++++++++++++ Lib/idlelib/pyshell.py | 6 +++ ...6-07-01-13-30-00.gh-issue-66172.Qm4xR7.rst | 3 ++ 4 files changed, 77 insertions(+), 3 deletions(-) create mode 100644 Misc/NEWS.d/next/IDLE/2026-07-01-13-30-00.gh-issue-66172.Qm4xR7.rst diff --git a/Lib/idlelib/config.py b/Lib/idlelib/config.py index 82afd6c49269d2d..0e0c884bcf9b838 100644 --- a/Lib/idlelib/config.py +++ b/Lib/idlelib/config.py @@ -25,7 +25,7 @@ """ # TODOs added Oct 2014, tjr -from configparser import ConfigParser +from configparser import ConfigParser, Error as ConfigParserError import os import sys @@ -74,7 +74,7 @@ def GetOptionList(self, section): def Load(self): "Load the configuration file from disk." if self.file and os.path.exists(self.file): - with open(self.file, encoding='utf-8', errors='replace') as f: + with open(self.file, encoding='utf-8') as f: self.read_file(f) class IdleUserConfParser(IdleConfParser): @@ -159,6 +159,7 @@ def __init__(self, _utest=False): self.defaultCfg = {} self.userCfg = {} self.cfg = {} # TODO use to select userCfg vs defaultCfg + self.file_load_errors = [] # (file, error) for unparsable cfg files. # See https://bugs.python.org/issue4630#msg356516 for following. # self.blink_off_time = ['insertofftime'] @@ -795,7 +796,28 @@ def LoadCfgFiles(self): "Load all configuration files." for key in self.defaultCfg: self.defaultCfg[key].Load() - self.userCfg[key].Load() #same keys + try: + self.userCfg[key].Load() # same keys + except (ConfigParserError, UnicodeDecodeError) as err: + # Move an invalid user file aside instead of losing it + # or failing to start (gh-66172). + file = self.userCfg[key].file + self.file_load_errors.append((file, err)) + try: + os.replace(file, file + '.bad') + except OSError: + pass + + def file_load_error_message(self): + "Return a warning about invalid config files, or None." + if not self.file_load_errors: + return None + files = '\n'.join( + f' {file}:\n {type(err).__name__}: {str(err).splitlines()[0]}' + for file, err in self.file_load_errors) + return ('The following IDLE configuration files could not be read. ' + 'They were renamed by appending ".bad", and default settings ' + 'are used instead:\n\n' + files) def SaveUserCfgFiles(self): "Write all loaded user configuration files to disk." diff --git a/Lib/idlelib/idle_test/test_config.py b/Lib/idlelib/idle_test/test_config.py index 028d9f9dbd613ce..f8f0415263dd226 100644 --- a/Lib/idlelib/idle_test/test_config.py +++ b/Lib/idlelib/idle_test/test_config.py @@ -312,6 +312,49 @@ def test_load_cfg_files(self): eq(conf.userCfg['foo'].Get('Foo Bar', 'foo'), 'newbar') eq(conf.userCfg['foo'].GetOptionList('Foo Bar'), ['foo']) + def test_load_cfg_files_bad_format(self): + # gh-66172: rename an unparsable user file and save the exception. + conf = self.new_config(_utest=True) + tmpdir = tempfile.TemporaryDirectory() + self.addCleanup(tmpdir.cleanup) + confpath = os.path.join(tmpdir.name, 'config-extensions.cfg') + with open(confpath, 'w') as f: + f.write('enable=1\n') # No section header. + conf.defaultCfg['foo'] = config.IdleConfParser('') # Empty, valid. + conf.userCfg['foo'] = config.IdleUserConfParser(confpath) + + self.assertIsNone(conf.file_load_error_message()) + conf.LoadCfgFiles() # Must not raise. + + self.assertEqual(len(conf.file_load_errors), 1) + file, err = conf.file_load_errors[0] + self.assertEqual(file, confpath) + # The bad file is moved aside, not left to be overwritten or deleted. + self.assertFalse(os.path.exists(confpath)) + with open(confpath + '.bad') as f: + self.assertEqual(f.read(), 'enable=1\n') + message = conf.file_load_error_message() + self.assertIn(confpath, message) + self.assertIn('MissingSectionHeaderError', message) + + def test_load_cfg_files_bad_encoding(self): + # gh-66172: a file that is not valid UTF-8 is handled like a bad parse. + conf = self.new_config(_utest=True) + tmpdir = tempfile.TemporaryDirectory() + self.addCleanup(tmpdir.cleanup) + confpath = os.path.join(tmpdir.name, 'config-main.cfg') + with open(confpath, 'wb') as f: + f.write(b'[Section]\nkey = \xff\n') # Invalid UTF-8. + conf.defaultCfg['foo'] = config.IdleConfParser('') # Empty, valid. + conf.userCfg['foo'] = config.IdleUserConfParser(confpath) + + conf.LoadCfgFiles() # Must not raise. + + self.assertEqual(len(conf.file_load_errors), 1) + self.assertIsInstance(conf.file_load_errors[0][1], UnicodeDecodeError) + self.assertFalse(os.path.exists(confpath)) + self.assertTrue(os.path.exists(confpath + '.bad')) + def test_save_user_cfg_files(self): conf = self.mock_config() diff --git a/Lib/idlelib/pyshell.py b/Lib/idlelib/pyshell.py index ef3d014d936ce85..6e57e306a3678d9 100755 --- a/Lib/idlelib/pyshell.py +++ b/Lib/idlelib/pyshell.py @@ -1612,6 +1612,12 @@ def main(): root.withdraw() fix_scaling(root) + # Warn about configuration files that could not be parsed (gh-66172). + config_error = idleConf.file_load_error_message() + if config_error: + messagebox.showwarning('IDLE Configuration Warning', config_error, + parent=root) + # set application icon icondir = os.path.join(os.path.dirname(__file__), 'Icons') if system() == 'Windows': diff --git a/Misc/NEWS.d/next/IDLE/2026-07-01-13-30-00.gh-issue-66172.Qm4xR7.rst b/Misc/NEWS.d/next/IDLE/2026-07-01-13-30-00.gh-issue-66172.Qm4xR7.rst new file mode 100644 index 000000000000000..c4248f85b98a258 --- /dev/null +++ b/Misc/NEWS.d/next/IDLE/2026-07-01-13-30-00.gh-issue-66172.Qm4xR7.rst @@ -0,0 +1,3 @@ +IDLE no longer fails to start when a user configuration file is corrupt. +The unparsable file is renamed with a ".bad" suffix, default settings are +used instead, and a warning lists the affected files. From fd0970c0ab7eb8c685ef9e5476f36e7d108c19ac Mon Sep 17 00:00:00 2001 From: Serhiy Storchaka Date: Sun, 13 Sep 2026 23:45:34 +0300 Subject: [PATCH 6/6] gh-156713: Fix test_nturl2path for non-UTF-8 filesystem encodings (GH-157461) Use os_helper.FS_NONASCII and os_helper.TESTFN_UNDECODABLE instead of hardcoded UTF-8 results. Co-authored-by: Claude Opus 5 (1M context) --- Lib/test/test_nturl2path.py | 32 +++++++++++++++++++++++++------- 1 file changed, 25 insertions(+), 7 deletions(-) diff --git a/Lib/test/test_nturl2path.py b/Lib/test/test_nturl2path.py index b4532137968d6e3..56e47aef5fe4c59 100644 --- a/Lib/test/test_nturl2path.py +++ b/Lib/test/test_nturl2path.py @@ -1,7 +1,9 @@ +import os import sys import unittest import urllib.parse +from test.support import os_helper from test.support import warnings_helper @@ -36,7 +38,6 @@ def test_pathname2url(self): self.assertEqual(fn('C:\\a\\b.c\\'), '///C:/a/b.c/') self.assertEqual(fn('C:\\a\\\\b.c'), '///C:/a//b.c') self.assertEqual(fn('C:\\a\\b%#c'), '///C:/a/b%25%23c') - self.assertEqual(fn('C:\\a\\b\xe9'), '///C:/a/b%C3%A9') self.assertEqual(fn('C:\\foo\\bar\\spam.foo'), "///C:/foo/bar/spam.foo") # NTFS alternate data streams self.assertEqual(fn('C:\\foo:bar'), '///C:/foo%3Abar') @@ -47,7 +48,7 @@ def test_pathname2url(self): self.assertEqual(fn("\\\\\\folder\\test\\"), '///folder/test/') self.assertEqual(fn('\\\\some\\share\\'), '//some/share/') self.assertEqual(fn('\\\\some\\share\\a\\b.c'), '//some/share/a/b.c') - self.assertEqual(fn('\\\\some\\share\\a\\b%#c\xe9'), '//some/share/a/b%25%23c%C3%A9') + self.assertEqual(fn('\\\\some\\share\\a\\b%#c'), '//some/share/a/b%25%23c') # Alternate path separator self.assertEqual(fn('C:/a/b.c'), '///C:/a/b.c') self.assertEqual(fn('//some/share/a/b.c'), '//some/share/a/b.c') @@ -60,14 +61,28 @@ def test_pathname2url(self): for url in urls: self.assertEqual(fn(nturl2path.url2pathname(url)), url) + @unittest.skipUnless(os_helper.FS_NONASCII, 'need os_helper.FS_NONASCII') + def test_pathname2url_nonascii(self): + encoding = sys.getfilesystemencoding() + errors = sys.getfilesystemencodeerrors() + char = os_helper.FS_NONASCII + quoted = urllib.parse.quote(char, encoding=encoding, errors=errors) + self.assertEqual(nturl2path.pathname2url(f'C:\\a\\b{char}'), + '///C:/a/b' + quoted) + self.assertEqual(nturl2path.pathname2url(f'\\\\some\\share\\a\\b{char}'), + '//some/share/a/b' + quoted) + + @unittest.skipUnless(os_helper.TESTFN_UNDECODABLE, + 'need os_helper.TESTFN_UNDECODABLE') def test_pathname2url_surrogates(self): # gh-156713: the filesystem encoding and error handler are used, # so that paths containing surrogate characters can be converted. encoding = sys.getfilesystemencoding() errors = sys.getfilesystemencodeerrors() - tail = urllib.parse.quote('a\udcff', encoding=encoding, errors=errors) - self.assertEqual(nturl2path.pathname2url('C:\\a\udcff'), - '///C:/' + tail) + path = os.fsdecode(os_helper.TESTFN_UNDECODABLE) + url = urllib.parse.quote(path, encoding=encoding, errors=errors) + self.assertEqual(nturl2path.pathname2url('C:\\' + path), + '///C:/' + url) def test_url2pathname(self): fn = nturl2path.url2pathname @@ -114,14 +129,17 @@ def test_url2pathname(self): self.assertEqual(fn(nturl2path.pathname2url(path)), path) + @unittest.skipUnless(os_helper.TESTFN_UNDECODABLE, + 'need os_helper.TESTFN_UNDECODABLE') def test_url2pathname_surrogates(self): # gh-156713: the filesystem encoding and error handler are used, so # that URLs containing percent-encoded surrogates can be converted. encoding = sys.getfilesystemencoding() errors = sys.getfilesystemencodeerrors() - url = urllib.parse.quote('a\udcff', encoding=encoding, errors=errors) + path = os.fsdecode(os_helper.TESTFN_UNDECODABLE) + url = urllib.parse.quote(path, encoding=encoding, errors=errors) self.assertEqual(nturl2path.url2pathname('///C:/' + url), - 'C:\\a\udcff') + 'C:\\' + path) if __name__ == '__main__':