Skip to content

Commit 01a1dd2

Browse files
gh-77188: Add support for pickling private methods and nested classes (GH-21480)
Co-authored-by: Serhiy Storchaka <storchaka@gmail.com>
1 parent 4644fed commit 01a1dd2

File tree

9 files changed

+174
-1
lines changed

9 files changed

+174
-1
lines changed

Doc/whatsnew/3.15.rst

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -704,6 +704,13 @@ os.path
704704
(Contributed by Petr Viktorin for :cve:`2025-4517`.)
705705

706706

707+
pickle
708+
------
709+
710+
* Add support for pickling private methods and nested classes.
711+
(Contributed by Zackery Spytz and Serhiy Storchaka in :gh:`77188`.)
712+
713+
707714
resource
708715
--------
709716

Include/internal/pycore_symtable.h

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -151,7 +151,12 @@ extern int _PySymtable_LookupOptional(struct symtable *, void *, PySTEntryObject
151151
extern void _PySymtable_Free(struct symtable *);
152152

153153
extern PyObject *_Py_MaybeMangle(PyObject *privateobj, PySTEntryObject *ste, PyObject *name);
154-
extern PyObject* _Py_Mangle(PyObject *p, PyObject *name);
154+
155+
// Export for '_pickle' shared extension
156+
PyAPI_FUNC(PyObject *)
157+
_Py_Mangle(PyObject *, PyObject *);
158+
PyAPI_FUNC(int)
159+
_Py_IsPrivateName(PyObject *);
155160

156161
/* Flags for def-use information */
157162

Lib/pickle.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1175,6 +1175,17 @@ def save_global(self, obj, name=None):
11751175
if name is None:
11761176
name = obj.__name__
11771177

1178+
if '.__' in name:
1179+
# Mangle names of private attributes.
1180+
dotted_path = name.split('.')
1181+
for i, subpath in enumerate(dotted_path):
1182+
if i and subpath.startswith('__') and not subpath.endswith('__'):
1183+
prev = prev.lstrip('_')
1184+
if prev:
1185+
dotted_path[i] = f"_{prev.lstrip('_')}{subpath}"
1186+
prev = subpath
1187+
name = '.'.join(dotted_path)
1188+
11781189
module_name = whichmodule(obj, name)
11791190
if self.proto >= 2:
11801191
code = _extension_registry.get((module_name, name), _NoValue)

Lib/test/picklecommon.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -388,3 +388,48 @@ def pie(self):
388388
class Subclass(tuple):
389389
class Nested(str):
390390
pass
391+
392+
# For test_private_methods
393+
class PrivateMethods:
394+
def __init__(self, value):
395+
self.value = value
396+
397+
def __private_method(self):
398+
return self.value
399+
400+
def get_method(self):
401+
return self.__private_method
402+
403+
@classmethod
404+
def get_unbound_method(cls):
405+
return cls.__private_method
406+
407+
@classmethod
408+
def __private_classmethod(cls):
409+
return 43
410+
411+
@classmethod
412+
def get_classmethod(cls):
413+
return cls.__private_classmethod
414+
415+
@staticmethod
416+
def __private_staticmethod():
417+
return 44
418+
419+
@classmethod
420+
def get_staticmethod(cls):
421+
return cls.__private_staticmethod
422+
423+
# For test_private_nested_classes
424+
class PrivateNestedClasses:
425+
@classmethod
426+
def get_nested(cls):
427+
return cls.__Nested
428+
429+
class __Nested:
430+
@classmethod
431+
def get_nested2(cls):
432+
return cls.__Nested2
433+
434+
class __Nested2:
435+
pass

Lib/test/pickletester.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4118,6 +4118,33 @@ def test_c_methods(self):
41184118
with self.subTest(proto=proto, descr=descr):
41194119
self.assertRaises(TypeError, self.dumps, descr, proto)
41204120

4121+
def test_private_methods(self):
4122+
if self.py_version < (3, 15):
4123+
self.skipTest('not supported in Python < 3.15')
4124+
obj = PrivateMethods(42)
4125+
for proto in protocols:
4126+
with self.subTest(proto=proto):
4127+
unpickled = self.loads(self.dumps(obj.get_method(), proto))
4128+
self.assertEqual(unpickled(), 42)
4129+
unpickled = self.loads(self.dumps(obj.get_unbound_method(), proto))
4130+
self.assertEqual(unpickled(obj), 42)
4131+
unpickled = self.loads(self.dumps(obj.get_classmethod(), proto))
4132+
self.assertEqual(unpickled(), 43)
4133+
unpickled = self.loads(self.dumps(obj.get_staticmethod(), proto))
4134+
self.assertEqual(unpickled(), 44)
4135+
4136+
def test_private_nested_classes(self):
4137+
if self.py_version < (3, 15):
4138+
self.skipTest('not supported in Python < 3.15')
4139+
cls1 = PrivateNestedClasses.get_nested()
4140+
cls2 = cls1.get_nested2()
4141+
for proto in protocols:
4142+
with self.subTest(proto=proto):
4143+
unpickled = self.loads(self.dumps(cls1, proto))
4144+
self.assertIs(unpickled, cls1)
4145+
unpickled = self.loads(self.dumps(cls2, proto))
4146+
self.assertIs(unpickled, cls2)
4147+
41214148
def test_object_with_attrs(self):
41224149
obj = Object()
41234150
obj.a = 1
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
The :mod:`pickle` module now properly handles name-mangled private methods.

Modules/_pickle.c

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
#include "pycore_pystate.h" // _PyThreadState_GET()
2020
#include "pycore_runtime.h" // _Py_ID()
2121
#include "pycore_setobject.h" // _PySet_NextEntry()
22+
#include "pycore_symtable.h" // _Py_Mangle()
2223
#include "pycore_sysmodule.h" // _PySys_GetSizeOf()
2324
#include "pycore_unicodeobject.h" // _PyUnicode_EqualToASCIIString()
2425

@@ -1928,6 +1929,37 @@ get_dotted_path(PyObject *name)
19281929
return PyUnicode_Split(name, _Py_LATIN1_CHR('.'), -1);
19291930
}
19301931

