Repository: obspy/obspy

Issue:
Resource IDs can refer to wrong object when former object goes out of scope
Ubuntu 18.04, python 3.7, master branch of ObsPy

Currently `get_referred_object` method of the `ResourceIdentifier` can return the wrong object if the object that was bound to the `ResourceIdentifier` instance gets deleted and a new object is assigned to the same python id. [Here](https://stackoverflow.com/questions/20753364/why-is-the-id-of-a-python-class-not-unique-when-called-quickly) is a good SO post that demonstrates how python object ids are only unique over an object's lifetime, not over the program lifetime.

The following code demonstrates the problem:

```python
import obspy.core.event as ev

# a class to count instance creations
class Counter:
    count = 0
    def __init__(self):
        self.count = self.__class__.count
        self.__class__.count += 1

# simple setup of objects and resource IDs
rid1 = ev.ResourceIdentifier(referred_object=obj1)
rid2 = ev.ResourceIdentifier(referred_object=obj2)

# rid1 and rid2 work as expected, obj1, obj2 have the following ids
print(id(obj1), id(obj2))

# now if we delete obj1 and obj2 then create additional python objects which are
# bound to different resource ids
del obj1
del obj2
obj3, obj4 = Counter(), Counter()
rid3 = ev.ResourceIdentifier(referred_object=obj3)
rid4 = ev.ResourceIdentifier(referred_object=obj4)

#  If the python id of obj1 or obj2 id is re-used we are going to have problems...
print(id(obj3), id(obj4))

# rid3 and rid4 work as expected, of course. However, if obj3 or obj4 has the same python
# id as obj1 or obj2 then rid1 or rid2 will return the wrong object rather than None.
# So, the following will sometimes (~50% of the time) raise an AssertionError because
# obj3 or obj4 is assigned to  a python id that was previously used by obj1 or obj2
# and the object id is the key for the ResourceIdentifier._id_object_map dict
assert rid1.get_referred_object() is None
assert rid1.get_referred_object() is None
```
Resource IDs can refer to wrong object when former object goes out of scope
Ubuntu 18.04, python 3.7, master branch of ObsPy

Currently `get_referred_object` method of the `ResourceIdentifier` can return the wrong object if the object that was bound to the `ResourceIdentifier` instance gets deleted and a new object is assigned to the same python id. [Here](https://stackoverflow.com/questions/20753364/why-is-the-id-of-a-python-class-not-unique-when-called-quickly) is a good SO post that demonstrates how python object ids are only unique over an object's lifetime, not over the program lifetime.

The following code demonstrates the problem:

```python
import obspy.core.event as ev

# a class to count instance creations
class Counter:
    count = 0
    def __init__(self):
        self.count = self.__class__.count
        self.__class__.count += 1

# simple setup of objects and resource IDs
rid1 = ev.ResourceIdentifier(referred_object=obj1)
rid2 = ev.ResourceIdentifier(referred_object=obj2)

# rid1 and rid2 work as expected, obj1, obj2 have the following ids
print(id(obj1), id(obj2))

# now if we delete obj1 and obj2 then create additional python objects which are
# bound to different resource ids
del obj1
del obj2
obj3, obj4 = Counter(), Counter()
rid3 = ev.ResourceIdentifier(referred_object=obj3)
rid4 = ev.ResourceIdentifier(referred_object=obj4)

#  If the python id of obj1 or obj2 id is re-used we are going to have problems...
print(id(obj3), id(obj4))

# rid3 and rid4 work as expected, of course. However, if obj3 or obj4 has the same python
# id as obj1 or obj2 then rid1 or rid2 will return the wrong object rather than None.
# So, the following will sometimes (~50% of the time) raise an AssertionError because
# obj3 or obj4 is assigned to  a python id that was previously used by obj1 or obj2
# and the object id is the key for the ResourceIdentifier._id_object_map dict
assert rid1.get_referred_object() is None
assert rid1.get_referred_object() is None
```


Discussion:

Resolving patch:
diff --git a/obspy/core/event/resourceid.py b/obspy/core/event/resourceid.py
--- a/obspy/core/event/resourceid.py
+++ b/obspy/core/event/resourceid.py
@@ -280,7 +280,11 @@ class ResourceIdentifier(object):
     # {object_id: object}
     _id_object_map = WeakValueDictionary()
 
-    # set default _ResourceKey attributes and object_id
+    # A get objects hook to allow plugging in custom logic for finding objects
+    # with by resource_id if they are not found via normal means.
+    _get_object_hook = []
+
+    # Set default _ResourceKey attributes and object_id.
     _parent_key = _ResourceKeyDescriptor('_parent_key')
     _resource_key = _ResourceKeyDescriptor('_resource_key')
     _object_id = None
@@ -314,9 +318,17 @@ def get_referred_object(self):
         return None.
         """
         try:
-            return ResourceIdentifier._id_object_map[self._object_id]
+            out = ResourceIdentifier._id_object_map[self._object_key]
         except KeyError:
-            return self._get_similar_referred_object()
+            out = self._get_similar_referred_object()
+        if out is not None:
+            return out
+        else:  # If no object was found iterate get_object hooks.
+            for hook in self._get_object_hook:
+                out = hook(self.id)
+                if out is not None:
+                    self.set_referred_object(out)
+                    return out
 
     def _get_similar_referred_object(self):
         """
@@ -333,37 +345,65 @@ def _get_similar_referred_object(self):
         """
         # Warn if resource_id was bound but its object got gc'ed.
         if self._object_id is not None:
-            msg = ("The object with identity of: %d  and id of %s no longer"
+            msg = ("The object with identity of: %d  and id of %s no longer "
                    "exists, trying to find an object with the same id"
                    ) % (self._object_id, self.id)
             warnings.warn(msg, UserWarning)
         # try to get the object_id from the parent_id_tree
-        id_tree = ResourceIdentifier._parent_id_tree
-        try:
-            obj_id = id_tree[self._parent_key][self._resource_key]
-        except (KeyError, TypeError):
-            pass
-        else:
-            out = ResourceIdentifier._id_object_map[obj_id]
-            self.set_referred_object(out, warn=False)
-            return out
-        # else try to find the last object assigned the same resource_id
+        obj = self._get_object_from_parent_scope()
+        if obj is None:
+            # else try to find the last object assigned the same resource_id
+            obj = self._get_newest_object_with_same_resource_id()
+        # if an object was found bind it to resource_id and return it
+        if obj is not None:
+            self.set_referred_object(obj, warn=False)
+        return obj
+
+    def _get_newest_object_with_same_resource_id(self):
+        """
+        Return the newest object which has been bound to the same resource_id.
+        If No such object exists return None.
+        """
         try:
             rid_list = ResourceIdentifier._id_order[self._resource_key]
         except KeyError:  # no existing object is assigned to this resource_id
             return None
         while len(rid_list):
             try:
-                obj_id = ResourceIdentifier._id_order[self._resource_key][-1]
+                obj_key = ResourceIdentifier._id_order[self._resource_key][-1]
             except KeyError:
                 return None
             else:
                 try:
-                    obj = ResourceIdentifier._id_object_map[obj_id]
-                    self.set_referred_object(obj, warn=False)
+                    obj = ResourceIdentifier._id_object_map[obj_key]
+                except KeyError:  # Object got gc'ed or was not defined.
+                    rid_list.pop()  # Remove last element in list.
+                    continue
+                # Ensure the current resource_id matches the old one, there
+                # can, in rare cases, be a mismatch due to issue #2278.
+                if obj_key[-1] != self.id:
+                    rid_list.pop()
+                    continue
+                return obj
+
+    def _get_object_from_parent_scope(self):
+        """
+        Find an object in the same parent scope with the same resource_id. If
+        No such object is found return None.
+        """
+        id_tree = ResourceIdentifier._parent_id_tree
+        try:
+            obj_key = id_tree[self._parent_key][self._resource_key]
+        except (KeyError, TypeError):
+            pass
+        else:
+            try:
+                obj = ResourceIdentifier._id_object_map[obj_key]
+            except KeyError:
+                pass
+            else:
+                if obj_key[-1] == self.id:
                     return obj
-                except KeyError:  # object got gc'ed or was not defined
-                    rid_list.pop()  # remove last element in list
 
     def set_referred_object(self, referred_object, warn=True, parent=None):
         """
@@ -392,11 +432,11 @@ def set_referred_object(self, referred_object, warn=True, parent=None):
         # if there is None, get the last referred_object assigned the same
         # resource_id code.
         id_order = ResourceIdentifier._id_order
-        old = ResourceIdentifier._id_object_map.get(self._object_id, None)
-        if old is None:  # look for last object with same id code.
+        old = ResourceIdentifier._id_object_map.get(self._object_key, None)
+        if old is None:  # Look for last object with same resource id.
             try:
-                old_obj_id = id_order[self._resource_key][-1]
-                old = ResourceIdentifier._id_object_map[old_obj_id]
+                old_obj_id_key = id_order[self._resource_key][-1]
+                old = ResourceIdentifier._id_object_map[old_obj_id_key]
             except (KeyError, IndexError):
                 pass
         if warn and old is not None and old != referred_object:
@@ -406,17 +446,18 @@ def set_referred_object(self, referred_object, warn=True, parent=None):
             warnings.warn(msg, UserWarning)
         # Set the object id to the new object, and update parent scoping tree.
         self._object_id = id(referred_object)
-        if parent is not None:
-            self._parent_key = parent
+        if parent is not None or self._parent_key is not None:
+            if parent is not None:
+                self._parent_key = parent
             id_tree = ResourceIdentifier._parent_id_tree
             if self._parent_key not in id_tree:
                 id_tree[self._parent_key] = WeakKeyDictionary()
-            id_tree[self._parent_key][self._resource_key] = self._object_id
+            id_tree[self._parent_key][self._resource_key] = self._object_key
         # Set the new id in id map and append referred_object to id_order.
-        ResourceIdentifier._id_object_map[self._object_id] = referred_object
+        ResourceIdentifier._id_object_map[self._object_key] = referred_object
         if self._resource_key not in id_order:
             id_order[self._resource_key] = []
-        id_order[self._resource_key].append(self._object_id)
+        id_order[self._resource_key].append(self._object_key)
 
     def convert_id_to_quakeml_uri(self, authority_id="local"):
         """
@@ -497,6 +538,15 @@ def __setstate__(self, state):
         self._parent_key = None
         self._resource_key = _ResourceKey.get_resource_key(self.id)
 
+    @property
+    def _object_key(self):
+        """
+        The value used to identify objects bound to resource_ids.
+
+        Uses a hash of both the object id and resource id, see #2278.
+        """
+        return self._object_id, self.id
+
     @property
     def id(self):
         """
@@ -621,6 +671,39 @@ def regenerate_uuid(self):
         """
         self._uuid = str(uuid4())
 
+    @classmethod
+    def register_get_object_hook(cls, get_object_callable):
+        """
+        Register a callable to return referred object if normal means fail.
+
+        This is useful, for example, to allow the resource_identifier to
+        create objects from entries in a database that do not yet have an
+        in-memory representation.
+
+        :param get_object_callable:
+            Any callable that takes a resource id string and returns an
+            object.
+        :type get_object_callable: callable
+        """
+        cls._get_object_hook.append(get_object_callable)
+
+    @classmethod
+    def remove_get_object_hook(cls, get_object_callable):
+        """
+        Remove a callable from the registered get_object hooks.
+
+        :param get_object_callable:
+            The callable to remove from the get_object hook registry.
+        :type get_object_callable: callable, optional
+        """
+        if get_object_callable is not None:
+            try:
+                cls._get_object_hook.remove(get_object_callable)
+            except ValueError:  # Pass if get_object_callable is not in list.
+                pass
+        else:
+            cls._get_object_hook.clear()
+
     @classmethod
     def _bind_class_state(cls, state_dict):
         """
@@ -629,6 +712,7 @@ def _bind_class_state(cls, state_dict):
         cls._parent_id_tree = state_dict['parent_id_tree']
         cls._id_order = state_dict['id_order']
         cls._id_object_map = state_dict['id_object_map']
+        cls._get_referred_object_hook = state_dict['get_object_hook']
 
     @classmethod
     @contextmanager
@@ -645,12 +729,14 @@ def _debug_class_state(cls):
             parent_id_tree=cls._parent_id_tree,
             id_order=cls._id_order,
             id_object_map=cls._id_object_map,
+            get_object_hook=cls._get_object_hook,
         )
         # init new class state
         new_state = dict(
             parent_id_tree=WeakKeyDictionary(),
             id_order=WeakKeyDictionary(),
             id_object_map=WeakValueDictionary(),
+            get_object_hook=[],
         )
         # bind new state and return dict
         cls._bind_class_state(new_state)


Test patch:
diff --git a/obspy/core/tests/test_resource_identifier.py b/obspy/core/tests/test_resource_identifier.py
--- a/obspy/core/tests/test_resource_identifier.py
+++ b/obspy/core/tests/test_resource_identifier.py
@@ -578,6 +578,87 @@ def test_get_pick_from_arrival_on_copied_catalog_doesnt_warn(self):
             arrival.pick_id.get_referred_object()
         self.assertEqual(len(w), 0)
 
+    def test_issue_2278(self):
+        """
+        Tests for issue # 2278 which has to do with resource ids returning
+        the wrong objects when the bound object has gone out of scope and
+        a new object adopts the old object's python id.
+        """
+        # Create a simple class for resource_ids to refere to.
+        class Simple(object):
+
+            def __init__(self, value):
+                self.value = value
+
+        parent = Simple('parent1')
+
+        # keep track of objects, resource_ids, and used python ids
+        obj_list1, rid_list1, used_ids1 = [], [], set()
+        # create a slew of objects and resource_ids
+        for _ in range(100):
+            obj_list1.append(Simple(1))
+            used_ids1.add(id(obj_list1[-1]))
+        for obj in obj_list1:
+            kwargs = dict(referred_object=obj, parent=parent)
+            rid_list1.append(ResourceIdentifier(**kwargs))
+        # delete objects and create second set
+        del obj_list1
+
+        # create another slew of objects and resource_ids. Some will reuse
+        # deleted object ids
+        obj_list2, rid_list2, used_ids2 = [], [], set()
+        for _ in range(100):
+            obj_list2.append(Simple(2))
+            used_ids2.add(id(obj_list2[-1]))
+        for obj in obj_list2:
+            kwargs = dict(referred_object=obj, parent=parent)
+            rid_list2.append(ResourceIdentifier(**kwargs))
+
+        # since we cannot control which IDs python uses, skip the test if
+        # no overlapping ids were created.
+        if not used_ids1 & used_ids2:
+            self.skipTest('setup requires reuse of python ids')
+
+        # Iterate over first list of ids. Referred objects should be None
+        for rid in rid_list1:
+            # should raise a warning
+            with WarningsCapture():
+                self.assertIs(rid.get_referred_object(), None)
+
+    def test_get_object_hook(self):
+        """
+        Test that custom logic for getting referred objects can be plugged
+        into resource ids.
+        """
+
+        class MyClass():
+            pass
+
+        new_obj1 = MyClass()
+        new_obj2 = MyClass()
+
+        def _get_object_hook(arg):
+            if str(arg) == '123':
+                return new_obj1
+            elif str(arg) == '789':
+                return new_obj2
+            else:
+                return None
+
+        with ResourceIdentifier._debug_class_state():
+            ResourceIdentifier.register_get_object_hook(_get_object_hook)
+            rid1 = ResourceIdentifier('123')
+            rid2 = ResourceIdentifier('456')
+            rid3 = ResourceIdentifier('789')
+            self.assertIs(rid1.get_referred_object(), new_obj1)
+            self.assertIs(rid2.get_referred_object(), None)
+            # Now clear the hook, _get_object_hook should no longer be called
+            ResourceIdentifier.remove_get_object_hook(_get_object_hook)
+            self.assertIs(rid3.get_referred_object(), None)
+            # But rid1 should have been bound to new_obj1 (so it no longer
+            # needs to call get_object_hook to find it
+            self.assertIs(rid1.get_referred_object(), new_obj1)
+
 
 def get_instances(obj, cls=None, is_attr=None, has_attr=None):
     """