django-stash 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,28 @@
1
+ BSD 3-Clause License
2
+
3
+ Copyright (c) 2026, Andy Babic
4
+
5
+ Redistribution and use in source and binary forms, with or without
6
+ modification, are permitted provided that the following conditions are met:
7
+
8
+ 1. Redistributions of source code must retain the above copyright notice, this
9
+ list of conditions and the following disclaimer.
10
+
11
+ 2. Redistributions in binary form must reproduce the above copyright notice,
12
+ this list of conditions and the following disclaimer in the documentation
13
+ and/or other materials provided with the distribution.
14
+
15
+ 3. Neither the name of the copyright holder nor the names of its
16
+ contributors may be used to endorse or promote products derived from
17
+ this software without specific prior written permission.
18
+
19
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
20
+ AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
21
+ IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
22
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
23
+ FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
24
+ DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
25
+ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
26
+ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
27
+ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
28
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
@@ -0,0 +1,110 @@
1
+ Metadata-Version: 2.5
2
+ Name: django-stash
3
+ Version: 0.1.0
4
+ Summary: Scoped ambient memoization for Django — stash values for the duration of a request or task, available anywhere in the stack.
5
+ Keywords: Django,cache,memoize,request,asgiref,performance
6
+ Author-email: Andy Babic <andyjbabic@gmail.com>
7
+ Maintainer-email: Andy Babic <andyjbabic@gmail.com>
8
+ Requires-Python: >=3.11
9
+ Description-Content-Type: text/markdown
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: License :: OSI Approved :: BSD License
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Programming Language :: Python
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Framework :: Django
20
+ Classifier: Framework :: Django :: 4.2
21
+ Classifier: Framework :: Django :: 5.0
22
+ Classifier: Framework :: Django :: 5.1
23
+ Classifier: Framework :: Django :: 5.2
24
+ License-File: LICENSE
25
+ Requires-Dist: Django>=4.2
26
+ Requires-Dist: asgiref>=3.7
27
+ Requires-Dist: ruff>=0.1.1,<1.0 ; extra == "development"
28
+ Requires-Dist: coverage>=7.0,<8.0 ; extra == "testing"
29
+ Project-URL: Changelog, https://github.com/ababic/django-stash/blob/main/CHANGELOG.md
30
+ Project-URL: Source, https://github.com/ababic/django-stash
31
+ Provides-Extra: development
32
+ Provides-Extra: testing
33
+ Import-Name: stash
34
+
35
+ # django-stash
36
+
37
+ [![License: BSD-3-Clause](https://img.shields.io/badge/License-BSD--3--Clause-blue.svg)](https://opensource.org/licenses/BSD-3-Clause)
38
+ [![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)
39
+
40
+ Scoped ambient memoization for Django.
41
+
42
+ Stash values for the duration of a **request** (or any other explicit scope), and read them again from anywhere in the call stack — without threading a `request` through every helper.
43
+
44
+ This is **not** a cache backend. It does not replace Redis/Memcached/database cache. It is an L1 memo pad that only exists while a scope is open.
45
+
46
+ ## Install
47
+
48
+ ```bash
49
+ pip install django-stash
50
+ ```
51
+
52
+ ```python
53
+ MIDDLEWARE = [
54
+ "stash.middleware.StashMiddleware",
55
+ # ...
56
+ ]
57
+ ```
58
+
59
+ No `INSTALLED_APPS` entry required.
60
+
61
+ ## Usage
62
+
63
+ ```python
64
+ import stash
65
+
66
+ def expensive_site_paths():
67
+ return stash.get_or_set(
68
+ "wagtail.site_root_paths",
69
+ loader=compute_site_root_paths,
70
+ )
71
+
72
+ @stash.memoize(key="taxonomy.colours")
73
+ def get_colour_choices():
74
+ return list(Colour.objects.values_list("id", "name"))
75
+ ```
76
+
77
+ Outside a scope (management commands, Celery, shell), `get_or_set` / `memoize` just call the loader every time. Nothing sticks on the worker.
78
+
79
+ ### Manual scopes
80
+
81
+ ```python
82
+ with stash.stash_scope():
83
+ ...
84
+ ```
85
+
86
+ Useful for tests, management commands, or task bodies that want the same behaviour.
87
+
88
+ ### Clearing
89
+
90
+ ```python
91
+ stash.clear("wagtail.site_root_paths") # one key
92
+ stash.clear() # everything in this scope
93
+ ```
94
+
95
+ ## Rules of thumb
96
+
97
+ 1. **Scope owns lifetime** — middleware / `stash_scope()` enable at enter and disable in `finally`.
98
+ 2. **No scope ⇒ no stash** — avoids sticky process caches on Gunicorn sync workers.
99
+ 3. **Prefer immutable-ish values** — stash stores/returns shallow copies; don't stash live model graphs you plan to mutate in place.
100
+ 4. **Shared cache is separate** — if you need cross-process invalidation, keep Django's cache as L2 and use stash as L1 in front of it.
101
+
102
+ ## Development
103
+
104
+ ```bash
105
+ cd django-stash
106
+ python -m venv .venv && source .venv/bin/activate
107
+ pip install -e ".[testing,development]"
108
+ python testmanage.py test
109
+ ```
110
+
@@ -0,0 +1,75 @@
1
+ # django-stash
2
+
3
+ [![License: BSD-3-Clause](https://img.shields.io/badge/License-BSD--3--Clause-blue.svg)](https://opensource.org/licenses/BSD-3-Clause)
4
+ [![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)
5
+
6
+ Scoped ambient memoization for Django.
7
+
8
+ Stash values for the duration of a **request** (or any other explicit scope), and read them again from anywhere in the call stack — without threading a `request` through every helper.
9
+
10
+ This is **not** a cache backend. It does not replace Redis/Memcached/database cache. It is an L1 memo pad that only exists while a scope is open.
11
+
12
+ ## Install
13
+
14
+ ```bash
15
+ pip install django-stash
16
+ ```
17
+
18
+ ```python
19
+ MIDDLEWARE = [
20
+ "stash.middleware.StashMiddleware",
21
+ # ...
22
+ ]
23
+ ```
24
+
25
+ No `INSTALLED_APPS` entry required.
26
+
27
+ ## Usage
28
+
29
+ ```python
30
+ import stash
31
+
32
+ def expensive_site_paths():
33
+ return stash.get_or_set(
34
+ "wagtail.site_root_paths",
35
+ loader=compute_site_root_paths,
36
+ )
37
+
38
+ @stash.memoize(key="taxonomy.colours")
39
+ def get_colour_choices():
40
+ return list(Colour.objects.values_list("id", "name"))
41
+ ```
42
+
43
+ Outside a scope (management commands, Celery, shell), `get_or_set` / `memoize` just call the loader every time. Nothing sticks on the worker.
44
+
45
+ ### Manual scopes
46
+
47
+ ```python
48
+ with stash.stash_scope():
49
+ ...
50
+ ```
51
+
52
+ Useful for tests, management commands, or task bodies that want the same behaviour.
53
+
54
+ ### Clearing
55
+
56
+ ```python
57
+ stash.clear("wagtail.site_root_paths") # one key
58
+ stash.clear() # everything in this scope
59
+ ```
60
+
61
+ ## Rules of thumb
62
+
63
+ 1. **Scope owns lifetime** — middleware / `stash_scope()` enable at enter and disable in `finally`.
64
+ 2. **No scope ⇒ no stash** — avoids sticky process caches on Gunicorn sync workers.
65
+ 3. **Prefer immutable-ish values** — stash stores/returns shallow copies; don't stash live model graphs you plan to mutate in place.
66
+ 4. **Shared cache is separate** — if you need cross-process invalidation, keep Django's cache as L2 and use stash as L1 in front of it.
67
+
68
+ ## Development
69
+
70
+ ```bash
71
+ cd django-stash
72
+ python -m venv .venv && source .venv/bin/activate
73
+ pip install -e ".[testing,development]"
74
+ python testmanage.py test
75
+ ```
@@ -0,0 +1,67 @@
1
+ [project]
2
+ name = "django-stash"
3
+ description = "Scoped ambient memoization for Django — stash values for the duration of a request or task, available anywhere in the stack."
4
+ authors = [{name = "Andy Babic", email = "andyjbabic@gmail.com"}]
5
+ maintainers = [
6
+ {name = "Andy Babic", email = "andyjbabic@gmail.com"},
7
+ ]
8
+ readme = "README.md"
9
+ license = {file = "LICENSE"}
10
+ keywords = ["Django", "cache", "memoize", "request", "asgiref", "performance"]
11
+ classifiers = [
12
+ "Development Status :: 3 - Alpha",
13
+ "Intended Audience :: Developers",
14
+ "License :: OSI Approved :: BSD License",
15
+ "Operating System :: OS Independent",
16
+ "Programming Language :: Python",
17
+ "Programming Language :: Python :: 3",
18
+ "Programming Language :: Python :: 3.11",
19
+ "Programming Language :: Python :: 3.12",
20
+ "Programming Language :: Python :: 3.13",
21
+ "Framework :: Django",
22
+ "Framework :: Django :: 4.2",
23
+ "Framework :: Django :: 5.0",
24
+ "Framework :: Django :: 5.1",
25
+ "Framework :: Django :: 5.2",
26
+ ]
27
+
28
+ dynamic = ["version"]
29
+ requires-python = ">=3.11"
30
+ dependencies = [
31
+ "Django>=4.2",
32
+ "asgiref>=3.7",
33
+ ]
34
+
35
+ [project.optional-dependencies]
36
+ testing = [
37
+ "coverage>=7.0,<8.0",
38
+ ]
39
+ development = [
40
+ "ruff>=0.1.1,<1.0",
41
+ ]
42
+
43
+ [project.urls]
44
+ Source = "https://github.com/ababic/django-stash"
45
+ Changelog = "https://github.com/ababic/django-stash/blob/main/CHANGELOG.md"
46
+
47
+ [build-system]
48
+ requires = ["flit_core >=3.2,<4"]
49
+ build-backend = "flit_core.buildapi"
50
+
51
+ [tool.flit.module]
52
+ name = "stash"
53
+
54
+ [tool.flit.sdist]
55
+ exclude = [
56
+ ".*",
57
+ "*.db",
58
+ "*.json",
59
+ "*.ini",
60
+ "*.sqlite3",
61
+ "*.yaml",
62
+ "tests",
63
+ "CHANGELOG.md",
64
+ "ruff.toml",
65
+ "manage.py",
66
+ "testmanage.py",
67
+ ]
@@ -0,0 +1,28 @@
1
+ from stash.__version__ import __version__
2
+ from stash.api import (
3
+ clear,
4
+ disable,
5
+ enable,
6
+ enabled,
7
+ get,
8
+ get_or_set,
9
+ memoize,
10
+ set,
11
+ stash_scope,
12
+ )
13
+ from stash.middleware import StashMiddleware
14
+
15
+
16
+ __all__ = [
17
+ "StashMiddleware",
18
+ "__version__",
19
+ "clear",
20
+ "disable",
21
+ "enable",
22
+ "enabled",
23
+ "get",
24
+ "get_or_set",
25
+ "memoize",
26
+ "set",
27
+ "stash_scope",
28
+ ]
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -0,0 +1,157 @@
1
+ from __future__ import annotations
2
+
3
+ import copy
4
+
5
+ from collections.abc import Callable, Iterator
6
+ from contextlib import contextmanager
7
+ from functools import wraps
8
+ from typing import Any, TypeVar, overload
9
+
10
+ from asgiref.local import Local
11
+
12
+
13
+ T = TypeVar("T")
14
+
15
+ _local = Local()
16
+ _MISSING = object()
17
+
18
+
19
+ def enabled() -> bool:
20
+ """Return whether a stash scope is active for this execution context."""
21
+ return bool(getattr(_local, "enabled", False))
22
+
23
+
24
+ def enable() -> None:
25
+ """
26
+ Open a stash scope for this execution context.
27
+
28
+ Prefer ``StashMiddleware`` or ``stash_scope()`` so lifetime is bounded.
29
+ """
30
+ _local.enabled = True
31
+ _local.values = {}
32
+
33
+
34
+ def disable() -> None:
35
+ """Close the stash scope and drop all stored values for this context."""
36
+ _local.values = {}
37
+ _local.enabled = False
38
+
39
+
40
+ def _values() -> dict[str, Any]:
41
+ values = getattr(_local, "values", None)
42
+ if values is None:
43
+ values = {}
44
+ _local.values = values
45
+ return values
46
+
47
+
48
+ def _store(value: Any) -> Any:
49
+ return copy.copy(value)
50
+
51
+
52
+ def _retrieve(value: Any) -> Any:
53
+ return copy.copy(value)
54
+
55
+
56
+ def get(key: str, default: Any = None) -> Any:
57
+ """Return a stashed value, or ``default`` on miss / when no scope is active."""
58
+ if not enabled():
59
+ return default
60
+ value = _values().get(key, _MISSING)
61
+ if value is _MISSING:
62
+ return default
63
+ return _retrieve(value)
64
+
65
+
66
+ def set(key: str, value: Any) -> None:
67
+ """
68
+ Store ``value`` under ``key`` when a scope is active.
69
+
70
+ No-op when no scope is active (avoids sticky process-level state).
71
+ """
72
+ if not enabled():
73
+ return
74
+ _values()[key] = _store(value)
75
+
76
+
77
+ def clear(key: str | None = None) -> None:
78
+ """Clear one key, or the entire stash when ``key`` is omitted."""
79
+ if not enabled():
80
+ return
81
+ if key is None:
82
+ _local.values = {}
83
+ return
84
+ _values().pop(key, None)
85
+
86
+
87
+ def get_or_set(key: str, loader: Callable[[], T]) -> T:
88
+ """
89
+ Return a stashed value, or call ``loader``, stash the result, and return it.
90
+
91
+ When no scope is active, always calls ``loader()`` and does not store.
92
+ """
93
+ if not enabled():
94
+ return loader()
95
+
96
+ values = _values()
97
+ value = values.get(key, _MISSING)
98
+ if value is not _MISSING:
99
+ return _retrieve(value)
100
+
101
+ loaded = loader()
102
+ values[key] = _store(loaded)
103
+ return _retrieve(loaded)
104
+
105
+
106
+ def _memo_key(func: Callable[..., Any], args: tuple[Any, ...], kwargs: dict[str, Any]) -> str:
107
+ qualname = f"{func.__module__}.{func.__qualname__}"
108
+ if not args and not kwargs:
109
+ return qualname
110
+ return f"{qualname}:{args!r}:{sorted(kwargs.items())!r}"
111
+
112
+
113
+ @overload
114
+ def memoize(func: Callable[..., T]) -> Callable[..., T]: ...
115
+
116
+
117
+ @overload
118
+ def memoize(
119
+ *,
120
+ key: str | None = None,
121
+ ) -> Callable[[Callable[..., T]], Callable[..., T]]: ...
122
+
123
+
124
+ def memoize(
125
+ func: Callable[..., T] | None = None,
126
+ *,
127
+ key: str | None = None,
128
+ ) -> Callable[..., T] | Callable[[Callable[..., T]], Callable[..., T]]:
129
+ """
130
+ Memoize a function into the active stash scope.
131
+
132
+ ``@memoize`` derives a key from the function and call arguments.
133
+ ``@memoize(key="...")`` uses a fixed key (argument values ignored).
134
+ Outside a scope, the function always runs normally.
135
+ """
136
+
137
+ def decorator(fn: Callable[..., T]) -> Callable[..., T]:
138
+ @wraps(fn)
139
+ def wrapper(*args: Any, **kwargs: Any) -> T:
140
+ cache_key = key if key is not None else _memo_key(fn, args, kwargs)
141
+ return get_or_set(cache_key, lambda: fn(*args, **kwargs))
142
+
143
+ return wrapper
144
+
145
+ if func is not None:
146
+ return decorator(func)
147
+ return decorator
148
+
149
+
150
+ @contextmanager
151
+ def stash_scope() -> Iterator[None]:
152
+ """Context manager that opens a stash scope for a block of work."""
153
+ enable()
154
+ try:
155
+ yield
156
+ finally:
157
+ disable()
@@ -0,0 +1,27 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Callable
4
+
5
+ from django.http import HttpRequest, HttpResponse
6
+
7
+ from stash.api import disable, enable
8
+
9
+
10
+ class StashMiddleware:
11
+ """
12
+ Open a stash scope for the duration of one HTTP request.
13
+
14
+ Outside this middleware (management commands, Celery tasks, etc.) stash
15
+ reads miss and writes no-op, so values cannot leak across units of work on
16
+ a reused worker thread.
17
+ """
18
+
19
+ def __init__(self, get_response: Callable[[HttpRequest], HttpResponse]) -> None:
20
+ self.get_response = get_response
21
+
22
+ def __call__(self, request: HttpRequest) -> HttpResponse:
23
+ enable()
24
+ try:
25
+ return self.get_response(request)
26
+ finally:
27
+ disable()