fin.cache

cache is designed to make caching the results of slow method simple, and painless. It’s very similar in nature to the common python @memoize pattern, but the results are stored on an object, rather than inside a closure, making caching of class-specific method results much simpler and easier.

An obvious example would be a database connection:

class DB(object):

    def __init__(self, dsn):
        self.dsn = dsn

    @fin.cache.depends("dsn")
    @fin.cache.property
    def connection(self):
        return dblibrary.connect(self.dsn)

In this example, the connection attribute is cached for each DB instance, allowing for very natural usage:

>>> db1 = DB("test")
>>> db1.connection
<dblibrary.Connection at 0x7f904012fb90>
>>> db1.connection
<dblibrary.Connection at 0x7f904012fb90>
>>> db2 = DB("test")
<dblibrary.Connection at 0x1a111111de55>

The `@fin.cache.depends` part means that if the dsn changes for a DB, the next time db.connection is requested, the class will create a new connection, using the new dsn, update the cache, and start using that instead. This allows code to be written in a very natural fashion, and not to worry about state management.

Code Docs

class fin.cache.property(fun, wrapper=<function method>)[source]

This decorator behaves like the builtin @property decorator, but caches the results, similarly to fin.cache.method:

>>> class Example(object):

>>>     @fin.cache.property
>>>     def number(self):
>>>         time.sleep(1)
>>>         return 4

>>> e = Example()
>>> print "Slow:", e.number
Slow: 4  (1 second later)
>>> print "Fast:", e.number
Fast: 4  (immediately)
>>> E.number.reset(e)
>>> print "Slow:", e.number
Slow: 4  (1 second later)

@fin.cache.property() descriptors support assignment. The attribute can be assigned a callable, taking one argument, which will always be called on attribute access, and the result returned. This is best shown by an example, continuing from the previous:

>>> e = Example()
>>> f = Example()
>>> print(e.number, f.number)
4 4
>>> f.number = lambda e: 8
>>> print(e.number, f.number, f.number)
4 8 8
>>> e.number = lambda e: random.randint(1, 10)
>>> print(e.number, f.number, f.number)
4 5 9
has_cached(inst)[source]

Returns True if a result has been cached for the property on the specified instance:

>>> class Example(object):
>>>
>>>     @fin.cache.property
>>>     def one(self):
>>>         return 1
>>> e = Example()
>>> Example.one.has_cached(e)
False
>>> e.one
1
>>> Example.one.has_cached(e)
True
>>> Example.one.reset(e)
>>> Example.one.has_cached(e)
False
reset(inst)[source]

‘Forgets’ any cached value for instance inst. Use as shown in in the example above.

fin.cache.method(fun)[source]

This is the core of fin.cache. Typically used as a decorator on class or instance methods. When a method is decorated with this function, repeatedly calling it, on the same object, with the same arguments*, will only cause the method to be called once. The result of that call is stored on the object, and is automatically returned subsequently.

An interesting (contrived) example from the tests:

class Factorial(object):

    #Try commenting out the @fin.cache.method line and see what happens..
    @fin.cache.method
    def factorial(self, num):
        if num <= 1:
            return 1
        return num * self.factorial(num - 1)

factorial = Factorial()
for i in range(2000):
    factorial.factorial(i)
* NOTE: Arguments are tested by equality (a==b not a is b). This can, in a very few situations, lead to unexpected results.

Also, the result value is cached by reference. If a cached method returns, for example, a list, then any modifications to that list will be shared amongst all return values, which can lead to some strange effects if mis-used:

>>> class Bad(object):

>>>     @classmethod
>>>     @fin.cache.method
>>>     def bad_range(self, n):
>>>         return list(range(n))

>>> nums = Bad.bad_range(1)
>>> print(Bad.bad_range(1), Bad.bad_range(2))
[0] [0, 1]
>>> nums.extend(Bad.bad_range(2))
>>> print(Bad.bad_range(1), Bad.bad_range(2))
[0, 0, 1] [0, 1]

Calling reset(object) on the descriptor will cause the cache to be cleared for that object, to continue the example:

