Repository: jsonpickle/jsonpickle
Pull request #483 (2024-03-12 21:23:21)

Add de/encode feature-flag for readonly attributes
This PR adds a feature-flag that can be set to True for graceful handling of read-only attributes, and which produces concise output. There are a few BS commits in the history because my local black version was out-of-date, and I had some issues doing merges as I'm still trying to get used to git-cola.

In the future, I'm going to update the documentation for decode and other such functions to be more similar to encode, because I noticed some formatting differences that are bugging me.


Patch:
diff --git a/jsonpickle/pickler.py b/jsonpickle/pickler.py
index 7b37e57..c660e10 100644
--- a/jsonpickle/pickler.py
+++ b/jsonpickle/pickler.py
@@ -36,6 +36,7 @@ def encode(
     indent=None,
     separators=None,
     include_properties=False,
+    handle_readonly=False,
 ):
     """Return a JSON formatted representation of value, a Python object.
 
@@ -118,6 +119,11 @@ def encode(
         meant to be used if processing the json outside of Python. Certain types
         such as sets will not pickle due to not having a native-json equivalent.
         Defaults to ``False``.
+    :param handle_readonly:
+        Handle objects with readonly methods, such as Django's SafeString. This
+        basically prevents jsonpickle from raising an exception for such objects.
+        You MUST set ``handle_readonly=True`` for the decoding if you encode with
+        this flag set to ``True``.
 
     >>> encode('my string') == '"my string"'
     True
@@ -143,6 +149,8 @@ def encode(
         use_base85=use_base85,
         fail_safe=fail_safe,
         include_properties=include_properties,
+        handle_readonly=handle_readonly,
+        original_object=value,
     )
     return backend.encode(
         context.flatten(value, reset=reset), indent=indent, separators=separators
@@ -190,6 +198,8 @@ class Pickler(object):
         use_base85=False,
         fail_safe=None,
         include_properties=False,
+        handle_readonly=False,
+        original_object=None,
     ):
         self.unpicklable = unpicklable
         self.make_refs = make_refs
@@ -212,6 +222,8 @@ class Pickler(object):
         self._use_decimal = use_decimal
         # A cache of objects that have already been flattened.
         self._flattened = {}
+        # Used for util.is_readonly, see +483
+        self.handle_readonly = handle_readonly
 
         if self.use_base85:
             self._bytes_tag = tags.B85
@@ -224,6 +236,8 @@ class Pickler(object):
         self.fail_safe = fail_safe
         self.include_properties = include_properties
 
+        self._original_object = original_object
+
     def _determine_sort_keys(self):
         for _, options in getattr(self.backend, '_encoder_options', {}).values():
             if options.get("sort_keys", False):
@@ -417,6 +431,13 @@ class Pickler(object):
         """Flatten a key/value pair into the passed-in dictionary."""
         if not util.is_picklable(k, v):
             return data
+        # TODO: use inspect.getmembers_static on 3.11+ because it avoids dynamic attribute lookups
+        if (
+            self.handle_readonly
+            and k in {attr for attr, val in inspect.getmembers(self._original_object)}
+            and util.is_readonly(self._original_object, k, v)
+        ):
+            return data
 
         if k is None:
             k = 'null'  # for compatibility with common json encoders
diff --git a/jsonpickle/unpickler.py b/jsonpickle/unpickler.py
index ea84e4e..7b022cf 100644
--- a/jsonpickle/unpickler.py
+++ b/jsonpickle/unpickler.py
@@ -25,6 +25,7 @@ def decode(
     classes=None,
     v1_decode=False,
     on_missing="ignore",
+    handle_readonly=False,
 ):
     """Convert a JSON string into a Python object.
 
@@ -62,6 +63,11 @@ def decode(
     name (a string) as the only parameter. Strings passed to on_missing are
     lowercased automatically.
 
+    The keyword argument 'handle_readonly' defaults to False.
+    If set to True, the Unpickler will handle objects encoded with
+    'handle_readonly' properly. Do not set this flag for objects not encoded
+    with 'handle_readonly' set to True.
+
 
     >>> decode('"my string"') == 'my string'
     True
@@ -83,6 +89,7 @@ def decode(
         safe=safe,
         v1_decode=v1_decode,
         on_missing=on_missing,
+        handle_readonly=handle_readonly,
     )
     data = backend.decode(string)
     return context.restore(data, reset=reset, classes=classes)
@@ -296,13 +303,20 @@ def has_tag_dict(obj, tag):
 
 class Unpickler(object):
     def __init__(
-        self, backend=None, keys=False, safe=False, v1_decode=False, on_missing="ignore"
+        self,
+        backend=None,
+        keys=False,
+        safe=False,
+        v1_decode=False,
+        on_missing="ignore",
+        handle_readonly=False,
     ):
         self.backend = backend or json
         self.keys = keys
         self.safe = safe
         self.v1_decode = v1_decode
         self.on_missing = on_missing
+        self.handle_readonly = handle_readonly
 
         self.reset()
 
@@ -630,7 +644,10 @@ class Unpickler(object):
                         if (
                             hasattr(instance, "__slots__")
                             and not len(instance.__slots__)
-                            and issubclass(instance.__class__, (int, str))
+                            and issubclass(instance.__class__, int)
+                            and self.handle_readonly
+                            # we have to handle this separately because of +483
+                            and issubclass(instance.__class__, str)
                         ):
                             continue
                         raise e
diff --git a/jsonpickle/util.py b/jsonpickle/util.py
index eadbcc1..2ce9795 100644
--- a/jsonpickle/util.py
+++ b/jsonpickle/util.py
@@ -397,6 +397,20 @@ def is_cython_function(obj):
     )
 
 
+def is_readonly(obj, attr, value):
+    # CPython 3.11+ has 0-cost try/except, please use up-to-date versions!
+    try:
+        setattr(obj, attr, value)
+        return False
+    except AttributeError:
+        # this is okay, it means the attribute couldn't be set
+        return True
+    except TypeError:
+        # this should only be happening when obj is a dict
+        # as these errors happen when attr isn't a str
+        return True
+
+
 def in_dict(obj, key, default=False):
     """
     Returns true if key exists in obj.__dict__; false if not in.


Test patch:
diff --git a/tests/jsonpickle_test.py b/tests/jsonpickle_test.py
index 3a98a4d..dc12345 100644
--- a/tests/jsonpickle_test.py
+++ b/tests/jsonpickle_test.py
@@ -164,6 +164,15 @@ class MyPropertiesDict:
         )
 
 
+# see issue #478
+class SafeData:
+    __slots__ = ()
+
+
+class SafeString(str, SafeData):
+    __slots__ = ()
+
+
 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)
@@ -990,6 +999,17 @@ class JSONPickleTestCase(SkippableTest):
         pickler.flatten([liszt, liszt, liszt, liszt, liszt])
         self.assertEqual(pickler._depth, -1)
 
+    def test_readonly_attrs(self):
+        s = SafeString("test")
+        pickled = jsonpickle.encode(s, handle_readonly=True)
+        pickled_json_dict = jsonpickle.backend.json.loads(pickled)
+        # make sure it's giving concise output by erroring if it includes
+        # a default method which is unnecessary
+        self.assertTrue("join" not in pickled_json_dict)
+        unpickled = jsonpickle.decode(pickled)
+        self.assertEqual(unpickled.__class__, SafeString)
+        self.assertEqual(unpickled, s)
+
 
 class PicklableNamedTuple(object):
     """