Repository: jsonpickle/jsonpickle
Pull request #392 (2022-06-17 18:06:33)

Allow non-fully qualified paths in classes argument to deserializer
`unpickler.loadclass` now checks to try and return the class even if the fully-qualified class name doesn't match anything in the classes dict. We do this by allowing users to pass a non-fully qualified class name to decode.


Patch:
diff --git a/CHANGES.rst b/CHANGES.rst
index 0bcc7b8..777f19e 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -12,6 +12,9 @@ v3.0.0
     * Remove jsonlib and yajl backends (py2 only)
     * Add ``include_properties`` option to the pickler. This should only
       be used if analyzing generated json outside of Python. (#297) (+387)
+    * Allow the ``classes`` argument to ``jsonpickle.decode`` to be a dict
+      of class name to class object. This lets you decode arbitrary dumps
+      into different classes. (#148) (+392)
 
 v2.2.0
 ======
diff --git a/jsonpickle/unpickler.py b/jsonpickle/unpickler.py
index 1bb3e69..7dd3422 100644
--- a/jsonpickle/unpickler.py
+++ b/jsonpickle/unpickler.py
@@ -34,9 +34,12 @@ def decode(
 
     The keyword argument 'classes' defaults to None.
     If set to a single class, or a sequence (list, set, tuple) of classes,
-    then the classes will be made available when constructing objects.  This
-    can be used to give jsonpickle access to local classes that are not
-    available through the global module import scope.
+    then the classes will be made available when constructing objects.
+    If set to a dictionary of class names to class objects, the class object
+    will be provided to jsonpickle to deserialize the class name into.
+    This can be used to give jsonpickle access to local classes that are not
+    available through the global module import scope, and the dict method can
+    be used to deserialize encoded objects into a new class.
 
     The keyword argument 'safe' defaults to False.
     If set to True, eval() is avoided, but backwards-compatible
@@ -173,7 +176,11 @@ def loadclass(module_and_name, classes=None):
         try:
             return classes[module_and_name]
         except KeyError:
-            pass
+            # maybe they didn't provide a fully qualified path
+            try:
+                return classes[module_and_name.rsplit('.', 1)[-1]]
+            except KeyError:
+                pass
     # Otherwise, load classes from globally-accessible imports
     names = module_and_name.split('.')
     # First assume that everything up to the last dot is the module name,
@@ -366,6 +373,9 @@ class Unpickler(object):
         if isinstance(classes, (list, tuple, set)):
             for cls in classes:
                 self.register_classes(cls)
+        elif isinstance(classes, dict):
+            for cls in classes.values():
+                self.register_classes(cls)
         else:
             self._classes[util.importable_name(classes)] = classes
 


Test patch:
diff --git a/tests/jsonpickle_test.py b/tests/jsonpickle_test.py
index 9ca8805..78b14fc 100644
--- a/tests/jsonpickle_test.py
+++ b/tests/jsonpickle_test.py
@@ -553,6 +553,16 @@ class PicklingTestCase(unittest.TestCase):
         self.assertTrue("py/property" in dumped)
         self.assertEqual(obj, jsonpickle.loads(dumped))
 
+    def test_load_non_fully_qualified_classes(self):
+        # reuse MyPropertiesSlots because it has a nice eq method
+        obj = MyPropertiesSlots()
+        encoded = jsonpickle.encode(obj)
+        # MyPropertiesSlots and MyPropertiesDict have compatible eq methods
+        self.assertEqual(
+            obj,
+            jsonpickle.decode(encoded, classes={"MyPropertiesSlots": MyPropertiesDict}),
+        )
+
 
 class JSONPickleTestCase(SkippableTest):
     def setUp(self):