>>> Bad.bad_range.reset(Bad)
>>> print(Bad.bad_range(1), Bad.bad_range(2))
[0] [0, 1]

When used on an instance method, rather than a classmethod, the object instance should be passed into reset.

fin.cache.depends(*attributes)[source]

Used in conjunction with fin.cache.property() or fin.cache.method(), this decorator tags a cached method as depending on the value of the specified named attribute on the method’s object. Note, this does mean all dependant properties are evaluated every time the cached method is called.

As a naive example, to cache an object hash, where the hashing algorithm might change:

>>> class HashedValue(object):

>>>    def __init__(self, value):
>>>        self.value = value
>>>        self.hash_method = "sha1"

>>>    @fin.cache.property
>>>    @fin.cache.depends("value", "hash_method")
>>>    def hash(self):
>>>        print('** Calculating **')
>>>        return getattr(hashlib, self.hash_method)(self.value).hexdigest()

>>> val = HashedValue('hello')
>>> val.hash
** Calculating **
'aaf4c61ddcc5e8a2dabede0f3b482cd9aea9434d'
>>> val.hash
'aaf4c61ddcc5e8a2dabede0f3b482cd9aea9434d'
>>> val.hash_method = 'sha1'
>>> val.hash
'aaf4c61ddcc5e8a2dabede0f3b482cd9aea9434d'
>>> val.hash_method = 'sha512'
>>> val.hash
** Calculating **
'9b71d224bd62f3785d96d46ad3ea3d73319bfbc2890caadae2dff72519673ca72323c3d99ba5c11d7c7acc6e14b8c5da0c4663475c2e5c3adef46f73bcdec043'

In this case, instance.hash will always reflect the currently selected hashing method, and the current value, but will not re-hash the value needlessly.

fin.cache.uncached_property(fun)[source]

Behaves like the builtin @property decorator, but supports the same assignment logic as @fin.cache.property. This method performs no caching, but is syntactic sugar.

fin.cache.invalidates(other)[source]

Explicitly clears another cached method when this method is called, on the same object:

>>> class Dice(object):
>>>     @fin.cache.property
>>>     def current_value(self):
>>>         return random.randint(1, 7)
>>>
>>>     @fin.cache.invalidates(current_value)
>>>     def roll(self):
>>>         print("Rolling...")
>>> die = Dice()
>>> die.current_value
1
>>> die.current_value
1
>>> die.roll()
>>> die.current_value
5
fin.cache.generator(fun)[source]

Use with care! This generator keeps a reference to all generated values for the lifetime of the cache (unless manually cleared). Given that generators are often used to handle larger volumes of data, this may cause memory issues if used incorrectly. This decorator is useful as a speed optimisation, but comes with a memory cost.

Acts like @fin.cache.method but for methods that return a generator (or uses :keyword:yield). Repeated calls to this method return an object that can be used to iterate over the generated values from the start:

>>> class Example(object):
>>> 
>>>     @fin.cache.generator
>>>     def slow_range(self, num):
>>>         for i in range(num):
>>>             time.sleep(0.2)
>>>             yield i

>>> e = Example()
>>> e.slow_range(10)
<fin.cache.TeeGenerator at 0x7f9041062650> # Fast
>>> list(e.slow_range(10))
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]             # Slow
>>> list(e.slow_range(10))
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]             # Fast (already calculated)
>>> list(e.slow_range(5))
[0, 1, 2, 3, 4]                            # Slow (different arguments used)
>>> list(Example().slow_range(10))
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]             # Slow (different instance used)
>>> Example.slow_range.reset(e) # Free the memory..
>>> list(e.slow_range(10))
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]             # Slow (cache has been cleared)

The generator is evaluated on-demand, and lazily, so the following works:

>>> timeit(lambda: list(e.slow_range(3)), number=1)
# Takes 0.6 seconds to evaluate the list
0.60060    #  [0, 1, 2]
>>> e.slow_range.reset(e)
>>> timeit(lambda: zip(e.slow_range(10), e.slow_range(10)))  
# Both generators are being evaluated at the same time, but the time stays the same:
0.60075   #  [(0, 0), (1, 1), (2, 2)]