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
----------------
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_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/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/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/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/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/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/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/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.
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;
}
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);
}
}
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;
}