From 501af3fbc26391e93d045fd8a643e7d8f4af74cd Mon Sep 17 00:00:00 2001 From: Daniel Greenfeld Date: Tue, 14 Apr 2015 09:19:14 -0700 Subject: [PATCH] Break out ttl tools because they don't always allow resetting of the cache. #16 --- README.rst | 71 ++++--- cached_property.py | 54 ++++- tests/test_cached_property.py | 35 +--- tests/test_cached_property_ttl.py | 274 +++++++++++++++++++++++++ tests/test_threaded_cached_property.py | 3 +- 5 files changed, 362 insertions(+), 75 deletions(-) create mode 100644 tests/test_cached_property_ttl.py diff --git a/README.rst b/README.rst index e1b78e7..c2d6daa 100644 --- a/README.rst +++ b/README.rst @@ -53,7 +53,6 @@ Now run it: Let's convert the boardwalk property into a ``cached_property``. - .. code-block:: python from cached_property import cached_property @@ -103,39 +102,6 @@ Results of cached functions can be invalidated by outside forces. Let's demonstr 600 >>> monopoly.boardwalk 600 - -Timing out the cache --------------------- - -Sometimes you want the price of things to reset after a time. - -.. code-block:: python - - import random - from cached_property import cached_property - - class Monopoly(object): - - @cached_property(ttl=5) # cache invalidates after 5 seconds - def dice(self): - # I dare the reader to implement a game using this method of 'rolling dice'. - return random.randint(2,12) - -Now use it: - -.. code-block:: python - - >>> monopoly = Monopoly() - >>> monopoly.dice - 10 - >>> monopoly.dice - 10 - >>> from time import sleep - >>> sleep(6) # Sleeps long enough to expire the cache - >>> monopoly.dice - 3 - >>> monopoly.dice - 3 Working with Threads --------------------- @@ -187,6 +153,43 @@ Now use it: >>> self.assertEqual(m.boardwalk, 550) +Timing out the cache +-------------------- + +Sometimes you want the price of things to reset after a time. Use the ``ttl`` +versions of ``cached_property`` and ``threaded_cached_property``. + +.. code-block:: python + + import random + from cached_property import cached_property_with_ttl + + class Monopoly(object): + + @cached_property_with_ttl(ttl=5) # cache invalidates after 5 seconds + def dice(self): + # I dare the reader to implement a game using this method of 'rolling dice'. + return random.randint(2,12) + +Now use it: + +.. code-block:: python + + >>> monopoly = Monopoly() + >>> monopoly.dice + 10 + >>> monopoly.dice + 10 + >>> from time import sleep + >>> sleep(6) # Sleeps long enough to expire the cache + >>> monopoly.dice + 3 + >>> monopoly.dice + 3 + +**Note:** The ``ttl`` tools do not reliably allow the clearing of the cache. This +is why they are broken out into seperate tools. See https://github.com/pydanny/cached-property/issues/16. + Credits -------- diff --git a/cached_property.py b/cached_property.py index 85009ae..48e615a 100644 --- a/cached_property.py +++ b/cached_property.py @@ -13,8 +13,44 @@ class cached_property(object): """ A property that is only computed once per instance and then replaces itself with an ordinary attribute. Deleting the attribute resets the property. - Source: https://github.com/bottlepy/bottle/commit/fa7733e075da0d790d809aa3d2f53071897e6f76 + """ + + def __init__(self, func): + self.__doc__ = getattr(func, '__doc__') + self.func = func + + def __get__(self, obj, cls): + if obj is None: + return self + value = obj.__dict__[self.func.__name__] = self.func(obj) + return value + + +class threaded_cached_property(cached_property): + """ A cached_property version for use in environments where multiple + threads might concurrently try to access the property. + """ + def __init__(self, func): + super(threaded_cached_property, self).__init__(func) + self.lock = threading.RLock() + + def __get__(self, obj, cls): + with self.lock: + # Double check if the value was computed before the lock was + # acquired. + prop_name = self.func.__name__ + if prop_name in obj.__dict__: + return obj.__dict__[prop_name] + + # If not, do the calculation and release the lock. + return super(threaded_cached_property, self).__get__(obj, cls) + + +class cached_property_with_ttl(object): + """ A property that is only computed once per instance and then replaces + itself with an ordinary attribute. Setting the ttl to a number expresses + how long the property will last before being timed out. """ # noqa def __init__(self, ttl=None): @@ -55,16 +91,17 @@ class cached_property(object): return value - def __delattr__(self, name): - print(name) +# Aliases to make cached_property_with_ttl easier to use +cached_property_ttl = cached_property_with_ttl +timed_cached_property = cached_property_with_ttl -class threaded_cached_property(cached_property): +class threaded_cached_property_with_ttl(cached_property_with_ttl): """ A cached_property version for use in environments where multiple threads might concurrently try to access the property. """ def __init__(self, ttl=None): - super(threaded_cached_property, self).__init__(ttl) + super(threaded_cached_property_with_ttl, self).__init__(ttl) self.lock = threading.RLock() def __get__(self, obj, cls): @@ -76,4 +113,9 @@ class threaded_cached_property(cached_property): return obj._cache[prop_name][0] # If not, do the calculation and release the lock. - return super(threaded_cached_property, self).__get__(obj, cls) + return super(threaded_cached_property_with_ttl, self).__get__(obj, cls) + +# Alias to make threaded_cached_property_with_ttl easier to use +threaded_cached_property_ttl = threaded_cached_property_with_ttl +timed_threaded_cached_property = threaded_cached_property_with_ttl + diff --git a/tests/test_cached_property.py b/tests/test_cached_property.py index 7ef773d..d39778a 100755 --- a/tests/test_cached_property.py +++ b/tests/test_cached_property.py @@ -10,7 +10,6 @@ Tests for `cached-property` module. from time import sleep from threading import Lock, Thread import unittest -from freezegun import freeze_time from cached_property import cached_property @@ -45,10 +44,6 @@ class TestCachedProperty(unittest.TestCase): self.assertEqual(c.add_cached, 1) self.assertEqual(c.add_cached, 1) - # Cannot expire the cache. - with freeze_time("9999-01-01"): - self.assertEqual(c.add_cached, 1) - # It's customary for descriptors to return themselves if accessed # though the class, rather than through an instance. self.assertTrue(isinstance(Check.add_cached, cached_property)) @@ -72,7 +67,7 @@ class TestCachedProperty(unittest.TestCase): self.assertEqual(c.add_cached, 1) # Reset the cache. - del c._cache['add_cached'] + del c.add_cached self.assertEqual(c.add_cached, 2) self.assertEqual(c.add_cached, 2) @@ -98,7 +93,7 @@ class TestThreadingIssues(unittest.TestCase): def test_threads(self): """ How well does the standard cached_property implementation work with threads? Short answer: It doesn't! Use threaded_cached_property instead! - """ # noqa + """ class Check(object): @@ -135,29 +130,3 @@ class TestThreadingIssues(unittest.TestCase): # between 1 and num_threads, depending on thread scheduling and # preemption. self.assertEqual(c.add_cached, num_threads) - - -class TestCachedPropertyWithTTL(unittest.TestCase): - - def test_ttl_expiry(self): - - class Check(object): - - def __init__(self): - self.total = 0 - - @cached_property(ttl=100000) - def add_cached(self): - self.total += 1 - return self.total - - c = Check() - - # Run standard cache assertion - self.assertEqual(c.add_cached, 1) - self.assertEqual(c.add_cached, 1) - - # Expire the cache. - with freeze_time("9999-01-01"): - self.assertEqual(c.add_cached, 2) - self.assertEqual(c.add_cached, 2) diff --git a/tests/test_cached_property_ttl.py b/tests/test_cached_property_ttl.py new file mode 100644 index 0000000..78e2e27 --- /dev/null +++ b/tests/test_cached_property_ttl.py @@ -0,0 +1,274 @@ +# -*- coding: utf-8 -*- + +""" +test_threaded_cache_property.py +---------------------------------- + +Tests for `cached-property` module, cached_property_with_ttl. +Tests for `cached-property` module, threaded_cache_property_with_ttl. +""" +import unittest +from freezegun import freeze_time + +from cached_property import ( + cached_property_with_ttl, + threaded_cached_property_with_ttl +) + + +from time import sleep +from threading import Lock, Thread +import unittest +from freezegun import freeze_time + +from cached_property import cached_property + + +class TestCachedProperty(unittest.TestCase): + + def test_cached_property(self): + + class Check(object): + + def __init__(self): + self.total1 = 0 + self.total2 = 0 + + @property + def add_control(self): + self.total1 += 1 + return self.total1 + + @cached_property_with_ttl + def add_cached(self): + self.total2 += 1 + return self.total2 + + c = Check() + + # The control shows that we can continue to add 1. + self.assertEqual(c.add_control, 1) + self.assertEqual(c.add_control, 2) + + # The cached version demonstrates how nothing new is added + self.assertEqual(c.add_cached, 1) + self.assertEqual(c.add_cached, 1) + + # Cannot expire the cache. + with freeze_time("9999-01-01"): + self.assertEqual(c.add_cached, 1) + + # It's customary for descriptors to return themselves if accessed + # though the class, rather than through an instance. + self.assertTrue(isinstance(Check.add_cached, cached_property_with_ttl)) + + def test_reset_cached_property(self): + + class Check(object): + + def __init__(self): + self.total = 0 + + @cached_property_with_ttl + def add_cached(self): + self.total += 1 + return self.total + + c = Check() + + # Run standard cache assertion + self.assertEqual(c.add_cached, 1) + self.assertEqual(c.add_cached, 1) + + # Reset the cache. + del c._cache['add_cached'] + self.assertEqual(c.add_cached, 2) + self.assertEqual(c.add_cached, 2) + + def test_none_cached_property(self): + + class Check(object): + + def __init__(self): + self.total = None + + @cached_property_with_ttl + def add_cached(self): + return self.total + + c = Check() + + # Run standard cache assertion + self.assertEqual(c.add_cached, None) + + +class TestThreadingIssues(unittest.TestCase): + + def test_threads(self): + """ How well does the standard cached_property implementation work with threads? + Short answer: It doesn't! Use threaded_cached_property instead! + """ # noqa + + class Check(object): + + def __init__(self): + self.total = 0 + self.lock = Lock() + + @cached_property_with_ttl + def add_cached(self): + sleep(1) + # Need to guard this since += isn't atomic. + with self.lock: + self.total += 1 + return self.total + + c = Check() + threads = [] + num_threads = 10 + for x in range(num_threads): + thread = Thread(target=lambda: c.add_cached) + thread.start() + threads.append(thread) + + for thread in threads: + thread.join() + + # Threads means that caching is bypassed. + self.assertNotEqual(c.add_cached, 1) + + # This assertion hinges on the fact the system executing the test can + # spawn and start running num_threads threads within the sleep period + # (defined in the Check class as 1 second). If num_threads were to be + # massively increased (try 10000), the actual value returned would be + # between 1 and num_threads, depending on thread scheduling and + # preemption. + self.assertEqual(c.add_cached, num_threads) + + +class TestCachedPropertyWithTTL(unittest.TestCase): + + def test_ttl_expiry(self): + + class Check(object): + + def __init__(self): + self.total = 0 + + @cached_property_with_ttl(ttl=100000) + def add_cached(self): + self.total += 1 + return self.total + + c = Check() + + # Run standard cache assertion + self.assertEqual(c.add_cached, 1) + self.assertEqual(c.add_cached, 1) + + # Expire the cache. + with freeze_time("9999-01-01"): + self.assertEqual(c.add_cached, 2) + self.assertEqual(c.add_cached, 2) + + +class TestCachedProperty(unittest.TestCase): + + def test_cached_property(self): + + class Check(object): + + def __init__(self): + self.total1 = 0 + self.total2 = 0 + + @property + def add_control(self): + self.total1 += 1 + return self.total1 + + @threaded_cached_property_with_ttl + def add_cached(self): + self.total2 += 1 + return self.total2 + + c = Check() + + # The control shows that we can continue to add 1. + self.assertEqual(c.add_control, 1) + self.assertEqual(c.add_control, 2) + + # The cached version demonstrates how nothing new is added + self.assertEqual(c.add_cached, 1) + self.assertEqual(c.add_cached, 1) + + def test_reset_cached_property(self): + + class Check(object): + + def __init__(self): + self.total = 0 + + @threaded_cached_property_with_ttl + def add_cached(self): + self.total += 1 + return self.total + + c = Check() + + # Run standard cache assertion + self.assertEqual(c.add_cached, 1) + self.assertEqual(c.add_cached, 1) + + # Reset the cache. + del c._cache['add_cached'] + self.assertEqual(c.add_cached, 2) + self.assertEqual(c.add_cached, 2) + + def test_none_cached_property(self): + + class Check(object): + + def __init__(self): + self.total = None + + @threaded_cached_property_with_ttl + def add_cached(self): + return self.total + + c = Check() + + # Run standard cache assertion + self.assertEqual(c.add_cached, None) + + +class TestThreadingIssues(unittest.TestCase): + + def test_threads(self): + """ How well does this implementation work with threads?""" + + class Check(object): + + def __init__(self): + self.total = 0 + self.lock = Lock() + + @threaded_cached_property_with_ttl + def add_cached(self): + sleep(1) + # Need to guard this since += isn't atomic. + with self.lock: + self.total += 1 + return self.total + + c = Check() + threads = [] + for x in range(10): + thread = Thread(target=lambda: c.add_cached) + thread.start() + threads.append(thread) + + for thread in threads: + thread.join() + + self.assertEqual(c.add_cached, 1) \ No newline at end of file diff --git a/tests/test_threaded_cached_property.py b/tests/test_threaded_cached_property.py index 8022104..0d87673 100755 --- a/tests/test_threaded_cached_property.py +++ b/tests/test_threaded_cached_property.py @@ -3,7 +3,6 @@ """ test_threaded_cache_property.py ---------------------------------- - Tests for `cached-property` module, threaded_cache_property. """ @@ -63,7 +62,7 @@ class TestCachedProperty(unittest.TestCase): self.assertEqual(c.add_cached, 1) # Reset the cache. - del c._cache['add_cached'] + del c.add_cached self.assertEqual(c.add_cached, 2) self.assertEqual(c.add_cached, 2) -- 2.30.2