Repository: seequent/pure_interface

Issue:
Add adapter tracking class
This idiom of checking if `x is b` is broken for adapted objects because a new adapter is potentially instantiated each time x or b is adapted.  

Add a class for tracking and re-using adapters for use in contexts where this is required.

Starting point:
```
class InterfaceAdapter(object):
    def __init__(self, interface, mapping_factory=weakref.WeakValueDictionary):
        self._factory = mapping_factory
        self._interface = interface
        self._adapters = mapping_factory()

    def clear(self):
        self._adapters = self._factory()

    def adapt(self, obj):
        try:
            return self._adapters[obj]
        except KeyError:
            adapted = self._interface.adapt(obj)
            self._adapters[obj] = adapted
            return adapted

    def adapt_or_none(self, obj):
        try:
            return self.adapt(obj)
        except ValueError:
            return None

```
Should use weak value dict for mapping.

Discussion:

Patch:
diff --git a/pure_interface.py b/pure_interface.py
index ab4c2e0..8643394 100644
--- a/pure_interface.py
+++ b/pure_interface.py
@@ -755,6 +755,44 @@ def register_adapter(adapter, from_type, to_interface):
     adapters[from_type] = weakref.ref(adapter, on_gone)
 
 
+class AdapterTracker(object):
+    """ The idiom of checking if `x is b` is broken for adapted objects because a new adapter is potentially
+    instantiated each time x or b is adapted.  Also in some context we adapt the same objects many times and don't
+    want the overhead of lots of copies.  This class provides adapt() and adapt_or_none() methods that track adaptions.
+    Thus if `x is b` is `True` then `adapter.adapt(x, I) is adapter.adapt(b, I)` is `True`.
+    """
+    def __init__(self, mapping_factory=dict):
+        self._factory = mapping_factory
+        self._adapters = mapping_factory()
+
+    def adapt(self, obj, interface):
+        """ Adapts `obj` to `interface`"""
+        try:
+            return self._adapters[interface][obj]
+        except KeyError:
+            return self._adapt(obj, interface)
+
+    def adapt_or_none(self, obj, interface):
+        """ Adapt obj to interface returning None on failure."""
+        try:
+            return self.adapt(obj, interface)
+        except ValueError:
+            return None
+
+    def clear(self):
+        """ Clears the cached adapters."""
+        self._adapters = self._factory()
+
+    def _adapt(self, obj, interface):
+        adapted = interface.adapt(obj)
+        try:
+            adapters = self._adapters[interface]
+        except KeyError:
+            adapters = self._adapters[interface] = self._factory()
+        adapters[obj] = adapted
+        return adapted
+
+
 def type_is_pure_interface(cls):
     # type: (Type[Any]) -> bool
     """ Return True if cls is a pure interface"""


Test patch:
diff --git a/tests/test_tracker.py b/tests/test_tracker.py
new file mode 100644
index 0000000..3bea47e
--- /dev/null
+++ b/tests/test_tracker.py
@@ -0,0 +1,86 @@
+# -*- coding: utf-8 -*-
+from __future__ import absolute_import, division, print_function, unicode_literals
+
+import unittest
+
+from pure_interface import *
+
+
+class ISpeaker(PureInterface):
+    def speak(self, volume):
+        pass
+
+
+class ISleeper(PureInterface):
+    is_asleep = None
+
+
+class Talker(object):
+    def __init__(self, saying='talk'):
+        self._saying = saying
+
+    def talk(self):
+        return self._saying
+
+
+class Sleeper(Concrete, ISleeper):
+    def __init__(self, is_asleep=False):
+        self.is_asleep = is_asleep
+
+
+@adapts(Talker, ISleeper)
+def talker_to_sleeper(talker):
+    return Sleeper(talker.talk() == 'zzz')
+
+
+@adapts(Talker)
+class TalkerToSpeaker(Concrete, ISpeaker):
+    def __init__(self, talker):
+        self._talker = talker
+
+    def speak(self, volume):
+        return self._talker.talk()
+
+
+class TestAdapterTracker(unittest.TestCase):
+    def test_adapt_is_same(self):
+        tracker = AdapterTracker()
+        t = Talker()
+
+        speaker1 = tracker.adapt(t, ISpeaker)
+        speaker2 = tracker.adapt(t, ISpeaker)
+        self.assertIsInstance(speaker1, ISpeaker)
+        self.assertIsInstance(speaker2, ISpeaker)
+        self.assertIs(speaker1, speaker2)
+
+    def test_adapt_multiple_interfaces(self):
+        tracker = AdapterTracker()
+        t = Talker()
+
+        speaker = tracker.adapt(t, ISpeaker)
+        sleeper = tracker.adapt(t, ISleeper)
+        self.assertIsInstance(speaker, ISpeaker)
+        self.assertIsInstance(sleeper, ISleeper)
+
+    def test_adapt_multiple_instances(self):
+        tracker = AdapterTracker()
+        t1 = Talker('hello')
+        t2 = Talker('zzz')
+
+        speaker1 = tracker.adapt(t1, ISpeaker)
+        speaker2 = tracker.adapt(t2, ISpeaker)
+        self.assertEqual('hello', speaker1.speak(5))
+        self.assertEqual('zzz', speaker2.speak(5))
+
+    def test_mapping_factory_is_used(self):
+        mocks = []
+
+        def factory():
+            mocks.append(dict())
+            return mocks[-1]
+
+        tracker = AdapterTracker(mapping_factory=factory)
+        t = Talker('hello')
+
+        tracker.adapt(t, ISpeaker)
+        self.assertTrue(len(mocks) > 1)