Repository: jsonpickle/jsonpickle (python)

Issue:
Private members of __slots__ are skipped
Issue
-------------

"private" members of `__slots__` are skipped, but not private members of `__dict__`.

Steps to reproduce
--------------------------

In the following code, the two classes are identical apart from that the first uses `__slots__` and the second uses `__dict__`. 

### Actual result
 `jsonpickle` ignores the private `__beta` member in the `__slots__` version, but not in the `__dict__` version.

### Expected result
Similar behaviour for both classes.

### Code

```
class MySlots:
    __slots__ = "alpha", "__beta"
    
    def __init__( self ):
        self.alpha = 1
        self.__beta = 2

class MyDict:
    def __init__( self ):
        self.alpha = 1
        self.__beta = 2

def print_obj( obj ):
    alpha = getattr( obj, "alpha", "(missing)" )
    beta = getattr( obj, f"_{obj.__class__.__name__}__beta", "(missing)" )
    print( f"{alpha=}, {beta=}" )

import jsonpickle

slots_orig = MySlots()
dict_orig = MyDict()

print_obj( slots_orig ) # alpha=1, beta=2
print_obj( dict_orig ) # alpha=1, beta=2

slots_str = jsonpickle.dumps( slots_orig )
dict_str = jsonpickle.dumps( dict_orig )

slots_recon = jsonpickle.loads( slots_str )
dict_recon = jsonpickle.loads( dict_str )

print_obj( slots_recon ) # alpha=1, beta='(missing)'
print_obj( dict_recon ) # alpha=1, beta=2
```

Jsonpickle 1.3, Python 3.8.2, Windows 10.

Pull request:
Unmangle private __slots__ members
Closes #318.
Allows jsonpickle to encode private slots members by unmangling their names. If anyone thinks this should be optional, I can add that upon receipt of such feedback.

Patch:
diff --git a/CHANGES.rst b/CHANGES.rst
index f5aa049..575c5d2 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -6,7 +6,7 @@
    Users on older Python versions should pin requirements to ``jsonpickle<3.0.0``.
 
 v2.2.0
