A customizable cache_page decorator with surrogate-key support and pluggable backends.
Project description
django-custom-cache-page
A cache_page decorator that gives you control over cache keys and invalidation.
Why not Django's built-in cache_page?
Django's cache_page generates cache keys automatically from the URL, headers, and cookies. You can't easily control what gets cached or invalidate specific entries when your data changes.
This package solves that:
# Django's cache_page - no control over keys, no way to invalidate
@cache_page(3600)
def product_list(request):
...
# This package - you control the key, you can invalidate by tag
@cache_page(timeout=3600, key_func=lambda r: r.path, tags=["products"])
def product_list(request):
...
# When products change, invalidate all related caches
invalidate_tag("products")
Features
- Custom cache keys - Define exactly what makes a cache entry unique
- Cache tags - Group related cache entries for bulk invalidation
- Instant invalidation - Versioned tags invalidate millions of entries in O(1)
- Pluggable backends - Works with Django cache, Redis, or custom backends
Installation
pip install django-custom-cache-page
# With Redis support (for RedisSurrogateIndex)
pip install django-custom-cache-page[redis]
Quick Start
from django.http import HttpResponse
from custom_cache_page import cache_page
@cache_page(
timeout=3600,
key_func=lambda r: r.path,
)
def my_view(request):
return HttpResponse("Hello, World!")
Usage
Basic Caching
from custom_cache_page import cache_page
@cache_page(
timeout=3600, # Cache for 1 hour
key_func=lambda r: r.path, # Cache key from URL path
)
def product_list(request):
...
Cache Tags (Surrogate Keys)
Tags allow you to invalidate groups of related cache entries:
from custom_cache_page import cache_page, invalidate_tag
@cache_page(
timeout=3600,
key_func=lambda r: r.path,
tags=[
"products", # Static tag
lambda r: f"category-{r.GET.get('cat')}", # Dynamic tag
],
)
def product_list(request):
...
# Later, invalidate all caches tagged with "products"
invalidate_tag("products")
O(1) Versioned Invalidation
For high-traffic applications, use versioned() tags for instant invalidation regardless of cache size:
from custom_cache_page import cache_page, versioned, invalidate_tag
@cache_page(
timeout=3600,
key_func=lambda r: r.path,
tags=[
versioned("products"), # O(1) invalidation via version increment
"category", # Regular tag (deletes entries)
],
)
def product_list(request):
...
# Invalidation is O(1) - just increments a version number
invalidate_tag("products")
How it works: Versioned tags embed a version number in the cache key. Invalidation simply increments the version, making all existing cache entries instantly stale without scanning or deleting them.
Dynamic Tags
Tags can be callables that receive the request:
@cache_page(
timeout=3600,
key_func=lambda r: r.path,
tags=[
lambda r: f"user-{r.user.pk}", # Per-user tag
lambda r: f"store-{r.headers.get('X-Store-ID')}",
lambda r: [f"a-{r.GET.get('a')}", f"b-{r.GET.get('b')}"], # Multiple tags
],
)
def dashboard(request):
...
Conditional Caching
@cache_page(
timeout=3600,
key_func=lambda r: r.path,
only_if=lambda r: r.user.is_anonymous, # Only cache for anonymous users
)
def public_page(request):
...
Bypass Cache
Set request.do_not_cache = True to skip caching for specific requests:
def my_view(request):
if request.GET.get("preview"):
request.do_not_cache = True
...
Key Generation Utilities
Built-in key generators:
from custom_cache_page import (
generate_cache_key, # Path + query params
generate_query_params_cache_key, # Query params only
hash_key, # MD5 hash utility
)
@cache_page(
timeout=3600,
key_func=generate_cache_key,
)
def my_view(request):
...
Surrogate Key Generators
Built-in surrogate key generators for common patterns:
from custom_cache_page import (
surrogate_from_path, # Key from URL path
surrogate_from_model, # Key for model instances
surrogate_from_user, # Key for authenticated user
surrogate_from_query_params, # Keys from query parameters
)
@cache_page(
timeout=3600,
key_func=lambda r: r.path,
tags=[
surrogate_from_path, # "path-api-products"
lambda r: surrogate_from_model("Product", r.GET.get("id")),
surrogate_from_user, # "user-123" or None
],
)
def product_detail(request):
...
Custom Backends
You can create custom backends by extending BaseCacheBackend. This is useful for CDN integration (Fastly, Cloudflare, etc.) or custom caching strategies.
Example: Fastly CDN Backend
import requests
from django.http import HttpResponse
from custom_cache_page.backends.base import BaseCacheBackend, CacheEntry
class FastlyBackend(BaseCacheBackend):
"""Backend that adds Surrogate-Key headers for Fastly CDN."""
def __init__(self, api_token: str, service_id: str, **options):
super().__init__(**options)
self.api_token = api_token
self.service_id = service_id
def get(self, key: str):
# Fastly handles caching at the edge
return None
def set(self, entry: CacheEntry):
# Caching handled by Fastly based on Cache-Control headers
pass
def delete(self, key: str):
return False
def invalidate_by_surrogate(self, surrogate_key: str) -> int:
"""Purge by surrogate key via Fastly API."""
response = requests.post(
f"https://api.fastly.com/service/{self.service_id}/purge/{surrogate_key}",
headers={
"Fastly-Key": self.api_token,
"Fastly-Soft-Purge": "1",
},
)
response.raise_for_status()
return 1
def prepare_response(self, response: HttpResponse, surrogate_keys: list[str]):
"""Add Surrogate-Key header for Fastly."""
if surrogate_keys:
response["Surrogate-Key"] = " ".join(surrogate_keys)
return response
Example: Cloudflare CDN Backend
import requests
from django.http import HttpResponse
from custom_cache_page.backends.base import BaseCacheBackend, CacheEntry
class CloudflareBackend(BaseCacheBackend):
"""Backend that adds Cache-Tag headers for Cloudflare CDN."""
def __init__(self, api_token: str, zone_id: str, **options):
super().__init__(**options)
self.api_token = api_token
self.zone_id = zone_id
def get(self, key: str):
return None
def set(self, entry: CacheEntry):
pass
def delete(self, key: str):
return False
def invalidate_by_surrogate(self, surrogate_key: str) -> int:
"""Purge by cache tag via Cloudflare API."""
response = requests.post(
f"https://api.cloudflare.com/client/v4/zones/{self.zone_id}/purge_cache",
headers={
"Authorization": f"Bearer {self.api_token}",
"Content-Type": "application/json",
},
json={"tags": [surrogate_key]},
)
response.raise_for_status()
return 1
def prepare_response(self, response: HttpResponse, surrogate_keys: list[str]):
"""Add Cache-Tag header for Cloudflare."""
if surrogate_keys:
response["Cache-Tag"] = " ".join(surrogate_keys)
return response
Registering Custom Backends
# settings.py
CUSTOM_CACHE_PAGE = {
"DEFAULT_BACKEND": "myapp.backends.FastlyBackend",
"BACKENDS": {
"fastly": {
"BACKEND": "myapp.backends.FastlyBackend",
"OPTIONS": {
"api_token": "your-token",
"service_id": "your-service-id",
},
},
},
}
Composite Backend
Combine multiple backends (e.g., local Django cache + CDN headers):
# settings.py
CUSTOM_CACHE_PAGE = {
"DEFAULT_BACKEND": "composite",
"BACKENDS": {
"django": {
"BACKEND": "django",
"OPTIONS": {"cache_name": "default"},
},
"fastly": {
"BACKEND": "myapp.backends.FastlyBackend",
"OPTIONS": {"api_token": "...", "service_id": "..."},
},
"composite": {
"BACKEND": "composite",
"OPTIONS": {
"backends": ["django", "fastly"],
},
},
},
}
Surrogate Index
The Django backend uses a surrogate index to track which cache keys belong to which tags. This enables bulk invalidation.
Auto-detection (Default)
When using django-redis, the DjangoCacheIndex automatically detects the Redis client and uses native Redis SADD:
# settings.py - No extra configuration needed
CACHES = {
"default": {
"BACKEND": "django_redis.cache.RedisCache",
"LOCATION": "redis://localhost:6379/1",
}
}
# The DjangoCacheBackend will auto-detect Redis and use SADD
Explicit Redis Index
For more control, use RedisSurrogateIndex directly:
from custom_cache_page import RedisSurrogateIndex
from custom_cache_page.backends.django import DjangoCacheBackend
backend = DjangoCacheBackend(
cache_name="default",
surrogate_index=RedisSurrogateIndex(
url="redis://localhost:6379/0",
timeout=86400, # Index TTL (default: 24h)
prefix="_surrogate:", # Key prefix
),
)
Or with an existing Redis client:
import redis
from custom_cache_page import RedisSurrogateIndex
redis_client = redis.from_url("redis://localhost:6379/0")
index = RedisSurrogateIndex(redis_client=redis_client)
Null Index (CDN-only)
When using CDN backends where invalidation happens via API, disable local index tracking:
from custom_cache_page import NullSurrogateIndex
from custom_cache_page.backends.django import DjangoCacheBackend
backend = DjangoCacheBackend(
cache_name="default",
surrogate_index=NullSurrogateIndex(),
)
Available Index Classes
| Class | Use Case |
|---|---|
DjangoCacheIndex |
Default. Auto-detects Redis, falls back to standard cache |
RedisSurrogateIndex |
Explicit Redis with native SADD |
NullSurrogateIndex |
No-op for CDN-only setups |
BaseSurrogateIndex |
Abstract base for custom implementations |
API Reference
cache_page
@cache_page(
timeout: int, # Cache TTL in seconds
key_func: Callable[[HttpRequest], str], # Cache key generator
*,
tags: list[str | Callable | Versioned] = None, # Cache tags
prefix: str = None, # Key prefix
backend: str | BaseCacheBackend = None, # Backend name or instance
cache_name: str = "default", # Django cache alias
only_if: Callable[[HttpRequest], bool] = None, # Condition function
)
versioned
versioned(name: str, timeout: int = 864000) -> Versioned
Wrap a tag name for O(1) versioned invalidation. The timeout parameter sets the TTL for the version key (default: 10 days).
invalidate_tag
invalidate_tag(tag: str, backend: str = None) -> int
Invalidate all caches with the given tag. Returns the number of invalidated entries (or new version number for versioned tags).
invalidate_tags
invalidate_tags(tags: list[str], backend: str = None) -> int
Batch invalidation of multiple tags.
Upgrading from v0.x
Version 1.0 introduces breaking changes. See HISTORY.md for the full changelog.
Key changes:
# Old (v0.x)
from custom_cache_page.cache import cache_page
from custom_cache_page.utils import invalidate_group_caches
@cache_page(
timeout=3600,
key_func=lambda r: r.path,
versioned=True,
group_func=lambda r: "my-group",
)
invalidate_group_caches("my-group")
# New (v1.0)
from custom_cache_page import cache_page, versioned, invalidate_tag
@cache_page(
timeout=3600,
key_func=lambda r: r.path,
tags=[versioned("my-group")],
)
invalidate_tag("my-group")
Development
git clone https://github.com/zidsa/django-custom-cache-page.git
cd django-custom-cache-page
pip install -e ".[dev]"
# Run tests
pytest
# Run tests across Python/Django versions
tox
# Lint and format
ruff check .
ruff format .
# Type check
pyright custom_cache_page/
License
MIT License - see LICENSE for details.
Changelog
1.0.0 (2026-01-14)
Breaking Changes
- Import paths changed:
- Old:
from custom_cache_page.cache import cache_page - New:
from custom_cache_page import cache_page
- Old:
- Removed modules:
cache.pyandutils.pyare removed. Import from package root instead. - API changed:
versioned,group_funcreplaced with unifiedtagsparameter - Minimum Python version: 3.9+ (was 3.6+)
- Minimum Django version: 4.2+ (was 2.0+)
New Features
-
Unified
tagsparameter: Replaces oldversioned/group_funcwith a single, flexible API:@cache_page( timeout=3600, key_func=lambda r: r.path, tags=[ versioned("products"), # O(1) invalidation via version increment "category", # Regular surrogate key lambda r: f"user-{r.user.pk}", # Dynamic tag from request ], )
-
versioned()wrapper: O(1) cache invalidation via version incrementingfrom custom_cache_page import versioned, invalidate_tag tags=[versioned("products")] # Uses version number in cache key invalidate_tag("products") # Just increments version (O(1))
-
Pluggable backends: Abstract backend interface for custom implementations:
DjangoCacheBackend- Uses Django's cache framework (default)CompositeBackend- Combine multiple backends- Easy to extend for CDN integration (Fastly, Cloudflare, etc.)
-
New invalidation functions:
invalidate_tag(tag)- Invalidate by tag (O(1) for versioned, deletes for regular)invalidate_tags(tags)- Batch invalidation
-
Surrogate key generators:
surrogate_from_path(request)- Key from URL pathsurrogate_from_model(name, pk)- Key for model instancessurrogate_from_user(request)- Key for authenticated usersurrogate_from_query_params(request)- Keys from query params
-
Configuration via Django settings:
CUSTOM_CACHE_PAGE = { "DEFAULT_BACKEND": "django", "BACKENDS": { "fastly": { "BACKEND": "fastly", "OPTIONS": {"api_token": "...", "service_id": "..."}, }, }, }
Installation
pip install django-custom-cache-page
0.4 (2026-01-14)
- Added support for Python 3.9-3.13
- Added support for Django 4.2-6.0
- Dropped support for Python 3.6-3.8
- Dropped support for Django 2.0-4.1
0.3
- Initial public release
Project details
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file django_custom_cache_page-1.0.0.tar.gz.
File metadata
- Download URL: django_custom_cache_page-1.0.0.tar.gz
- Upload date:
- Size: 27.1 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.14.2
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
bc6c87efc42486698023dcfe0c8ea0f0473c720f83b10e67f757300070621593
|
|
| MD5 |
b210608d1d8d43069188b5134bdfd4b4
|
|
| BLAKE2b-256 |
159b7dd2aa08d2052c77be26019f69d460fd4f287233f850b84e23916c5da43b
|
File details
Details for the file django_custom_cache_page-1.0.0-py3-none-any.whl.
File metadata
- Download URL: django_custom_cache_page-1.0.0-py3-none-any.whl
- Upload date:
- Size: 28.5 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.14.2
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
d648f10235e1da03f0f92e363bc799058e7eea223cfe51bd671d9de811b7f62b
|
|
| MD5 |
3b4db5d03fa4d8aca3ab7e0d735ffb7a
|
|
| BLAKE2b-256 |
f95209a562fb8c6f46d18ed795948e0007f0838e0b53715faac5ed90c3f44e96
|