Metadata-Version: 2.5
Name: django-stash
Version: 0.5.0
Summary: Scoped ambient memoization for Django — stash values for the duration of a request or task, available anywhere in the stack.
Keywords: Django,cache,memoize,request,asgiref,performance
Author-email: Andy Babic <andyjbabic@gmail.com>
Maintainer-email: Andy Babic <andyjbabic@gmail.com>
Requires-Python: >=3.11
Description-Content-Type: text/markdown
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: BSD License
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Framework :: Django
Classifier: Framework :: Django :: 4.2
Classifier: Framework :: Django :: 5.0
Classifier: Framework :: Django :: 5.1
Classifier: Framework :: Django :: 5.2
License-File: LICENSE
Requires-Dist: Django>=4.2
Requires-Dist: asgiref>=3.7
Requires-Dist: ruff>=0.1.1,<1.0 ; extra == "development"
Requires-Dist: coverage>=7.0,<8.0 ; extra == "testing"
Requires-Dist: pytest>=7 ; extra == "testing"
Project-URL: Changelog, https://github.com/ababic/django-stash/blob/main/CHANGELOG.md
Project-URL: Source, https://github.com/ababic/django-stash
Provides-Extra: development
Provides-Extra: testing
Import-Name: stash

# django-stash

[![PyPI](https://img.shields.io/pypi/v/django-stash.svg)](https://pypi.org/project/django-stash/)
[![License: BSD-3-Clause](https://img.shields.io/badge/License-BSD--3--Clause-blue.svg)](https://opensource.org/licenses/BSD-3-Clause)
[![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff)

Remember a value for the rest of the current request, and read it back from anywhere — without passing `request` around.

```python
import stash

# middleware.py — the one place that actually has `request`
class TenantMiddleware:
    def __init__(self, get_response):
        self.get_response = get_response

    def __call__(self, request):
        stash.set("tenant", Tenant.objects.get(domain=request.get_host()))
        return self.get_response(request)


# models.py, templatetags/, signals.py — no `request` in sight
def get_current_tenant():
    return stash.get("tenant")
```

`TenantMiddleware` resolves the tenant from the hostname once, because that's the only place `request` is available. Every other call to `get_current_tenant()` during that request — from a model manager, a template tag, a signal handler — reads the same value back. Next request: middleware resolves it again. A scope has to be open for `stash.set` to stick — `StashMiddleware`, or `stash_scope()` in this same class. See [Install](#install).

This is **not** a cache backend. Nothing is shared between requests, processes, or workers. Values live only while a request is being handled, and are thrown away when it finishes.

## Install

```bash
pip install django-stash
```

Nothing is stored unless a scope is open. No `INSTALLED_APPS` entry needed.

If you want to use django-stash in an app you own — a website or web app — `StashMiddleware` makes it easy to get started:

```python
MIDDLEWARE = [
    "stash.middleware.StashMiddleware",
    # ...
]
```

If you want to use it in a package (an add-on, a performance monitoring tool, or a CMS framework), open the scope from middleware you already require, or add one of your own. That way installers don't have to add an unfamiliar third-party middleware to their project settings:

```python
import stash

class TenantMiddleware:
    def __init__(self, get_response):
        self.get_response = get_response

    def __call__(self, request):
        with stash.stash_scope():
            stash.set("tenant", Tenant.objects.get(domain=request.get_host()))
            return self.get_response(request)
```

The same `with` works in async middleware.

Do not open the HTTP scope from a view. Django renders `TemplateResponse` *after* the view returns — `TemplateView`, `ListView`, and `DetailView` all do this — so a mixin on `dispatch()`, a decorator on `as_view()`, or `with stash_scope():` around the view body would close before template tags run. Middleware is the only opener that covers the full request/response cycle: `get_response` includes the view *and* that deferred render. `StashMiddleware` does it for an app you own; in a package, wrap `get_response` as above.

`with stash_scope():` *inside* a view is still fine for work that finishes before you return — a loop, a loader. That is a nested block, not the request opener.

Management commands sit outside the request cycle, so `StashMiddleware` never runs. Mix `StashCommandMixin` onto the command so each invocation gets a stash scope — in an app you own, or in a package (installers never see it):

```python
from django.core.management.base import BaseCommand

import stash

class Command(stash.StashCommandMixin, BaseCommand):
    def handle(self, *args, **options):
        tenant = stash.get_or_set("tenant", load_tenant)
        self.stdout.write(str(tenant))
```

In a package, set `stash_scopes = ("wagtail",)` so that [named scope](#named-scopes) is open for the whole run. The mixin still opens the default too.

These openers compose. A package `stash_scope()` around `get_response` is safe even if the project also uses `StashMiddleware` — keep `StashMiddleware` first so it opens the request, then later middleware stacks on it.

Keys in the default scope are shared with the project. If a value is yours alone, give it a [named scope](#named-scopes) so nothing else can read, overwrite, or `clear` it.

## The problem it solves

Not every case needs `request` at all. Sometimes you just have something expensive, needed in several unrelated places during one request — a template tag, a model method, a serializer, a signal handler:

```python
# views.py
settings = SiteSettings.objects.get()

# templatetags/theme.py
settings = SiteSettings.objects.get()   # same query again

# models.py
settings = SiteSettings.objects.get()   # and again
```

The usual fixes are awkward: thread `request` through every function, hang attributes off `request`, or reach for a global cache and then worry about invalidation.

With stash, each call site asks for the value by name and the first one pays:

```python
import stash

def get_site_settings():
    return stash.get_or_set("site_settings", lambda: SiteSettings.objects.get())
```

## Where this fits

Reach for stash when a value is expensive, needed in more than one place during a single request, and awkward to get to from where you are:

- **A request-derived value needed somewhere without `request`.** This is the example at the top: something that can only be resolved from the hostname, a header, or a cookie — the current tenant, the authenticated API client, an A/B test bucket — computed once where `request` exists, then read back from wherever it doesn't.
- **A check that fans out across a page.** A changelist renders 50 rows and calls `can_edit(obj)` for each. If `can_edit` starts with something request-wide — "is this user a reviewer this month" — that shouldn't be recomputed 50 times.
- **You're about to write `request._cached_thing = ...`.** This pattern already exists in most Django codebases: stash something on `request` in middleware, read it back everywhere else. It works, but it only works where `request` is reachable, and it leaks the caching detail into every call site. `stash.get_or_set` is the same idea, usable from anywhere, without a `request` reference.
- **A value that must be refreshed mid-request after a write.** Read settings early; a view updates them; later code in the *same* request must see the new value. One `stash.clear("site_settings")` call after the write handles it — see [`clear`](#get-set-clear) below.
- **Batch scripts, one memo per unit of work.** Mix `StashCommandMixin` onto a management command to scope the whole run, and wrap each row in `stash_scope()` so per-item memos die with the block. See [Nested scopes](#nested-scopes).

## Where this doesn't fit

Skip stash when the value is easy to pass, cheap to compute, or belongs in a different tool:

- **You can pass the value — or `request` — through a few functions you own.** `view → helper → do_the_thing` can take `tenant` as an argument. That's a clearer API: the dependency is in the signature, and the function is easy to test. Stash is for call sites that *don't* share a chain — a template tag, a signal handler, a model method — not for skipping an argument you'd rather not write.
- **The value is only needed in one place.** Compute it there. Remembering it only pays off when unrelated code needs the same answer during one request.
- **The lookup is cheap.** A dict access, a simple attribute, an already-loaded relation. Stash is for work you don't want to repeat — a query, a permission that fans out, a hostname lookup.
- **Opening the HTTP scope from a view mixin or decorator.** Django renders `TemplateResponse` after the view returns, so template tags would miss. Use middleware — it is the only way to cover the full request/response cycle. See [Install](#install).
- **Secrets, tokens, passwords, or anything you would not put in Django's cache.** Stash is ambient: any code in the same scope can `stash.get` the value by name. A named scope only keeps your keys apart from other packages; it does not hide anything. Leave credentials out.
- **You want the answer shared across requests, workers, or deploys.** That's Django's cache framework (`django.core.cache`, backed by Redis/Memcached/the DB). Stash never outlives one scope — put it in front of that cache as L1 if you want both.
- **The value needs to reach other processes.** Stash is per-process, per-scope. One worker's stash tells another worker nothing.
- **A pure function with no invalidation need, no request in the picture.** `functools.lru_cache` is simpler and doesn't need a scope at all.

## Alternatives

| | Scope | Needs `request`? | Cost per hit | Crosses requests / processes? | Invalidation |
|---|---|---|---|---|---|
| **stash** | One request, a management command run, or a `stash_scope()` block | No | Dict lookup | Never | Automatic when the scope ends; `clear()` any time before that |
| **[`django-request-cache`](https://github.com/anexia/django-request-cache)** | One request | Yes — exposes the *whole* request globally to get one | Attribute lookup | No | Automatic at request end only |
| **`request._cached_x`** (manual) | One request | Yes, at every call site | Attribute lookup | No | Manual, ad hoc |
| **Django's cache framework** | Until TTL, eviction, or delete | No | locmem: lock + dict. Redis/Memcached/DB: network round trip + (de)serialization, every call | Yes — that's the point | TTL, `cache.delete()`, or signals |
| **`functools.lru_cache`** | Process lifetime | No | Dict lookup | Accidentally, forever — same answer until the process restarts | None, short of calling `cache_clear()` yourself |
| **Bare thread-local / module global** | However long you remember to keep it valid | No | Dict/attribute lookup | Accidentally — sync workers reuse a thread across requests | Whatever you remember to write, wherever you remember to write it |

The last row is the trap: a hand-rolled `threading.local()` or `asgiref.Local()` looks identical to stash until a worker process reuses its thread for a second request and the old value is still sitting there. Stash's storage is the same mechanism (`asgiref.local.Local`), but the lifetime is never left to memory — `StashMiddleware`, `StashCommandMixin`, or `stash_scope()` opens and closes the scope around each unit of work, so there's no window where a stale value can survive into the next one.

**[`django-request-cache`](https://github.com/anexia/django-request-cache)** takes the same idea a step further: instead of exposing one named value, it makes the *whole request object* reachable from anywhere first (via `django-userforeignkey`'s `get_current_request()`), then hangs a cache off it as an attribute. That's an extra dependency, and a much bigger object made globally available than most call sites need. Stash's storage never holds `request` — only the specific values you chose to stash, by name.

**Django's cache framework** (`django.core.cache`) is still the right choice the moment a value needs to survive past one request — nothing here replaces it. The trade-off in the table above is the one to remember: reach for the cache framework when a value is shared across requests or processes, and put stash *in front of it* when the same value is read more than once inside a single request.

## Usage

### `get_or_set`

```python
import stash

def get_feature_flags():
    return stash.get_or_set("feature_flags", load_feature_flags_from_db)
```

- First call in a request: runs `load_feature_flags_from_db()`, stores the result, returns it.
- Later calls in the same request: returns the stored result. The loader is not called.
- After the request ends: the stored value is gone.

### `memoize`

Same thing, as a decorator:

```python
import stash

@stash.memoize
def get_exchange_rate(currency):
    return ExchangeRate.objects.get(currency=currency).rate
```

`get_exchange_rate("EUR")` and `get_exchange_rate("USD")` are remembered separately (the key is built from the arguments). Use `@stash.memoize(key="...")` for a fixed key that ignores arguments.

### `get`, `set`, `clear`

```python
stash.set("user_permissions", perms)
stash.get("user_permissions")            # -> perms, or None
stash.get("missing", default=[])         # -> []

stash.clear("user_permissions")          # forget one value
stash.clear()                            # forget everything stored here
```

`clear` is what you call after writing to the underlying data, so later reads in the same request see the new state:

```python
def update_site_settings(**changes):
    SiteSettings.objects.filter(pk=1).update(**changes)
    stash.clear("site_settings")
```

### Nested scopes

`stash_scope()` with no name uses the same default scope as `StashMiddleware` and `StashCommandMixin`. Nesting one inside the other does not wipe the outer values:

```python
class Command(stash.StashCommandMixin, BaseCommand):
    def handle(self, *args, **options):
        tenant = stash.get_or_set("tenant", load_tenant)
        for row in rows:
            with stash.stash_scope():
                # tenant is still visible here
                process(row)   # per-item memos live only in this block
        # tenant is still here; per-item memos are gone
```

Writes in the inner `with` stay there. Reads look in the inner block first, then the outer one. `clear("tenant")` forgets that key in both; `clear()` with no key only forgets what the inner block stored.

### Named scopes

A name is a separate namespace. `stash.get("page")` never sees `stash.get("page", scope="wagtail")`, and `clear("page")` in one does not touch the other.

**In a reusable package, use a unique name for values that are yours.** You cannot know whether the project — or another package — already uses `"page"` or `"tenant"`. Pick the package name, open that scope from middleware you control, and pass `scope=` at every call site, including `@stash.memoize(scope="wagtail")`:

```python
import stash

class PageMiddleware:
    def __init__(self, get_response):
        self.get_response = get_response

    def __call__(self, request):
        with stash.stash_scope("wagtail"):
            stash.set("page", resolve_page(request), scope="wagtail")
            return self.get_response(request)


def get_current_page():
    return stash.get("page", scope="wagtail")
```

Management commands need the same name on the mixin, or `get_or_set(..., scope="wagtail")` will not store:

```python
class Command(stash.StashCommandMixin, BaseCommand):
    stash_scopes = ("wagtail",)
```

**Leave the name off when the rest of the project is supposed to read the value** — the current tenant, the current site. That is the [Install](#install) example: `stash.set("tenant", ...)` with no `scope=`, so `stash.get("tenant")` works from models, template tags, and signals. Document those keys.

### Outside a request

Nothing is stored unless a scope is open. In a management command, Celery task, or shell:

```python
stash.get_or_set("k", loader)   # just calls loader() every time
stash.set("k", value)           # does nothing
stash.get("k")                  # -> None
```

That is deliberate: a long-lived worker never accumulates stale values by accident.

For a management command, mix in `StashCommandMixin` so each run gets a scope — the same idea as `StashMiddleware` for HTTP. See [Install](#install). Per-item memos go in a nested `stash_scope()` as in [Nested scopes](#nested-scopes). In a Celery task or shell, open the scope yourself:

```python
with stash.stash_scope():
    process_item()
```

A test is not a special case: no scope, no stored value. See [Testing](#testing).

## Behaviour in one table

| Situation | `get_or_set("k", loader)` |
|-----------|---------------------------|
| Inside a request, first call | runs `loader()`, stores, returns |
| Inside a request, later call | returns stored value |
| After `stash.clear("k")` | runs `loader()` again |
| Different request | runs `loader()` again |
| Different thread / process | runs `loader()` again |
| No scope | runs `loader()` every time; stores nothing |

## Notes

- **Do not stash secrets.** Same rule as Django's cache: no passwords, API keys, session tokens, or raw credentials. See [Where this doesn't fit](#where-this-doesnt-fit).
- Values are stored and returned as **shallow copies**. Mutating what you get back does not change what is stored. Don't rely on identity.
- Stash is an L1 in front of whatever you already do. If you also want cross-process sharing, keep using Django's cache as L2 inside your loader.
- Works under WSGI and ASGI, with sync or async views. The middleware is sync- and async-capable, so it does not force Django to adapt the rest of the chain. Storage is `asgiref.local.Local`.

## Testing

Nothing is stored unless a scope is open, and a test does not get one for free. `stash.set` with no scope does nothing, `stash.get` returns the default, and `stash.get_or_set` calls the loader on every call — the same rule as [outside a request](#outside-a-request). If a helper opened a scope for every test, the suite would stay green after `StashMiddleware` disappeared from production. Nothing below turns itself on unless the test asks.

Open the scope the same way production does for the code under test.

### Through the real opener

**HTTP.** Put `StashMiddleware` in the test `MIDDLEWARE` — the list you actually ship — and use the Django test client. The client runs middleware, so the scope covers the view *and* a deferred `TemplateResponse`. Using `RequestFactory`, or calling the view yourself, skips middleware, so it skips the scope too.

`StashMiddleware` does not join a scope the test already opened. It calls `enable()` at the start of the request, which drops whatever was there, and `disable()` when the response is done. `stash.set("tenant", ...)` in the test, then `self.client.get(...)`, does not show that tenant to the view, and `stash.get` after the client returns is a miss. Put the tenant where the middleware actually finds it, and assert on the response:

```python
response = self.client.get("/", HTTP_HOST="acme.test")
self.assertContains(response, "acme")
self.assertFalse(stash.enabled())
```

A package that opens the scope with `with stash.stash_scope():` around `get_response` stacks, instead of replacing. An outer scope opened in the test is visible for that request; writes inside the `with` still die with it. If `StashMiddleware` is also installed and listed first, its `enable()` has already dropped that outer scope — same as production, where nothing is open before middleware.

**Management commands.** Use `call_command`, not `Command().handle()`. `StashCommandMixin` opens the scope in `execute()`, and calling `handle()` directly skips it. The mixin stacks on a scope the test already opened, and drops only its own frame when `call_command` returns. You usually don't need that outer scope — the mixin owns the run.

### Calling a function directly

A template tag, a model method, `get_current_tenant()` from the top of this page — nothing is about to open a scope for you. Open one around the call. This does not prove the middleware is installed. The client test above is the one that proves that.

```python
def test_current_tenant(self):
    tenant = Tenant(domain="acme.test")
    with stash.stash_scope():
        stash.set("tenant", tenant)
        self.assertEqual(get_current_tenant(), tenant)
```

`@stash.stash_scope()` on the method is the same block, when the whole method needs it. `@stash.stash_scope("wagtail")` is the named scope. It stacks, and on the way out it restores whatever was open outside. It does not call `disable()`.

You do not need a scope to check the value from something that only uses `get_or_set`. No scope means the loader runs every time; the answer is still the answer. Open a scope when the assertion is "loaded once", or when the code uses `set` / `get`. A "called once" assertion that fails because the loader ran twice is the test saying the scope is not open — the same thing production does if the middleware is missing.

### A whole class, or pytest

Repeating `with stash.stash_scope():` gets old, and it will not notice a previous test that called `enable()` and never `disable()`. `stash.testing` is that fence. Entering it drops any scope already open. Leaving it drops them again, including one this test opened and did not close.

```python
from django.test import TestCase

import stash

from stash.testing import StashTestMixin, activate


class TenantTests(StashTestMixin, TestCase):
    def setUp(self):
        super().setUp()  # scope is open after this
        stash.set("tenant", "acme")

    def test_current_tenant(self):
        self.assertEqual(get_current_tenant(), "acme")


class OneOffTests(TestCase):
    @activate()
    def test_current_tenant(self):
        stash.set("tenant", "acme")
        self.assertEqual(get_current_tenant(), "acme")

    @activate("wagtail")
    def test_page(self):
        stash.set("page", "home", scope="wagtail")
        self.assertEqual(stash.get("page", scope="wagtail"), "home")
```

Mix `StashTestMixin` in ahead of `TestCase`, the same way `StashCommandMixin` goes ahead of `BaseCommand`. It always opens the default scope. `stash_scopes = ("wagtail",)` — or a single string — also opens that named scope, the same attribute as on the command mixin. Call `super().setUp()` before `stash.set`; the scope is not open until you do. It is still open in `tearDown`. Subtests share it. `stash.clear()`, or a nested `stash_scope()`, when one of them needs a fresh memo.

`activate` is the same fence for one method, or for a `with` block that must not see anything from outside. Don't use it for a nested block whose outer values you still need afterwards: exit calls `disable()`, and the outer scope is not put back. That is what `stash_scope()` is for.

Don't combine the mixin or `activate()` with the test client when `StashMiddleware` is installed. The middleware's `enable()` / `disable()` throws that scope away for the request and does not restore it.

pytest, when django-stash is installed:

```python
import pytest

import stash


@pytest.mark.stash_scope
def test_current_tenant():
    stash.set("tenant", "acme")
    assert stash.get("tenant") == "acme"


@pytest.mark.stash_scope("wagtail")
def test_page():
    stash.set("page", "home", scope="wagtail")
    assert stash.get("page", scope="wagtail") == "home"


def test_current_tenant_via_fixture(stash_scope):
    stash.set("tenant", "acme")
    assert stash.get("tenant") == "acme"
```

The marker and the `stash_scope` fixture do what `activate()` does: a fresh default scope, plus any names passed to the marker, cleared when the test ends. The marker applies to a function, a class, or a module (`pytestmark = pytest.mark.stash_scope`). Requesting the fixture as well is fine; it still uses the marker's names. Neither turns itself on for a test that didn't ask. If plugin autoload is off, pass `-p stash.pytest_plugin`.

### No scope, on purpose

Some tests should prove the inactive path: a command without the mixin, a branch that runs in the shell. Leave the scope closed. `stash.disable()` in `setUp` and `tearDown`, so a leak from another test cannot look like this one passed:

```python
def setUp(self):
    super().setUp()
    stash.disable()


def tearDown(self):
    stash.disable()
    super().tearDown()
```

The mixin and `activate` already do that around the tests that opt in. They do not do it for the rest of the suite.

A scope opened in the test is visible to Django's `async def` test methods. It is not visible to a `threading.Thread` you start yourself — a request's scope stays on the request thread too.

## Development

```bash
cd django-stash
python -m venv .venv && source .venv/bin/activate
pip install -e ".[testing,development]"
python testmanage.py test
```

