django-vcache 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,12 @@
1
+
2
+ # Python-generated files
3
+ __pycache__/
4
+ *.py[oc]
5
+ build/
6
+ dist/
7
+ wheels/
8
+ *.egg-info
9
+
10
+ # Virtual environments
11
+ .venv
12
+ staticfiles
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 David Burke, Burke Software and Consulting
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,10 @@
1
+ Metadata-Version: 2.4
2
+ Name: django-vcache
3
+ Version: 0.1.0
4
+ Summary: A specialized, lightweight Django cache backend for Valkey.
5
+ Author-email: David Burke <david@burkesoftware.com>
6
+ License-File: LICENSE
7
+ Requires-Python: >=3.12
8
+ Requires-Dist: django>=6.0
9
+ Requires-Dist: valkey[libvalkey]
10
+ Requires-Dist: zstd; python_version < '3.14'
File without changes
@@ -0,0 +1,374 @@
1
+ import asyncio
2
+ import pickle
3
+ import zlib
4
+ from functools import wraps
5
+
6
+ import valkey
7
+ import valkey.asyncio as valkey_async
8
+ from django.core.cache.backends.base import BaseCache
9
+
10
+ try:
11
+ import zstd
12
+ except ImportError:
13
+ zstd = None
14
+
15
+
16
+ def ignore_connection_errors(func):
17
+ """
18
+ Decorator that catches connection errors and returns a default value.
19
+ """
20
+ if asyncio.iscoroutinefunction(func):
21
+
22
+ @wraps(func)
23
+ async def async_wrapper(self, *args, **kwargs):
24
+ if not self._ignore_exceptions:
25
+ return await func(self, *args, **kwargs)
26
+ try:
27
+ return await func(self, *args, **kwargs)
28
+ except (valkey.exceptions.ConnectionError, valkey.exceptions.TimeoutError):
29
+ if func.__name__ in ["get", "aget"]:
30
+ return kwargs.get("default") or args[1] if len(args) > 1 else None
31
+ elif func.__name__ in [
32
+ "set",
33
+ "aset",
34
+ "add",
35
+ "aadd",
36
+ "delete",
37
+ "adelete",
38
+ "touch",
39
+ "atouch",
40
+ ]:
41
+ return False
42
+ elif func.__name__ in [
43
+ "incr",
44
+ "aincr",
45
+ "decr",
46
+ "adecr",
47
+ "has_key",
48
+ "ahas_key",
49
+ ]:
50
+ return 0
51
+ return None
52
+
53
+ return async_wrapper
54
+ else:
55
+
56
+ @wraps(func)
57
+ def sync_wrapper(self, *args, **kwargs):
58
+ if not self._ignore_exceptions:
59
+ return func(self, *args, **kwargs)
60
+ try:
61
+ return func(self, *args, **kwargs)
62
+ except (valkey.exceptions.ConnectionError, valkey.exceptions.TimeoutError):
63
+ if func.__name__ in ["get", "aget"]:
64
+ return kwargs.get("default") or args[1] if len(args) > 1 else None
65
+ elif func.__name__ in [
66
+ "set",
67
+ "aset",
68
+ "add",
69
+ "aadd",
70
+ "delete",
71
+ "adelete",
72
+ "touch",
73
+ "atouch",
74
+ ]:
75
+ return False
76
+ elif func.__name__ in [
77
+ "incr",
78
+ "aincr",
79
+ "decr",
80
+ "adecr",
81
+ "has_key",
82
+ "ahas_key",
83
+ ]:
84
+ return 0
85
+ return None
86
+
87
+ return sync_wrapper
88
+
89
+
90
+ class ValkeyCache(BaseCache):
91
+ def __init__(self, alias, params):
92
+ super().__init__(params)
93
+ self._location = params.get("LOCATION", "valkey://valkey:6379/1")
94
+ self._options = params.get("OPTIONS", {})
95
+ self._client = None
96
+ self._async_client = None
97
+ self._ignore_exceptions = self._options.get("IGNORE_EXCEPTIONS", False)
98
+ self._compress_min_len = self._options.get("COMPRESS_MIN_LEN", 1024)
99
+
100
+ if zstd:
101
+ self._compressor = "zstd"
102
+ self._compress = zstd.compress
103
+ self._decompress = zstd.decompress
104
+ self._magic_byte = b"z"
105
+ else:
106
+ self._compressor = "zlib"
107
+ self._compress = zlib.compress
108
+ self._decompress = zlib.decompress
109
+ self._magic_byte = b"\x8f"
110
+
111
+ def _get_valkey_client_kwargs(self):
112
+ kwargs = self._options.copy()
113
+ kwargs.pop("IGNORE_EXCEPTIONS", None)
114
+ kwargs.pop("COMPRESS_MIN_LEN", None)
115
+ return kwargs
116
+
117
+ @property
118
+ def client(self):
119
+ if self._client is None:
120
+ self._client = valkey.from_url(
121
+ self._location, **self._get_valkey_client_kwargs()
122
+ )
123
+ return self._client
124
+
125
+ @property
126
+ def async_client(self):
127
+ if self._async_client is None:
128
+ self._async_client = valkey_async.from_url(
129
+ self._location, **self._get_valkey_client_kwargs()
130
+ )
131
+ return self._async_client
132
+
133
+ def get_raw_client(self, async_client=False):
134
+ """
135
+ Returns the underlying valkey connection object.
136
+ """
137
+ if async_client:
138
+ return self.async_client
139
+ return self.client
140
+
141
+ def _get_expiration_time(self, timeout):
142
+ if timeout is None:
143
+ return None # No expiration
144
+ if timeout == 0:
145
+ return 0 # Expire immediately
146
+ return timeout # Expire in seconds
147
+
148
+ def _encode(self, value):
149
+ pickled_value = pickle.dumps(value, protocol=pickle.HIGHEST_PROTOCOL)
150
+ if self._compress_min_len and len(pickled_value) > self._compress_min_len:
151
+ return self._magic_byte + self._compress(pickled_value)
152
+ return pickled_value
153
+
154
+ def _decode(self, value):
155
+ if value.startswith(b"z"):
156
+ if zstd:
157
+ return pickle.loads(zstd.decompress(value[1:]))
158
+ return None
159
+ if value.startswith(b"\x8f"):
160
+ return pickle.loads(zlib.decompress(value[1:]))
161
+ return pickle.loads(value)
162
+
163
+ # Sync methods
164
+ @ignore_connection_errors
165
+ def get(self, key, default=None, version=None):
166
+ _key = self.make_key(key, version=version)
167
+ value = self.client.get(_key)
168
+ if value is None:
169
+ return default
170
+ return self._decode(value)
171
+
172
+ @ignore_connection_errors
173
+ def set(self, key, value, timeout=None, version=None):
174
+ _key = self.make_key(key, version=version)
175
+ encoded_value = self._encode(value)
176
+ ttl = self._get_expiration_time(timeout)
177
+ if ttl == 0:
178
+ self.client.delete(_key)
179
+ return True
180
+ elif ttl is None:
181
+ return self.client.set(_key, encoded_value)
182
+ else:
183
+ return self.client.set(_key, encoded_value, ex=ttl)
184
+
185
+ @ignore_connection_errors
186
+ def add(self, key, value, timeout=None, version=None):
187
+ _key = self.make_key(key, version=version)
188
+ encoded_value = self._encode(value)
189
+ ttl = self._get_expiration_time(timeout)
190
+ if ttl == 0:
191
+ return False # if timeout is 0, treat as not adding.
192
+ elif ttl is None:
193
+ return self.client.set(_key, encoded_value, nx=True)
194
+ else:
195
+ return self.client.set(_key, encoded_value, ex=ttl, nx=True)
196
+
197
+ @ignore_connection_errors
198
+ def delete(self, key, version=None):
199
+ _key = self.make_key(key, version=version)
200
+ return self.client.delete(_key) > 0
201
+
202
+ def close(self, **kwargs):
203
+ # Clear the client instances so they can be re-lazy-loaded if needed.
204
+ # This allows connections to be returned to the pool without killing the pool.
205
+ self._client = None
206
+ self._async_client = None
207
+
208
+ @ignore_connection_errors
209
+ def touch(self, key, timeout=0, version=None):
210
+ _key = self.make_key(key, version=version)
211
+ ttl = self._get_expiration_time(timeout)
212
+ if ttl == 0:
213
+ return self.client.delete(_key) > 0
214
+ elif ttl is None:
215
+ return self.client.persist(_key)
216
+ else:
217
+ return self.client.expire(_key, ttl)
218
+
219
+ @ignore_connection_errors
220
+ def incr(self, key, delta=1, version=None):
221
+ value = self.get(key, version=version)
222
+ if value is None:
223
+ if self._ignore_exceptions:
224
+ return 0
225
+ raise ValueError("Key '%s' does not exist." % key)
226
+ if not isinstance(value, int):
227
+ raise ValueError("Key '%s' contains a non-integer value." % key)
228
+ new_value = value + delta
229
+ self.set(key, new_value, version=version)
230
+ return new_value
231
+
232
+ @ignore_connection_errors
233
+ def decr(self, key, delta=1, version=None):
234
+ value = self.get(key, version=version)
235
+ if value is None:
236
+ if self._ignore_exceptions:
237
+ return 0
238
+ raise ValueError("Key '%s' does not exist." % key)
239
+ if not isinstance(value, int):
240
+ raise ValueError("Key '%s' contains a non-integer value." % key)
241
+ new_value = value - delta
242
+ self.set(key, new_value, version=version)
243
+ return new_value
244
+
245
+ def lock(
246
+ self,
247
+ key,
248
+ version=None,
249
+ timeout=None,
250
+ sleep=0.1,
251
+ blocking=True,
252
+ blocking_timeout=None,
253
+ thread_local=True,
254
+ ):
255
+ _key = self.make_key(key, version=version)
256
+ return self.client.lock(
257
+ _key,
258
+ timeout=timeout,
259
+ sleep=sleep,
260
+ blocking=blocking,
261
+ blocking_timeout=blocking_timeout,
262
+ thread_local=thread_local,
263
+ )
264
+
265
+ @ignore_connection_errors
266
+ def has_key(self, key, version=None):
267
+ _key = self.make_key(key, version=version)
268
+ return self.client.exists(_key)
269
+
270
+ # Async methods
271
+ @ignore_connection_errors
272
+ async def aget(self, key, default=None, version=None):
273
+ _key = self.make_key(key, version=version)
274
+ value = await self.async_client.get(_key)
275
+ if value is None:
276
+ return default
277
+ return self._decode(value)
278
+
279
+ @ignore_connection_errors
280
+ async def aset(self, key, value, timeout=None, version=None):
281
+ _key = self.make_key(key, version=version)
282
+ encoded_value = self._encode(value)
283
+ ttl = self._get_expiration_time(timeout)
284
+ if ttl == 0:
285
+ await self.async_client.delete(_key)
286
+ return True
287
+ elif ttl is None:
288
+ return await self.async_client.set(_key, encoded_value)
289
+ else:
290
+ return await self.async_client.set(_key, encoded_value, ex=ttl)
291
+
292
+ @ignore_connection_errors
293
+ async def aadd(self, key, value, timeout=None, version=None):
294
+ _key = self.make_key(key, version=version)
295
+ encoded_value = self._encode(value)
296
+ ttl = self._get_expiration_time(timeout)
297
+ if ttl == 0:
298
+ return False
299
+ elif ttl is None:
300
+ return await self.async_client.set(_key, encoded_value, nx=True)
301
+ else:
302
+ return await self.async_client.set(_key, encoded_value, ex=ttl, nx=True)
303
+
304
+ @ignore_connection_errors
305
+ async def adelete(self, key, version=None):
306
+ _key = self.make_key(key, version=version)
307
+ return await self.async_client.delete(_key) > 0
308
+
309
+ async def aclose(self, **kwargs):
310
+ # Similar to sync close, clear the client instances for re-lazy-loading.
311
+ self._client = None
312
+ self._async_client = None
313
+
314
+ @ignore_connection_errors
315
+ async def atouch(self, key, timeout=0, version=None):
316
+ _key = self.make_key(key, version=version)
317
+ ttl = self._get_expiration_time(timeout)
318
+ if ttl == 0:
319
+ return await self.async_client.delete(_key) > 0
320
+ elif ttl is None:
321
+ return await self.async_client.persist(_key)
322
+ else:
323
+ return await self.async_client.expire(_key, ttl)
324
+
325
+ @ignore_connection_errors
326
+ async def aincr(self, key, delta=1, version=None):
327
+ value = await self.aget(key, version=version)
328
+ if value is None:
329
+ if self._ignore_exceptions:
330
+ return 0
331
+ raise ValueError("Key '%s' does not exist." % key)
332
+ if not isinstance(value, int):
333
+ raise ValueError("Key '%s' contains a non-integer value." % key)
334
+ new_value = value + delta
335
+ await self.aset(key, new_value, version=version)
336
+ return new_value
337
+
338
+ @ignore_connection_errors
339
+ async def adecr(self, key, delta=1, version=None):
340
+ value = await self.aget(key, version=version)
341
+ if value is None:
342
+ if self._ignore_exceptions:
343
+ return 0
344
+ raise ValueError("Key '%s' does not exist." % key)
345
+ if not isinstance(value, int):
346
+ raise ValueError("Key '%s' contains a non-integer value." % key)
347
+ new_value = value - delta
348
+ await self.aset(key, new_value, version=version)
349
+ return new_value
350
+
351
+ def alock(
352
+ self,
353
+ key,
354
+ version=None,
355
+ timeout=None,
356
+ sleep=0.1,
357
+ blocking=True,
358
+ blocking_timeout=None,
359
+ thread_local=True,
360
+ ):
361
+ _key = self.make_key(key, version=version)
362
+ return self.async_client.lock(
363
+ _key,
364
+ timeout=timeout,
365
+ sleep=sleep,
366
+ blocking=blocking,
367
+ blocking_timeout=blocking_timeout,
368
+ thread_local=thread_local,
369
+ )
370
+
371
+ @ignore_connection_errors
372
+ async def ahas_key(self, key, version=None):
373
+ _key = self.make_key(key, version=version)
374
+ return await self.async_client.exists(_key)
File without changes
@@ -0,0 +1,48 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "django-vcache"
7
+ version = "0.1.0"
8
+ description = "A specialized, lightweight Django cache backend for Valkey."
9
+ authors = [{ name = "David Burke", email = "david@burkesoftware.com" }]
10
+ requires-python = ">=3.12"
11
+ dependencies = [
12
+ "django>=6.0",
13
+ "valkey[libvalkey]",
14
+ "zstd; python_version < '3.14'",
15
+ ]
16
+
17
+ [dependency-groups]
18
+ dev = [
19
+ "granian[reload]",
20
+ "ruff",
21
+ ]
22
+
23
+ [tool.hatch.build]
24
+ packages = ["django_vcache"]
25
+ include = [
26
+ "django_vcache",
27
+ "README.md",
28
+ "LICENSE", # Assuming a LICENSE file might be added later
29
+ ]
30
+ exclude = [
31
+ ".*", # Exclude all dotfiles/folders globally by default
32
+ "dist",
33
+ "*.egg-info",
34
+ "uv.lock",
35
+ "compose.yml",
36
+ "Dockerfile",
37
+ "main.py",
38
+ "manage.py",
39
+ "mypy.ini",
40
+ ".gitlab-ci.yml",
41
+ "sample", # sample app, not part of library
42
+ ]
43
+
44
+ [tool.ruff]
45
+ target-version = "py312"
46
+
47
+ [tool.ruff.lint]
48
+ select = ["E", "F", "I"]