https://github.com/python/cpython/commit/7f7ff4c99d1f53424be8c323c50f3623c3488ba1
commit: 7f7ff4c99d1f53424be8c323c50f3623c3488ba1
branch: main
author: ankhikarmakar <[email protected]>
committer: serhiy-storchaka <[email protected]>
date: 2026-09-18T10:56:30+03:00
summary:

gh-145856: Fix plistlib.dump() with skipkeys=True and sort_keys=True (GH-150109)

A dict with non-string keys raised TypeError, because the keys were sorted
before skipping non-string keys.

Co-authored-by: VanshAgarwal24036 <[email protected]>
Co-authored-by: Serhiy Storchaka <[email protected]>
Co-authored-by: Claude Opus 5 (1M context) <[email protected]>

files:
A Misc/NEWS.d/next/Library/2026-05-20-14-00-00.gh-issue-145856.Pl5kZx.rst
M Lib/plistlib.py
M Lib/test/test_plistlib.py

diff --git a/Lib/plistlib.py b/Lib/plistlib.py
index 93f3ef5e38af84..822fbcf17b16d0 100644
--- a/Lib/plistlib.py
+++ b/Lib/plistlib.py
@@ -159,6 +159,25 @@ def _date_to_string(d, aware_datetime):
         d.hour, d.minute, d.second
     )
 
+def _dict_items(d, sort_keys, skipkeys):
+    """Return the (key, value) pairs of a dict, sorted if needed.
+
+    Sorting fails for keys of different types, so non-string keys are
+    removed or reported before sorting.
+    """
+    items = d.items()
+    if sort_keys:
+        if skipkeys:
+            items = [item for item in items if isinstance(item[0], str)]
+            items.sort()
+        else:
+            for key in d:
+                if not isinstance(key, str):
+                    raise TypeError("keys must be strings")
+            items = sorted(items)
+    return items
+
+
 def _escape(text):
     m = _controlCharPat.search(text)
     if m is not None:
@@ -388,11 +407,7 @@ def write_bytes(self, data):
     def write_dict(self, d):
         if d:
             self.begin_element("dict")
-            if self._sort_keys:
-                items = sorted(d.items())
-            else:
-                items = d.items()
-
+            items = _dict_items(d, self._sort_keys, self._skipkeys)
             for key, value in items:
                 if not isinstance(key, str):
                     if self._skipkeys:
@@ -718,10 +733,7 @@ def _flatten(self, value):
         if isinstance(value, (dict, frozendict)):
             keys = []
             values = []
-            items = value.items()
-            if self._sort_keys:
-                items = sorted(items)
-
+            items = _dict_items(value, self._sort_keys, self._skipkeys)
             for k, v in items:
                 if not isinstance(k, str):
                     if self._skipkeys:
@@ -839,11 +851,7 @@ def _write_object(self, value):
         elif isinstance(value, (dict, frozendict)):
             keyRefs, valRefs = [], []
 
-            if self._sort_keys:
-                rootItems = sorted(value.items())
-            else:
-                rootItems = value.items()
-
+            rootItems = _dict_items(value, self._sort_keys, self._skipkeys)
             for k, v in rootItems:
                 if not isinstance(k, str):
                     if self._skipkeys:
diff --git a/Lib/test/test_plistlib.py b/Lib/test/test_plistlib.py
index b9c261310bb567..fd6036c033ffb8 100644
--- a/Lib/test/test_plistlib.py
+++ b/Lib/test/test_plistlib.py
@@ -722,20 +722,34 @@ def test_skipkeys(self):
             'snake': 'aWord',
         }
 
+        for fmt in ALL_FORMATS:
+            for sort_keys in (False, True):
+                with self.subTest(fmt=fmt, sort_keys=sort_keys):
+                    data = plistlib.dumps(
+                        pl, fmt=fmt, skipkeys=True, sort_keys=sort_keys)
+
+                    pl2 = plistlib.loads(data)
+                    self.assertEqual(pl2, {'snake': 'aWord'})
+
+                    fp = BytesIO()
+                    plistlib.dump(
+                        pl, fp, fmt=fmt, skipkeys=True, sort_keys=sort_keys)
+                    data = fp.getvalue()
+                    pl2 = plistlib.loads(fp.getvalue())
+                    self.assertEqual(pl2, {'snake': 'aWord'})
+
+    def test_skipkeys_with_sort_keys_mixed_types(self):
+        # gh-145856: skipkeys=True + sort_keys=True with mixed key types
+        # used to raise TypeError because the sort ran before the filter.
+        pl = {1: 'a', 'z': 'b', 'a': 'c'}
+
         for fmt in ALL_FORMATS:
             with self.subTest(fmt=fmt):
                 data = plistlib.dumps(
-                    pl, fmt=fmt, skipkeys=True, sort_keys=False)
-
-                pl2 = plistlib.loads(data)
-                self.assertEqual(pl2, {'snake': 'aWord'})
-
-                fp = BytesIO()
-                plistlib.dump(
-                    pl, fp, fmt=fmt, skipkeys=True, sort_keys=False)
-                data = fp.getvalue()
-                pl2 = plistlib.loads(fp.getvalue())
-                self.assertEqual(pl2, {'snake': 'aWord'})
+                    pl, fmt=fmt, skipkeys=True, sort_keys=True)
+                pl2 = plistlib.loads(data, dict_type=collections.OrderedDict)
+                self.assertEqual(dict(pl2), {'z': 'b', 'a': 'c'})
+                self.assertEqual(list(pl2.keys()), ['a', 'z'])
 
     def test_tuple_members(self):
         pl = {
diff --git 
a/Misc/NEWS.d/next/Library/2026-05-20-14-00-00.gh-issue-145856.Pl5kZx.rst 
b/Misc/NEWS.d/next/Library/2026-05-20-14-00-00.gh-issue-145856.Pl5kZx.rst
new file mode 100644
index 00000000000000..9f8cc32fd81c88
--- /dev/null
+++ b/Misc/NEWS.d/next/Library/2026-05-20-14-00-00.gh-issue-145856.Pl5kZx.rst
@@ -0,0 +1,4 @@
+Fix :func:`plistlib.dumps` and :func:`plistlib.dump` so that ``skipkeys=True``
+together with ``sort_keys=True`` correctly drops non-string keys when the
+dictionary contains a mix of string and non-string keys.  Previously the sort
+ran before the filter and raised :exc:`TypeError`.

_______________________________________________
Python-checkins mailing list -- [email protected]
To unsubscribe send an email to [email protected]
https://mail.python.org/mailman3//lists/python-checkins.python.org
Member address: [email protected]

Reply via email to