Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
82 commits
Select commit Hold shift + click to select a range
71ddde7
We can (now?) use value_to_string and avoid problems with custom fields.
lamby Feb 28, 2014
9f505d9
Merge pull request #1 from lamby/master
danpalmer Jun 2, 2015
7ef7d95
Support Django >= 1.6
danpalmer Jun 2, 2015
c09df44
This wasn't supposed to be added
danpalmer Jun 2, 2015
ee530e2
parent_model was a private API and removed in Django 1.8
danpalmer Jun 2, 2015
3eae424
Lookup the right model
danpalmer Jun 2, 2015
578b2b3
Special case some field types.
danpalmer Jun 10, 2015
b4508fa
This is now model_name
lamby Aug 8, 2015
b691fe7
This link is no longer live
prophile Nov 6, 2015
daf8d55
Correct these
prophile Nov 6, 2015
5338dc3
Add `install_requires`
prophile Nov 6, 2015
413eba1
Exclude the "tests" directory
prophile Nov 6, 2015
66eda84
Trailing comma
prophile Nov 6, 2015
ce4353e
Delete this documentation
prophile Nov 6, 2015
0c3fd78
Add a first test
prophile Nov 6, 2015
508e071
Add a simple (currently failing)_cached relation test
prophile Nov 6, 2015
76fdfd8
Mark as skipped
prophile Nov 6, 2015
09f410c
Add cache invalidation tests
prophile Nov 6, 2015
870b6c0
Fix this test
prophile Nov 6, 2015
1f6491b
Add a version number on the cache keys
prophile Nov 6, 2015
fd22a23
Run all encoding through Pickle
prophile Nov 6, 2015
cc43ac6
Merge pull request #2 from thread/improvements
prophile Nov 9, 2015
a601b2e
Fix the authentication middleware
prophile Nov 9, 2015
737fea0
Don't assume a numeric primary key
prophile Nov 9, 2015
51d0f20
Reflow
Feb 23, 2016
0e5c7d0
Specify using for relations
Feb 23, 2016
e56f107
Releasing version 0.1.1
lamby Mar 14, 2016
5b0afd8
Merge https://github.com/thread/django-cache-toolbox
lamby Mar 14, 2016
4a95ad6
Releasing version 0.2.0
lamby Mar 14, 2016
056b318
Drop support for 1.6
lamby Mar 14, 2016
a56f5c4
Releasing version 0.2.1
lamby Mar 14, 2016
582bf12
This is now on related_model, not model!
lamby Mar 21, 2016
54d6bd7
Releasing version 0.2.2
lamby Mar 21, 2016
4b36e4b
Add missing trailing comma
lamby Mar 21, 2016
bc6309a
Drop broken creation non-UPSET functionality
lamby Mar 21, 2016
6cfae0d
Fork
lamby Mar 21, 2016
0b65e49
Drop unused import
lamby Mar 21, 2016
8c5d5a1
Releasing version 0.2.3
lamby Mar 21, 2016
4ae4f8b
Move away from deprecated django.template.resolve_variable.
lamby Feb 24, 2017
63caa7c
Releasing version 0.2.4
lamby Feb 24, 2017
43a6c33
Ignore eggs (#1)
PeterJCLaw Mar 8, 2018
3864f5d
Django 1.x (#2)
PeterJCLaw Mar 8, 2018
62fd94f
Switch to the test running mechanism from django-enumfield
PeterJCLaw Mar 7, 2018
5961c37
Support `hasattr`
PeterJCLaw Mar 8, 2018
497094e
Ensure `hasattr` is consistent
PeterJCLaw Mar 8, 2018
a576370
Releasing version 0.3.0
lamby Mar 8, 2018
2a615c4
Avoid race condition between transaction commit and cache clear
PeterJCLaw Jul 13, 2018
8b80fcd
Releasing version 0.3.1
lamby Jul 14, 2018
c924be8
Support older setuptools
PeterJCLaw Jul 17, 2018
8b4eb37
Upgrade for Django 2.0 compatibility
mthpower Jul 20, 2018
8edbc05
Update setup.py since the tests don’t pass under Django 1.8
mthpower Jul 20, 2018
f0f0d9e
Releasing version 0.3.2
lamby Jul 18, 2018
e701e42
Releasing version 1.0.0
lamby Jul 20, 2018
ca7f6cf
Bump allowed Django version to <2.2
danpalmer Aug 24, 2018
80ba9a6
Releasing version 1.1.0
lamby Aug 27, 2018
1c49314
Add a README with basic usage information
PeterJCLaw Jan 21, 2019
95f5609
Actually demonstrate using a _cached_ relation
PeterJCLaw Jan 22, 2019
25617b8
Also support Django 2.2.
lamby Jul 20, 2019
8908bcf
Releasing version 1.1.1
lamby Jul 20, 2019
59cfc2e
Include the README as the package long description
PeterJCLaw Mar 13, 2020
4033135
Cache negative relation lookups locally
PeterJCLaw Mar 13, 2020
2855f64
Make setup.py executable.
lamby Mar 31, 2020
823cb31
Releasing version 1.2.0
lamby Mar 31, 2020
ab50996
Fix a secondary error from cache exceptions
PeterJCLaw Apr 29, 2020
b5e5bdf
Releasing version 1.2.1
lamby Apr 29, 2020
d4baa75
Explicitly require cached relations are primary keys
PeterJCLaw Dec 28, 2020
9cd9ea9
Move to Python 3.x.
lamby Dec 28, 2020
e4f9dab
Releasing version 1.3.0
lamby Dec 28, 2020
1168533
Make the README example more correct
PeterJCLaw Dec 28, 2020
3380c26
Allow use of extended User model
matt-dalton Feb 4, 2021
af04924
Releasing version 1.4.0
lamby Feb 4, 2021
4ff61ed
Extract de/serialisation helpers
PeterJCLaw Dec 28, 2020
303ce9d
Extract a couple of utils
PeterJCLaw Dec 28, 2020
a913b62
Support always fetching some relations when loading a model
PeterJCLaw Dec 28, 2020
3dfbf07
Apply 'black'.
lamby Feb 21, 2021
c6f69c4
Releasing version 1.5.0
lamby Feb 21, 2021
f404b12
Avoid unrelated select-relateds
PeterJCLaw Feb 25, 2021
97c9296
Wrap these collections in tuples for compatibility
PeterJCLaw Feb 25, 2021
9a55e12
Cope with only some of the related models actually being loaded
PeterJCLaw Feb 25, 2021
544966d
Releasing version 1.6.0
lamby Feb 26, 2021
303b38f
Configure setup.cfg to generate a wheel during the release process.
lamby Jun 16, 2022
cdca670
Releasing version 1.6.1
lamby Jun 16, 2022
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,2 +1,4 @@
*.pyc
docs/_build
/.eggs
/*.egg-info
1 change: 1 addition & 0 deletions COPYING
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
Copyright © 2016 Chris Lamb <[email protected]>
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hahahahahahahahahahahahahahahaha.

Copyright © 2010, 2011 UUMC Ltd. <[email protected]>
All rights reserved.

Expand Down
42 changes: 42 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# django-cache-toolbox

_Non-magical object caching for Django._

Another caching framework for Django that does not do any magic behind your
back, saving brain cycles when debugging as well as sticking to Django
principles.

## Installation

From [PyPI](https://pypi.org/project/django-cache-toolbox/):
```
pip install django-cache-toolbox
```

## Basic Usage

``` python
from cache_toolbox import cache_model, cache_relation
from django.db import models

class Foo(models.Model):
...

class Bazz(models.Model):
foo = models.OneToOneField(Foo, related_name='bazz', primary_key=True)
...

# Prepare caching of a model
cache_model(Foo)

# Prepare caching of a relation
cache_relation(Foo.bazz)

# Fetch the cached version of a model
foo = Foo.get_cached(pk=42)

# Load a cached relation
print(foo.bazz_cache)
```

See the module docstrings for further details.
4 changes: 0 additions & 4 deletions README.rst

This file was deleted.

2 changes: 1 addition & 1 deletion cache_toolbox/app_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,6 @@
# Default cache timeout
CACHE_TOOLBOX_DEFAULT_TIMEOUT = getattr(
settings,
'CACHE_TOOLBOX_DEFAULT_TIMEOUT',
"CACHE_TOOLBOX_DEFAULT_TIMEOUT",
60 * 60 * 24 * 7,
)
199 changes: 149 additions & 50 deletions cache_toolbox/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,80 @@

"""

try:
import cPickle as pickle
except ImportError:
import pickle

from django.core.cache import cache
from django.db import DEFAULT_DB_ALIAS
from django.db import DEFAULT_DB_ALIAS, transaction

from . import app_settings


def get_instance(
model, instance_or_pk,
timeout=None, using=None, create=False, defaults=None
):
CACHE_FORMAT_VERSION = 2


def setattrdefault(obj, name, default):
try:
return getattr(obj, name)
except AttributeError:
setattr(obj, name, default)
return default


def get_related_name(descriptor):
return "%s_cache" % descriptor.related.field.related_query_name()


def get_related_cache_name(related_name: str) -> str:
return "_%s_cache" % related_name


def add_always_fetch_relation(descriptor):
setattrdefault(
descriptor.related.model,
"_cache_fetch_related",
[],
).append(descriptor)


def serialise(instance):
data = {}
for field in instance._meta.fields:
# Harmless to save, but saves space in the dictionary - we already know
# the primary key when we lookup
if field.primary_key:
continue

# We also don't want to save any virtual fields.
if not field.concrete:
continue

data[field.attname] = getattr(instance, field.attname)

# Encode through Pickle, since that allows overriding and covers (most)
# Python types we'd want to serialise.
return pickle.dumps(data, protocol=-1)


def deserialise(model, data, pk, using):
# Try and construct instance from dictionary
instance = model(pk=pk, **pickle.loads(data))

# Ensure instance knows that it already exists in the database,
# otherwise we will fail any uniqueness checks when saving the
# instance.
instance._state.adding = False

# Specify database so that instance is setup correctly. We don't
# namespace cached objects by their origin database, however.
instance._state.db = using or DEFAULT_DB_ALIAS

return instance


def get_instance(model, instance_or_pk, timeout=None, using=None):
"""
Returns the ``model`` instance with a primary key of ``instance_or_pk``.

Expand All @@ -27,9 +91,6 @@ def get_instance(
If omitted, the timeout value defaults to
``settings.CACHE_TOOLBOX_DEFAULT_TIMEOUT`` instead of 0 (zero).

If ``create`` is True, we are going to create the instance in case that it
was not found.

Example::

>>> get_instance(User, 1) # Cache miss
Expand All @@ -40,76 +101,114 @@ def get_instance(
True
"""

pk = getattr(instance_or_pk, 'pk', instance_or_pk)
key = instance_key(model, instance_or_pk)
data = cache.get(key)
pk = getattr(instance_or_pk, "pk", instance_or_pk)

if data is not None:
try:
# Try and construct instance from dictionary
instance = model(pk=pk, **data)

# Ensure instance knows that it already exists in the database,
# otherwise we will fail any uniqueness checks when saving the
# instance.
instance._state.adding = False
primary_model = model
descriptors = getattr(primary_model, "_cache_fetch_related", ())
models = [model, *(d.related.field.model for d in descriptors)]
# Note: we're assuming that the relations are primary key foreign keys, and
# so all have the same primary key. This matches the assumption which
# `cache_relation` makes.
keys_to_models = {instance_key(model, instance_or_pk): model for model in models}

# Specify database so that instance is setup correctly. We don't
# namespace cached objects by their origin database, however.
instance._state.db = using or DEFAULT_DB_ALIAS
data_map = cache.get_many(tuple(keys_to_models.keys()))
instance_map = {}

return instance
if data_map.keys() == keys_to_models.keys():
try:
for key, data in data_map.items():
model = keys_to_models[key]
instance_map[key] = deserialise(model, data, pk, using)
except:
# Error when deserialising - remove from the cache; we will
# fallback and return the underlying instance
cache.delete(key)
cache.delete_many(tuple(keys_to_models.keys()))

# Use the default manager so we are never filtered by a .get_query_set()
queryset = model._default_manager.using(using)
if create:
# It's possible that the related object didn't exist yet
instance, _ = queryset.get_or_create(pk=pk, defaults=defaults or {})
else:
instance = queryset.get(pk=pk)
else:
key = instance_key(primary_model, instance_or_pk)
primary_instance = instance_map[key]

data = {}
for field in instance._meta.fields:
# Harmless to save, but saves space in the dictionary - we already know
# the primary key when we lookup
if field.primary_key:
for descriptor in descriptors:
related_instance = instance_map[
instance_key(
descriptor.related.field.model,
instance_or_pk,
)
]
related_cache_name = get_related_cache_name(
get_related_name(descriptor),
)
setattr(primary_instance, related_cache_name, related_instance)

return primary_instance

related_names = [d.related.field.related_query_name() for d in descriptors]

# Use the default manager so we are never filtered by a .get_query_set()
queryset = primary_model._default_manager.using(using)
if related_names:
# NB: select_related without args selects all it can find, which we don't want.
queryset = queryset.select_related(*related_names)
primary_instance = queryset.get(pk=pk)

instances = [
primary_instance,
*(getattr(primary_instance, x, None) for x in related_names),
]

cache_data = {}
for instance in instances:
if instance is None:
continue

if field.get_internal_type() == 'FileField':
# Avoid problems with serializing FileFields
# by only serializing the file name
file = getattr(instance, field.attname)
data[field.attname] = file.name
else:
data[field.attname] = getattr(instance, field.attname)
key = instance_key(instance._meta.model, instance)
cache_data[key] = serialise(instance)

if timeout is None:
timeout = app_settings.CACHE_TOOLBOX_DEFAULT_TIMEOUT

cache.set(key, data, timeout)
cache.set_many(cache_data, timeout)

return instance
return primary_instance


def delete_instance(model, *instance_or_pk):
"""
Purges the cache keys for the instances of this model.
"""

cache.delete_many([instance_key(model, x) for x in instance_or_pk])
# Only clear the cache when the current transaction commits.
# While clearing the cache earlier than that is valid, it is insufficient
# to ensure cache consistency. There is a possible race between two
# transactions as follows:
#
# Transaction 1: modifies model (and thus clears cache)
# Transaction 2: queries cache, which misses, so it populates the cache
# from the database, picking up the unmodified model
# Transaction 1: commits, without further signal to the cache
#
# At this point the cache contains the _original_ value of the model, which
# is out of step with the database.
# To avoid this we delay clearing the cache until the transaction commits.
# While this does leave a small window after the transaction has committed
# but before the cache has cleared, that is better than leaving the cache
# incorrect until the model is next updated.

transaction.on_commit(
lambda: cache.delete_many(
[instance_key(model, x) for x in instance_or_pk],
)
)


def instance_key(model, instance_or_pk):
"""
Returns the cache key for this (model, instance) pair.
"""

return '%s.%s:%d' % (
return "cache.%d:%s.%s:%s" % (
CACHE_FORMAT_VERSION,
model._meta.app_label,
model._meta.module_name,
getattr(instance_or_pk, 'pk', instance_or_pk),
model._meta.model_name,
getattr(instance_or_pk, "pk", instance_or_pk),
)
14 changes: 9 additions & 5 deletions cache_toolbox/middleware.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,19 +79,23 @@
"""

from django.contrib.auth import SESSION_KEY
from django.contrib.auth.models import User
from django.contrib.auth import get_user_model
from django.contrib.auth.middleware import AuthenticationMiddleware

from .model import cache_model


class CacheBackedAuthenticationMiddleware(AuthenticationMiddleware):
def __init__(self):
cache_model(User)
def __init__(self, get_response):
super(CacheBackedAuthenticationMiddleware, self).__init__(get_response)
cache_model(get_user_model())

def process_request(self, request):
try:
# Try and construct a User instance from data stored in the cache
request.user = User.get_cached(request.session[SESSION_KEY])
except:
request.user = get_user_model().get_cached(
int(request.session[SESSION_KEY])
)
except Exception:
# Fallback to constructing the User from the database.
super(CacheBackedAuthenticationMiddleware, self).process_request(request)
3 changes: 2 additions & 1 deletion cache_toolbox/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,8 +58,9 @@ class Foo(models.Model):

from .core import get_instance, delete_instance


def cache_model(model, timeout=None):
if hasattr(model, 'get_cached'):
if hasattr(model, "get_cached"):
# Already patched
return

Expand Down
Loading