Repository: jsonpickle/jsonpickle
Pull request #471 (2023-11-30 13:05:11)

Fix jsonpickle handling of missing modules or attributes
I think the inner `except` in `loadclass` is a bug. It's causing `loadclass` to skip attributes it can't find along the module path.

For example: `loadclass("numpy.missing")` returns `numpy` instead of failing.

Also, `loadclass("numpy.bla.bla.bla.array")` returns `numpy.array`.

Update:

There is a test named `test_unpickler_on_missing` which should have caught this, but it coincidentally passed anyway. 
The reason is: 
1. The test was decoding an instance of a local class. This shouldn't work, but because of the above bug, `loadclass` returned the test function as the class, instead of `None`.
2. It was then unable to instantiate the class (because it's not the class, it's the function)
3. The code treated this failure as a case of a missing object, and the test passed.

So I fixed the test to use a nonlocal class (Specifically, the `Outer` class defined in the test suite). I also modified it to test missing nested attributes.

The test still failed at this point, seems like it is because of another bug - a missing call to `_process_missing` in the case `loadclass` returns `None`. When adding it, all tests pass.


Patch:
diff --git a/CHANGES.rst b/CHANGES.rst
index e680b6a..ca91438 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -2,6 +2,9 @@ v3.0.3
 ======
     * Fixed a bug where pickling some built-in classes (e.g. zoneinfo) 
       could return a ``None`` module. (#447)
+    * Fixed a bug where unpickling a missing class would return a different object
+      instead of ``None``. (+471)
+    * Fixed the handling of missing classes when setting ``on_missing`` to ``warn`` or ``error``. (+471)
 
 v3.0.2
 ======
diff --git a/jsonpickle/unpickler.py b/jsonpickle/unpickler.py
index 2a99ebd..3031202 100644
--- a/jsonpickle/unpickler.py
+++ b/jsonpickle/unpickler.py
@@ -192,10 +192,7 @@ def loadclass(module_and_name, classes=None):
             __import__(module)
             obj = sys.modules[module]
             for class_name in names[up_to:]:
-                try:
-                    obj = getattr(obj, class_name)
-                except AttributeError:
-                    continue
+                obj = getattr(obj, class_name)
             return obj
         except (AttributeError, ImportError, ValueError):
             continue
@@ -772,6 +769,7 @@ class Unpickler(object):
             return instance
 
         if cls is None:
+            self._process_missing(class_name)
             return self._mkref(obj)
 
         return self._restore_object_instance(obj, cls, class_name)


Test patch:
diff --git a/tests/jsonpickle_test.py b/tests/jsonpickle_test.py
index f001703..cde1cf5 100644
--- a/tests/jsonpickle_test.py
+++ b/tests/jsonpickle_test.py
@@ -498,42 +498,44 @@ class PicklingTestCase(unittest.TestCase):
         self.assertTrue(cls is int)
 
     def test_unpickler_on_missing(self):
-        class SimpleClass(object):
-            def __init__(self, i):
-                self.i = i
+        encoded = jsonpickle.encode(Outer.Middle.Inner())
+        self.assertTrue(isinstance(jsonpickle.decode(encoded), Outer.Middle.Inner))
+
+        # Alter the encoded string to create cases where the class is missing, in multiple levels
+        self.assertTrue(encoded == '{"py/object": "jsonpickle_test.Outer.Middle.Inner"}')
+        missing_cases = [
+            '{"py/object": "MISSING.Outer.Middle.Inner"}',
+            '{"py/object": "jsonpickle_test.MISSING.Middle.Inner"}',
+            '{"py/object": "jsonpickle_test.Outer.MISSING.Inner"}',
+            '{"py/object": "jsonpickle_test.Outer.Middle.MISSING"}',
+        ]
+
+        for case in missing_cases:
+            # https://docs.python.org/3/library/warnings.html#testing-warnings
+            with warnings.catch_warnings(record=True) as w:
+                warnings.simplefilter("always")
+                jsonpickle.decode(case, on_missing='warn')
+                self.assertTrue(issubclass(w[-1].category, UserWarning))
+                self.assertTrue(
+                    "Unpickler._restore_object could not find" in str(w[-1].message)
+                )
+
+                jsonpickle.decode(case, on_missing=on_missing_callback)
+                self.assertTrue(issubclass(w[-1].category, RuntimeWarning))
+                self.assertTrue("The unpickler couldn't find" in str(w[-1].message))
 
-        frozen = jsonpickle.encode(SimpleClass(4))
-        del SimpleClass
-
-        # https://docs.python.org/3/library/warnings.html#testing-warnings
-
-        with warnings.catch_warnings(record=True) as w:
-            warnings.simplefilter("always")
-            jsonpickle.decode(frozen, on_missing='warn')
-            self.assertTrue(issubclass(w[-1].category, UserWarning))
             self.assertTrue(
-                "Unpickler._restore_object could not find" in str(w[-1].message)
+                jsonpickle.decode(case, on_missing='ignore')
+                == jsonpickle.backend.json.loads(case)
             )
 
-            jsonpickle.decode(frozen, on_missing=on_missing_callback)
-            self.assertTrue(issubclass(w[-1].category, RuntimeWarning))
-            self.assertTrue("The unpickler couldn't find" in str(w[-1].message))
-
-        self.assertTrue(
-            jsonpickle.decode(frozen, on_missing='ignore')
-            == {
-                'py/object': 'jsonpickle_test.PicklingTestCase.test_unpickler_on_missing.<locals>.SimpleClass',
-                'i': 4,
-            }
-        )
-
-        try:
-            jsonpickle.decode(frozen, on_missing='error')
-        except jsonpickle.errors.ClassNotFoundError:
-            # it's supposed to error
-            self.assertTrue(True)
-        else:
-            self.assertTrue(False)
+            try:
+                jsonpickle.decode(case, on_missing='error')
+            except jsonpickle.errors.ClassNotFoundError:
+                # it's supposed to error
+                self.assertTrue(True)
+            else:
+                self.assertTrue(False)
 
     def test_private_slot_members(self):
         obj = jsonpickle.loads(jsonpickle.dumps(MySlots()))