Skip to main content

A customizable cache_page decorator with surrogate-key support and pluggable backends.

Project description

django-custom-cache-page

Python Django Coverage License

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
  • Removed modules: cache.py and utils.py are removed. Import from package root instead.
  • API changed: versioned, group_func replaced with unified tags parameter
  • Minimum Python version: 3.9+ (was 3.6+)
  • Minimum Django version: 4.2+ (was 2.0+)

New Features

  • Unified tags parameter: Replaces old versioned/group_func with 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 incrementing

    from 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 path
    • surrogate_from_model(name, pk) - Key for model instances
    • surrogate_from_user(request) - Key for authenticated user
    • surrogate_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

django_custom_cache_page-1.0.0.tar.gz (27.1 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

django_custom_cache_page-1.0.0-py3-none-any.whl (28.5 kB view details)

Uploaded Python 3

File details

Details for the file django_custom_cache_page-1.0.0.tar.gz.

File metadata

File hashes

Hashes for django_custom_cache_page-1.0.0.tar.gz
Algorithm Hash digest
SHA256 bc6c87efc42486698023dcfe0c8ea0f0473c720f83b10e67f757300070621593
MD5 b210608d1d8d43069188b5134bdfd4b4
BLAKE2b-256 159b7dd2aa08d2052c77be26019f69d460fd4f287233f850b84e23916c5da43b

See more details on using hashes here.

File details

Details for the file django_custom_cache_page-1.0.0-py3-none-any.whl.

File metadata

File hashes

Hashes for django_custom_cache_page-1.0.0-py3-none-any.whl
Algorithm Hash digest
SHA256 d648f10235e1da03f0f92e363bc799058e7eea223cfe51bd671d9de811b7f62b
MD5 3b4db5d03fa4d8aca3ab7e0d735ffb7a
BLAKE2b-256 f95209a562fb8c6f46d18ed795948e0007f0838e0b53715faac5ed90c3f44e96

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page