-======
+=======
 
     * Classes with a custom ``__getitem__()`` and ``append()``
       now pickle properly. (#362) (+379)
@@ -19,6 +19,9 @@ v2.2.0
     * Added a long-requested on_missing attribute to the Unpickler class.
       This lets you choose behavior for when jsonpickle can't find a class
       to deserialize to. (#190) (#193) (+384)
+    * Private members of ``__slots__`` are no longer skipped when encoding.
+      Any objects encoded with versions prior to 2.2.0 should still decode
+      properly. (#318) (+385)
 
 v2.1.0
 ======
diff --git a/jsonpickle/pickler.py b/jsonpickle/pickler.py
index 3b90250..5151108 100644
--- a/jsonpickle/pickler.py
+++ b/jsonpickle/pickler.py
@@ -704,7 +704,11 @@ class Pickler(object):
         ok = False
         for k in attrs:
             try:
-                value = getattr(obj, k)
+                if not k.startswith('__'):
+                    value = getattr(obj, k)
+                else:
+                    # we can use f-strings once we drop < 3.7 in jsonpickle 3.0
+                    value = getattr(obj, "_" + obj.__class__.__name__ + k)
                 flatten(k, value, data)
             except AttributeError:
                 # The attribute may have been deleted
diff --git a/jsonpickle/unpickler.py b/jsonpickle/unpickler.py
index 1456b76..3baab3b 100644
--- a/jsonpickle/unpickler.py
+++ b/jsonpickle/unpickler.py
@@ -613,7 +613,11 @@ class Unpickler(object):
                     self._namestack.pop()
                     continue
             else:
-                setattr(instance, k, value)
+                if not k.startswith('__'):
+                    setattr(instance, k, value)
+                else:
+                    # we can use f-strings once we drop < 3.7 in jsonpickle 3.0
+                    setattr(instance, "_" + instance.__class__.__name__ + k, value)
 
             # This instance has an instance variable named `k` that is
             # currently a proxy and must be replaced


Test patch:
diff --git a/tests/jsonpickle_test.py b/tests/jsonpickle_test.py
index 5c9d4cf..4d7da9d 100644
--- a/tests/jsonpickle_test.py
+++ b/tests/jsonpickle_test.py
@@ -105,6 +105,14 @@ class Outer(object):
             pass
 
 
+class MySlots:
+    __slots__ = ("alpha", "__beta")
+
+    def __init__(self):
+        self.alpha = 1
+        self.__beta = 1
+
+
 def on_missing_callback(class_name):
     # not actually a runtime problem but it doesn't matter
     warnings.warn("The unpickler couldn't find %s" % class_name, RuntimeWarning)
@@ -452,11 +460,11 @@ class PicklingTestCase(unittest.TestCase):
         no longer supported.
         """
         ae = jsonpickle.decode('{"py/type": "__builtin__.AssertionError"}')
-        assert ae is AssertionError
+        self.assertTrue(ae is AssertionError)
         ae = jsonpickle.decode('{"py/type": "exceptions.AssertionError"}')
-        assert ae is AssertionError
+        self.assertTrue(ae is AssertionError)
         cls = jsonpickle.decode('{"py/type": "__builtin__.int"}')
-        assert cls is int
+        self.assertTrue(cls is int)
 
     def test_unpickler_on_missing(self):
         class SimpleClass(object):
@@ -471,31 +479,45 @@ class PicklingTestCase(unittest.TestCase):
         with warnings.catch_warnings(record=True) as w:
             warnings.simplefilter("always")
             jsonpickle.decode(frozen, on_missing='warn')
-            assert issubclass(w[-1].category, UserWarning)
-            assert "Unpickler._restore_object could not find" in str(w[-1].message)
+            self.assertTrue(issubclass(w[-1].category, UserWarning))
+            self.assertTrue(
+                "Unpickler._restore_object could not find" in str(w[-1].message)
+            )
 
             jsonpickle.decode(frozen, on_missing=on_missing_callback)
-            assert issubclass(w[-1].category, RuntimeWarning)
-            assert "The unpickler couldn't find" in str(w[-1].message)
+            self.assertTrue(issubclass(w[-1].category, RuntimeWarning))
+            self.assertTrue("The unpickler couldn't find" in str(w[-1].message))
 
         if PY3:
-            assert jsonpickle.decode(frozen, on_missing='ignore') == {
-                'py/object': 'jsonpickle_test.PicklingTestCase.test_unpickler_on_missing.<locals>.SimpleClass',
-                'i': 4,
-            }
+            self.assertTrue(
+                jsonpickle.decode(frozen, on_missing='ignore')
+                == {
+                    'py/object': 'jsonpickle_test.PicklingTestCase.test_unpickler_on_missing.<locals>.SimpleClass',
+                    'i': 4,
+                }
+            )
         else:
-            assert jsonpickle.decode(frozen, on_missing='ignore') == {
-                u'i': 4,
-                u'py/object': u'jsonpickle_test.SimpleClass',
-            }
+            self.assertTrue(
+                jsonpickle.decode(frozen, on_missing='ignore')
+                == {
+                    u'i': 4,
+                    u'py/object': u'jsonpickle_test.SimpleClass',
+                }
+            )
 
         try:
             jsonpickle.decode(frozen, on_missing='error')
         except jsonpickle.errors.ClassNotFoundError:
             # it's supposed to error
-            assert True
+            self.assertTrue(True)
         else:
-            assert False
+            self.assertTrue(False)
+
+    def test_private_slot_members(self):
+        obj = jsonpickle.loads(jsonpickle.dumps(MySlots()))
+        alpha = getattr(obj, "alpha", "(missing alpha)")
+        beta = getattr(obj, "_" + obj.__class__.__name__ + "__beta", "(missing beta)")
+        self.assertEqual(alpha, beta)
 
 
 class JSONPickleTestCase(SkippableTest):