From 5f28aa2f372339ba0c70373b96d33ec4d2879e04 Mon Sep 17 00:00:00 2001 From: Serhiy Storchaka Date: Mon, 12 Jan 2026 10:40:06 +0200 Subject: [PATCH 1/5] gh-143346: Fix calculation of the line width for wrapped Base64 in plistlib (GH-143347) It was incorrect in case of mixed tabs and spaces in indentation. --- Lib/plistlib.py | 2 +- Lib/test/test_plistlib.py | 64 ++++++++++++++++++- ...-01-02-12-55-52.gh-issue-143346.iTekce.rst | 2 + 3 files changed, 66 insertions(+), 2 deletions(-) create mode 100644 Misc/NEWS.d/next/Library/2026-01-02-12-55-52.gh-issue-143346.iTekce.rst diff --git a/Lib/plistlib.py b/Lib/plistlib.py index 655c51eea3da5d..5b2b4e42c95a83 100644 --- a/Lib/plistlib.py +++ b/Lib/plistlib.py @@ -384,7 +384,7 @@ def write_bytes(self, data): self._indent_level -= 1 maxlinelength = max( 16, - 76 - len(self.indent.replace(b"\t", b" " * 8) * self._indent_level)) + 76 - len((self.indent * self._indent_level).expandtabs())) for line in _encode_base64(data, maxlinelength).split(b"\n"): if line: diff --git a/Lib/test/test_plistlib.py b/Lib/test/test_plistlib.py index de2a2fd1fc34bf..d9216be4d95658 100644 --- a/Lib/test/test_plistlib.py +++ b/Lib/test/test_plistlib.py @@ -509,6 +509,69 @@ def test_bytes(self): data2 = plistlib.dumps(pl2) self.assertEqual(data, data2) + def test_bytes_indent(self): + header = ( + b'\n' + b'\n' + b'\n') + data = [{'bytes': bytes(range(50))}] + pl = plistlib.dumps(data) + self.assertEqual(pl, header + + b'\n' + b'\t\n' + b'\t\tbytes\n' + b'\t\t\n' + b'\t\tAAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8gISIjJCUmJygpKiss\n' + b'\t\tLS4vMDE=\n' + b'\t\t\n' + b'\t\n' + b'\n' + b'\n') + + def dumps_with_indent(data, indent): + fp = BytesIO() + writer = plistlib._PlistWriter(fp, indent=indent) + writer.write(data) + return fp.getvalue() + + pl = dumps_with_indent(data, b' ') + self.assertEqual(pl, header + + b'\n' + b' \n' + b' bytes\n' + b' \n' + b' AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8gISIjJCUmJygpKissLS4vMDE=\n' + b' \n' + b' \n' + b'\n' + b'\n') + + pl = dumps_with_indent(data, b' \t') + self.assertEqual(pl, header + + b'\n' + b' \t\n' + b' \t \tbytes\n' + b' \t \t\n' + b' \t \tAAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8gISIjJCUmJygpKiss\n' + b' \t \tLS4vMDE=\n' + b' \t \t\n' + b' \t\n' + b'\n' + b'\n') + + pl = dumps_with_indent(data, b'\t ') + self.assertEqual(pl, header + + b'\n' + b'\t \n' + b'\t \t bytes\n' + b'\t \t \n' + b'\t \t AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8gISIjJCUmJygp\n' + b'\t \t KissLS4vMDE=\n' + b'\t \t \n' + b'\t \n' + b'\n' + b'\n') + def test_loads_str_with_xml_fmt(self): pl = self._create() b = plistlib.dumps(pl) @@ -581,7 +644,6 @@ def test_appleformatting(self): self.assertEqual(data, TESTDATA[fmt], "generated data was not identical to Apple's output") - def test_appleformattingfromliteral(self): self.maxDiff = None for fmt in ALL_FORMATS: diff --git a/Misc/NEWS.d/next/Library/2026-01-02-12-55-52.gh-issue-143346.iTekce.rst b/Misc/NEWS.d/next/Library/2026-01-02-12-55-52.gh-issue-143346.iTekce.rst new file mode 100644 index 00000000000000..93c45eefe373d3 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-01-02-12-55-52.gh-issue-143346.iTekce.rst @@ -0,0 +1,2 @@ +Fix incorrect wrapping of the Base64 data in :class:`!plistlib._PlistWriter` +when the indent contains a mix of tabs and spaces. From dbd10a6c29ba1cfc9348924a090b5dc514470002 Mon Sep 17 00:00:00 2001 From: Serhiy Storchaka Date: Mon, 12 Jan 2026 10:45:10 +0200 Subject: [PATCH 2/5] gh-142881: Fix concurrent and reentrant call of atexit.unregister() (GH-142901) --- Lib/test/_test_atexit.py | 34 +++++++++++++++++++ ...-12-17-20-18-17.gh-issue-142881.5IizIQ.rst | 1 + Modules/atexitmodule.c | 28 +++++++++++---- 3 files changed, 56 insertions(+), 7 deletions(-) create mode 100644 Misc/NEWS.d/next/Library/2025-12-17-20-18-17.gh-issue-142881.5IizIQ.rst diff --git a/Lib/test/_test_atexit.py b/Lib/test/_test_atexit.py index 490b0686a0c179..2e961d6a4854a0 100644 --- a/Lib/test/_test_atexit.py +++ b/Lib/test/_test_atexit.py @@ -148,6 +148,40 @@ def __eq__(self, other): atexit.unregister(Evil()) atexit._clear() + def test_eq_unregister(self): + # Issue #112127: callback's __eq__ may call unregister + def f1(): + log.append(1) + def f2(): + log.append(2) + def f3(): + log.append(3) + + class Pred: + def __eq__(self, other): + nonlocal cnt + cnt += 1 + if cnt == when: + atexit.unregister(what) + if other is f2: + return True + return False + + for what, expected in ( + (f1, [3]), + (f2, [3, 1]), + (f3, [1]), + ): + for when in range(1, 4): + with self.subTest(what=what.__name__, when=when): + cnt = 0 + log = [] + for f in (f1, f2, f3): + atexit.register(f) + atexit.unregister(Pred()) + atexit._run_exitfuncs() + self.assertEqual(log, expected) + if __name__ == "__main__": unittest.main() diff --git a/Misc/NEWS.d/next/Library/2025-12-17-20-18-17.gh-issue-142881.5IizIQ.rst b/Misc/NEWS.d/next/Library/2025-12-17-20-18-17.gh-issue-142881.5IizIQ.rst new file mode 100644 index 00000000000000..02f22d367bd831 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2025-12-17-20-18-17.gh-issue-142881.5IizIQ.rst @@ -0,0 +1 @@ +Fix concurrent and reentrant call of :func:`atexit.unregister`. diff --git a/Modules/atexitmodule.c b/Modules/atexitmodule.c index f81f0b5724799b..1c901d9124d9ca 100644 --- a/Modules/atexitmodule.c +++ b/Modules/atexitmodule.c @@ -256,22 +256,36 @@ atexit_ncallbacks(PyObject *module, PyObject *Py_UNUSED(dummy)) static int atexit_unregister_locked(PyObject *callbacks, PyObject *func) { - for (Py_ssize_t i = 0; i < PyList_GET_SIZE(callbacks); ++i) { + for (Py_ssize_t i = PyList_GET_SIZE(callbacks) - 1; i >= 0; --i) { PyObject *tuple = Py_NewRef(PyList_GET_ITEM(callbacks, i)); assert(PyTuple_CheckExact(tuple)); PyObject *to_compare = PyTuple_GET_ITEM(tuple, 0); int cmp = PyObject_RichCompareBool(func, to_compare, Py_EQ); - Py_DECREF(tuple); - if (cmp < 0) - { + if (cmp < 0) { + Py_DECREF(tuple); return -1; } if (cmp == 1) { // We found a callback! - if (PyList_SetSlice(callbacks, i, i + 1, NULL) < 0) { - return -1; + // But its index could have changed if it or other callbacks were + // unregistered during the comparison. + Py_ssize_t j = PyList_GET_SIZE(callbacks) - 1; + j = Py_MIN(j, i); + for (; j >= 0; --j) { + if (PyList_GET_ITEM(callbacks, j) == tuple) { + // We found the callback index! For real! + if (PyList_SetSlice(callbacks, j, j + 1, NULL) < 0) { + Py_DECREF(tuple); + return -1; + } + i = j; + break; + } } - --i; + } + Py_DECREF(tuple); + if (i >= PyList_GET_SIZE(callbacks)) { + i = PyList_GET_SIZE(callbacks); } } From f3759d21dd5e6510361d7409a1df53f35ebd9a58 Mon Sep 17 00:00:00 2001 From: Serhiy Storchaka Date: Mon, 12 Jan 2026 11:49:18 +0200 Subject: [PATCH 3/5] gh-142306: Improve errors for Element.remove() (GH-142308) * Raise TypeError for non-element argument in the Python implementation too. * Include the repr of the elements in the ValueError error message. --- Lib/test/test_xml_etree.py | 14 ++++++++++++-- Lib/xml/etree/ElementTree.py | 7 +++++-- .../2025-12-05-17-22-25.gh-issue-142306.Gj3_1m.rst | 2 ++ Modules/_elementtree.c | 3 +-- 4 files changed, 20 insertions(+), 6 deletions(-) create mode 100644 Misc/NEWS.d/next/Library/2025-12-05-17-22-25.gh-issue-142306.Gj3_1m.rst diff --git a/Lib/test/test_xml_etree.py b/Lib/test/test_xml_etree.py index 7aa949b2819172..93162f52ba0344 100644 --- a/Lib/test/test_xml_etree.py +++ b/Lib/test/test_xml_etree.py @@ -370,8 +370,7 @@ def test_simpleops(self): self.serialize_check(element, '') # 4 element.remove(subelement) self.serialize_check(element, '') # 5 - with self.assertRaisesRegex(ValueError, - r'Element\.remove\(.+\): element not found'): + with self.assertRaises(ValueError): element.remove(subelement) self.serialize_check(element, '') # 6 element[0:0] = [subelement, subelement, subelement] @@ -2758,6 +2757,17 @@ def test_pickle_issue18997(self): self.assertEqual(e2.tag, 'group') self.assertEqual(e2[0].tag, 'dogs') + def test_remove_errors(self): + e = ET.Element('tag') + with self.assertRaisesRegex(ValueError, + r" not in "): + e.remove(ET.Element('subtag')) + with self.assertRaisesRegex(TypeError, + r".*\bElement, not type"): + e.remove(ET.Element) + with self.assertRaisesRegex(TypeError, + r".*\bElement, not int"): + e.remove(1) class BadElementTest(ElementTestCase, unittest.TestCase): diff --git a/Lib/xml/etree/ElementTree.py b/Lib/xml/etree/ElementTree.py index 92f902b9a8b875..e3d81a2c4560d9 100644 --- a/Lib/xml/etree/ElementTree.py +++ b/Lib/xml/etree/ElementTree.py @@ -263,12 +263,15 @@ def remove(self, subelement): ValueError is raised if a matching element could not be found. """ - # assert iselement(element) try: self._children.remove(subelement) except ValueError: + # to align the error type with the C implementation + if isinstance(subelement, type) or not iselement(subelement): + raise TypeError('expected an Element, not %s' % + type(subelement).__name__) from None # to align the error message with the C implementation - raise ValueError("Element.remove(x): element not found") from None + raise ValueError(f"{subelement!r} not in {self!r}") from None def find(self, path, namespaces=None): """Find first matching element by tag name or path. diff --git a/Misc/NEWS.d/next/Library/2025-12-05-17-22-25.gh-issue-142306.Gj3_1m.rst b/Misc/NEWS.d/next/Library/2025-12-05-17-22-25.gh-issue-142306.Gj3_1m.rst new file mode 100644 index 00000000000000..ac39490a31e7e2 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2025-12-05-17-22-25.gh-issue-142306.Gj3_1m.rst @@ -0,0 +1,2 @@ +Improve errors for :meth:`Element.remove +`. diff --git a/Modules/_elementtree.c b/Modules/_elementtree.c index 22d3205e6ad314..f60a4c295e6495 100644 --- a/Modules/_elementtree.c +++ b/Modules/_elementtree.c @@ -1679,8 +1679,7 @@ _elementtree_Element_remove_impl(ElementObject *self, PyObject *subelement) } if (rc == 0) { - PyErr_SetString(PyExc_ValueError, - "Element.remove(x): element not found"); + PyErr_Format(PyExc_ValueError, "%R not in %R", subelement, self); return NULL; } From bd83a57463af70f0706c5d3f5873f881b49b9c06 Mon Sep 17 00:00:00 2001 From: Petr Viktorin Date: Mon, 12 Jan 2026 12:21:14 +0100 Subject: [PATCH 4/5] gh-143578: Restore note about patchlevel.h (#143596) Co-authored-by: Hugo van Kemenade <1324225+hugovk@users.noreply.github.com> --- Doc/c-api/apiabiversion.rst | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Doc/c-api/apiabiversion.rst b/Doc/c-api/apiabiversion.rst index a17329e4ed6032..cb98d4307ee522 100644 --- a/Doc/c-api/apiabiversion.rst +++ b/Doc/c-api/apiabiversion.rst @@ -67,6 +67,8 @@ See :ref:`stable` for a discussion of API and ABI stability across versions. The Python version as a string, for example, ``"3.4.1a2"``. +These macros are defined in :source:`Include/patchlevel.h`. + Run-time version ---------------- From 43c76587c1ba2c3937fa6834db10cffc604e39e0 Mon Sep 17 00:00:00 2001 From: Inada Naoki Date: Mon, 12 Jan 2026 20:21:23 +0900 Subject: [PATCH 5/5] gh-143189: fix insertdict() for non-Unicode key (#143285) --- Lib/test/test_dict.py | 19 +++++++++++++++++++ ...-12-30-06-48-48.gh-issue-143189.in_sv2.rst | 3 +++ Objects/dictobject.c | 7 +++++-- 3 files changed, 27 insertions(+), 2 deletions(-) create mode 100644 Misc/NEWS.d/next/Core_and_Builtins/2025-12-30-06-48-48.gh-issue-143189.in_sv2.rst diff --git a/Lib/test/test_dict.py b/Lib/test/test_dict.py index 77a5f2a108d07f..3b4e95015e5b4c 100644 --- a/Lib/test/test_dict.py +++ b/Lib/test/test_dict.py @@ -1602,6 +1602,7 @@ def __hash__(self): d.get(key2) def test_clear_at_lookup(self): + # gh-140551 dict crash if clear is called at lookup stage class X: def __hash__(self): return 1 @@ -1622,6 +1623,8 @@ def __eq__(self, other): self.assertEqual(len(d), 1) def test_split_table_update_with_str_subclass(self): + # gh-142218: inserting into a split table dictionary with a non str + # key that matches an existing key. class MyStr(str): pass class MyClass: pass obj = MyClass() @@ -1629,6 +1632,22 @@ class MyClass: pass obj.__dict__[MyStr('attr')] = 2 self.assertEqual(obj.attr, 2) + def test_split_table_insert_with_str_subclass(self): + # gh-143189: inserting into split table dictionary with a non str + # key that matches an existing key in the shared table but not in + # the dict yet. + + class MyStr(str): pass + class MyClass: pass + + obj = MyClass() + obj.attr1 = 1 + + obj2 = MyClass() + d = obj2.__dict__ + d[MyStr("attr1")] = 2 + self.assertIsInstance(list(d)[0], MyStr) + class CAPITest(unittest.TestCase): diff --git a/Misc/NEWS.d/next/Core_and_Builtins/2025-12-30-06-48-48.gh-issue-143189.in_sv2.rst b/Misc/NEWS.d/next/Core_and_Builtins/2025-12-30-06-48-48.gh-issue-143189.in_sv2.rst new file mode 100644 index 00000000000000..706b9ded20c4f1 --- /dev/null +++ b/Misc/NEWS.d/next/Core_and_Builtins/2025-12-30-06-48-48.gh-issue-143189.in_sv2.rst @@ -0,0 +1,3 @@ +Fix crash when inserting a non-:class:`str` key into a split table +dictionary when the key matches an existing key in the split table +but has no corresponding value in the dict. diff --git a/Objects/dictobject.c b/Objects/dictobject.c index 5a2bb7d3d8cd2d..a4e2fd19cefb63 100644 --- a/Objects/dictobject.c +++ b/Objects/dictobject.c @@ -1877,7 +1877,7 @@ static int insertdict(PyDictObject *mp, PyObject *key, Py_hash_t hash, PyObject *value) { - PyObject *old_value; + PyObject *old_value = NULL; Py_ssize_t ix; ASSERT_DICT_LOCKED(mp); @@ -1898,11 +1898,14 @@ insertdict(PyDictObject *mp, goto Fail; } - if (ix == DKIX_EMPTY) { + if (old_value == NULL) { // insert_combined_dict() will convert from non DICT_KEYS_GENERAL table // into DICT_KEYS_GENERAL table if key is not Unicode. // We don't convert it before _Py_dict_lookup because non-Unicode key // may change generic table into Unicode table. + // + // NOTE: ix may not be DKIX_EMPTY because split table may have key + // without value. if (insert_combined_dict(mp, hash, key, value) < 0) { goto Fail; }