1932+
static PyObject *
1933+
join_dotted_path(PyObject *dotted_path)
1934+
{
1935+
return PyUnicode_Join(_Py_LATIN1_CHR('.'), dotted_path);
1936+
}
1937+
1938+
/* Returns -1 (with an exception set) on error, 0 if there were no changes,
1939+
* 1 if some names were mangled. */
1940+
static int
1941+
mangle_dotted_path(PyObject *dotted_path)
1942+
{
1943+
int rc = 0;
1944+
Py_ssize_t n = PyList_GET_SIZE(dotted_path);
1945+
for (Py_ssize_t i = n-1; i > 0; i--) {
1946+
PyObject *subpath = PyList_GET_ITEM(dotted_path, i);
1947+
if (_Py_IsPrivateName(subpath)) {
1948+
PyObject *parent = PyList_GET_ITEM(dotted_path, i-1);
1949+
PyObject *mangled = _Py_Mangle(parent, subpath);
1950+
if (mangled == NULL) {
1951+
return -1;
1952+
}
1953+
if (mangled != subpath) {
1954+
rc = 1;
1955+
}
1956+
PyList_SET_ITEM(dotted_path, i, mangled);
1957+
Py_DECREF(subpath);
1958+
}
1959+
}
1960+
return rc;
1961+
}
1962+
19311963
static int
19321964
check_dotted_path(PickleState *st, PyObject *obj, PyObject *dotted_path)
19331965
{
@@ -3809,6 +3841,15 @@ save_global(PickleState *st, PicklerObject *self, PyObject *obj,
38093841
dotted_path = get_dotted_path(global_name);
38103842
if (dotted_path == NULL)
38113843
goto error;
3844+
switch (mangle_dotted_path(dotted_path)) {
3845+
case -1:
3846+
goto error;
3847+
case 1:
3848+
Py_SETREF(global_name, join_dotted_path(dotted_path));
3849+
if (global_name == NULL) {
3850+
goto error;
3851+
}
3852+
}
38123853
module_name = whichmodule(st, obj, global_name, dotted_path);
38133854
if (module_name == NULL)
38143855
goto error;

Objects/classobject.c

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
#include "pycore_object.h"
88
#include "pycore_pyerrors.h"
99
#include "pycore_pystate.h" // _PyThreadState_GET()
10+
#include "pycore_symtable.h" // _Py_Mangle()
1011
#include "pycore_weakref.h" // FT_CLEAR_WEAKREFS()
1112

1213

@@ -143,6 +144,20 @@ method___reduce___impl(PyMethodObject *self)
143144
if (funcname == NULL) {
144145
return NULL;
145146
}
147+
if (_Py_IsPrivateName(funcname)) {
148+
PyObject *classname = PyType_Check(funcself)
149+
? PyType_GetName((PyTypeObject *)funcself)
150+
: PyType_GetName(Py_TYPE(funcself));
151+
if (classname == NULL) {
152+
Py_DECREF(funcname);
153+
return NULL;
154+
}
155+
Py_SETREF(funcname, _Py_Mangle(classname, funcname));
156+
Py_DECREF(classname);
157+
if (funcname == NULL) {
158+
return NULL;
159+
}
160+
}
146161
return Py_BuildValue(
147162
"N(ON)", _PyEval_GetBuiltin(&_Py_ID(getattr)), funcself, funcname);
148163
}

Python/symtable.c

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3183,6 +3183,27 @@ _Py_MaybeMangle(PyObject *privateobj, PySTEntryObject *ste, PyObject *name)
31833183
return _Py_Mangle(privateobj, name);
31843184
}
31853185

3186+
int
3187+
_Py_IsPrivateName(PyObject *ident)
3188+
{
3189+
if (!PyUnicode_Check(ident)) {
3190+
return 0;
3191+
}
3192+
Py_ssize_t nlen = PyUnicode_GET_LENGTH(ident);
3193+
if (nlen < 3 ||
3194+
PyUnicode_READ_CHAR(ident, 0) != '_' ||
3195+
PyUnicode_READ_CHAR(ident, 1) != '_')
3196+
{
3197+
return 0;
3198+
}
3199+
if (PyUnicode_READ_CHAR(ident, nlen-1) == '_' &&
3200+
PyUnicode_READ_CHAR(ident, nlen-2) == '_')
3201+
{
3202+
return 0; /* Don't mangle __whatever__ */
3203+
}
3204+
return 1;
3205+
}
3206+
31863207
PyObject *
31873208
_Py_Mangle(PyObject *privateobj, PyObject *ident)
31883209
{

0 commit comments

Comments
 (0)