Skip to content

Commit 949cafc

Browse files
committed
3.1.0 - Added SqliteCache + AsyncSqliteCache, plus minor fixes in privex.helpers.plugin
- **privex.helpers.cache** - Added `SqliteCache` module, containing the synchronous cache adapter `SqliteCache` which uses an Sqlite3 database for persistent cache storage without the need for any extra system service (unlike the memcached / redis adapters) - Added `asyncx.AsyncSqliteCache` module, containing the AsyncIO cache adapter `AsyncSqliteCache`, which is simply an AsyncIO version of `SqliteCache` using the `aiosqlite` library. - **NOTE:** Due to the file-based nature of SQLite3, combined with the fact write operations generally result in the database being locked until the write is completed - use of the AsyncIO SQLite3 cache adapter only *slightly* improves performance, due to the blocking single-user nature of SQLite3. - Added `post_deps` module, short for **post-init dependencies**. This module contains functions and classes which are known to have (or have a high risk of) recursive import conflicts - e.g. the new SQLite caching uses the `privex-db` package, and the `privex-db` package imports various things from `privex.helpers` causing a recursive import issue if we load `privex.db` within a class that's auto-loaded in an `__init__.py` file. The nature of this module means that none of it's contents are auto-loaded / aliased using `__init__.py` module constructor files. This shouldn't be a problem for most people though, as the functions/classes etc. within the module are primarily only useful for certain cache adapter classes, rather than intended for use by the users of `privex-helpers` (though there's nothing stopping you from importing things from `post_deps` in your own project). - `sqlite_cache_set_dbfolder` and `sqlite_cache_set_dbname` are two module level functions that are intended for use by users. These functions allow you to quickly override the `DEFAULT_DB_FOLDER` and/or `DEFAULT_DB_NAME` dynamically for both SqliteCacheManager and AsyncSqliteCacheManager. - `SqliteCacheResult` is a namedtuple that represents a row returned when querying the `pvcache` table within an SQLite cache database - `_SQManagerBase` is a mix-in class used by both `SqliteCacheManager` and `AsyncSqliteCacheManager`, containing code which is used by both classes. - `SqliteCacheManager` is a child class of `SqliteWrapper`, designed to provide easier interaction with an SQLite3 cache database, including automatic creation of the database file, and the `pvcache` table within it. This class is intended for use by `privex.helpers.cache.SqliteCache` - `AsyncSqliteCacheManager` is a child class of `SqliteAsyncWrapper`, and is simply an AsyncIO version of `SqliteCacheManager`. This class is intended for use by `privex.helpers.cache.asyncx.AsyncSqliteCache` - **privex.helpers.plugins** - Added `HAS_PRIVEX_DB` attribute, for tracking whether the `privex-db` library is available for use. - Added `clean_threadstore` to `__all__` - seems I forgot to add it previously. - **privex.helpers.settings** - Added `SQLITE_APP_DB_NAME` which can also be controlled via an env var of the same name - allowing you to adjust the base of the default DB filename for the SQLite3 cache adapters. - Added `SQLITE_APP_DB_FOLDER` (can also be controlled via env) - similar to the DB_NAME attribute, controls the default base folder used by the SQLite3 cache adapters.
1 parent 8fc568c commit 949cafc

File tree

16 files changed

+985
-6
lines changed

16 files changed

+985
-6
lines changed

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,4 +17,6 @@ dist
1717
/dev_*.py
1818
/*.sqlite*
1919
/*.db
20+
/privex/db
21+
/out
2022

CHANGELOG.md

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,68 @@
1+
-----------------------------------------------------------------------------------------------------------------------
2+
3+
3.1.0 - Added SqliteCache + AsyncSqliteCache, plus minor fixes in privex.helpers.plugin
4+
====================================================================================================================
5+
6+
-----------------------------------------------------------------------------------------------------------------------
7+
8+
Author: Chris (Someguy123)
9+
Date: Wed Oct 7 11:36 2020 +0000
10+
11+
- **privex.helpers.cache**
12+
- Added `SqliteCache` module, containing the synchronous cache adapter `SqliteCache` which uses an Sqlite3 database for
13+
persistent cache storage without the need for any extra system service (unlike the memcached / redis adapters)
14+
15+
- Added `asyncx.AsyncSqliteCache` module, containing the AsyncIO cache adapter `AsyncSqliteCache`, which is simply an
16+
AsyncIO version of `SqliteCache` using the `aiosqlite` library.
17+
- **NOTE:** Due to the file-based nature of SQLite3, combined with the fact write operations generally result in the
18+
database being locked until the write is completed - use of the AsyncIO SQLite3 cache adapter only *slightly*
19+
improves performance, due to the blocking single-user nature of SQLite3.
20+
21+
- Added `post_deps` module, short for **post-init dependencies**. This module contains functions and classes which are
22+
known to have (or have a high risk of) recursive import conflicts - e.g. the new SQLite caching uses the `privex-db`
23+
package, and the `privex-db` package imports various things from `privex.helpers` causing a recursive import issue if we
24+
load `privex.db` within a class that's auto-loaded in an `__init__.py` file.
25+
26+
The nature of this module means that none of it's contents are auto-loaded / aliased using `__init__.py` module constructor
27+
files. This shouldn't be a problem for most people though, as the functions/classes etc. within the module are primarily
28+
only useful for certain cache adapter classes, rather than intended for use by the users of `privex-helpers`
29+
(though there's nothing stopping you from importing things from `post_deps` in your own project).
30+
31+
- `sqlite_cache_set_dbfolder` and `sqlite_cache_set_dbname` are two module level functions that are intended for use
32+
by users. These functions allow you to quickly override the `DEFAULT_DB_FOLDER` and/or `DEFAULT_DB_NAME` dynamically
33+
for both SqliteCacheManager and AsyncSqliteCacheManager.
34+
- `SqliteCacheResult` is a namedtuple that represents a row returned when querying the `pvcache` table within
35+
an SQLite cache database
36+
- `_SQManagerBase` is a mix-in class used by both `SqliteCacheManager` and `AsyncSqliteCacheManager`, containing code
37+
which is used by both classes.
38+
- `SqliteCacheManager` is a child class of `SqliteWrapper`, designed to provide easier interaction with an SQLite3
39+
cache database, including automatic creation of the database file, and the `pvcache` table within it. This class is
40+
intended for use by `privex.helpers.cache.SqliteCache`
41+
- `AsyncSqliteCacheManager` is a child class of `SqliteAsyncWrapper`, and is simply an AsyncIO
42+
version of `SqliteCacheManager`. This class is intended for use by `privex.helpers.cache.asyncx.AsyncSqliteCache`
43+
44+
- **privex.helpers.plugins**
45+
- Added `HAS_PRIVEX_DB` attribute, for tracking whether the `privex-db` library is available for use.
46+
- Added `clean_threadstore` to `__all__` - seems I forgot to add it previously.
47+
48+
- **privex.helpers.settings**
49+
- Added `SQLITE_APP_DB_NAME` which can also be controlled via an env var of the same name - allowing you to adjust the
50+
base of the default DB filename for the SQLite3 cache adapters.
51+
- Added `SQLITE_APP_DB_FOLDER` (can also be controlled via env) - similar to the DB_NAME attribute, controls the
52+
default base folder used by the SQLite3 cache adapters.
53+
54+
55+
156
-----------------------------------------------------------------------------------------------------------------------
257

358
3.0.0 - Overhauled net module, new object cleaner for easier serialisation, improved class generation/mocking + more
459
====================================================================================================================
560

661
-----------------------------------------------------------------------------------------------------------------------
762

63+
Author: Chris (Someguy123)
64+
Date: Sat Sep 26 04:29 2020 +0000
65+
866
- `privex.helpers.common`
967
- Added `strip_null` - very simple helper function to strip both `\00` and white space
1068
from a string - with 2 cycles for good measure.

extras/cache.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,6 @@ redis>=3.3
22
aioredis>=1.3
33
hiredis
44
aiomcache>=0.6
5+
privex-db>=0.9.2
6+
aiosqlite>=0.12
57

extras/dev.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,4 @@
22
-r extras/django.txt
33
-r extras/docs.txt
44
-r extras/tests.txt
5+
-r extras/dev_general.txt

extras/dev_general.txt

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
# For generating stub files (.pyi) + some other helpful dev features
2+
mypy>=0.780
3+
4+
# (IPython) Provides a much nicer Python REPL console, especially in PyCharm
5+
jupyter

privex/helpers/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -148,7 +148,7 @@ def _setup_logging(level=logging.WARNING):
148148
log = _setup_logging()
149149
name = 'helpers'
150150

151-
VERSION = '3.0.0'
151+
VERSION = '3.1.0'
152152

153153

154154

Lines changed: 213 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,213 @@
1+
import pickle
2+
import time
3+
import logging
4+
from os import makedirs
5+
from os.path import dirname, exists, isabs, join
6+
from typing import Any, Optional
7+
8+
from privex.helpers.cache.CacheAdapter import CacheAdapter
9+
from privex.helpers.exceptions import CacheNotFound
10+
from privex.helpers.common import empty, empty_if, is_true
11+
from privex.helpers import settings
12+
13+
14+
log = logging.getLogger(__name__)
15+
16+
17+
def _cache_result_expired(res, _auto_purge=True) -> bool:
18+
if empty(res): return False
19+
if empty(res.expires_at, zero=True) or not _auto_purge: return False
20+
return float(res.expires_at) <= time.time()
21+
22+
23+
class SqliteCache(CacheAdapter):
24+
"""
25+
An SQLite3 backed implementation of :class:`.CacheAdapter`. Creates and uses a semi-global Sqlite instance via
26+
:py:mod:`privex.helpers.plugin` by default.
27+
28+
To allow for a wide variety of Python objects to be safely stored and retrieved from Sqlite, this class
29+
uses the :py:mod:`pickle` module for serialising + un-serialising values to/from Sqlite.
30+
31+
**Basic Usage**::
32+
33+
>>> from privex.helpers.cache import SqliteCache
34+
>>> rc = SqliteCache()
35+
>>> rc.set('hello', 'world')
36+
>>> rc['hello']
37+
'world'
38+
39+
40+
**Disabling Pickling**
41+
42+
In some cases, you may need interoperable caching with other languages. The :py:mod:`pickle` serialisation
43+
technique is extremely specific to Python and is largely unsupported outside of Python. Thus if you need
44+
to share Sqlite cache data with applications in other languages, then you must disable pickling.
45+
46+
**WARNING:** If you disable pickling, then you must perform your own serialisation + de-serialization on
47+
complex objects such as ``dict``, ``list``, ``Decimal``, or arbitrary classes/functions after getting
48+
or setting cache keys.
49+
50+
**Disabling Pickle per instance**
51+
52+
Pass ``use_pickle=False`` to the constructor, or access the attribute directly to disable pickling for a
53+
single instance of SqliteCache (not globally)::
54+
55+
>>> rc = SqliteCache(use_pickle=False) # Opt 1. Disable pickle in constructor
56+
>>> rc.use_pickle = False # Opt 2. Disable pickle on an existing instance
57+
58+
59+
**Disabling Pickle by default on any new instances**
60+
61+
Change the static attribute :py:attr:`.pickle_default` to ``False`` to disable the use of pickle by default
62+
across any new instances of SqliteCache::
63+
64+
>>> SqliteCache.pickle_default = False
65+
66+
67+
68+
"""
69+
70+
pickle_default: bool = True
71+
"""
72+
Change this to ``False`` to disable the use of :py:mod:`pickle` by default for any new instances
73+
of this class.
74+
"""
75+
76+
use_pickle: bool
77+
"""If ``True``, will use :py:mod:`pickle` for serializing objects before inserting into Sqlite, and
78+
un-serialising objects retrieved from Sqlite3. This attribute is set in :py:meth:`.__init__`.
79+
80+
Change this to ``False`` to disable the use of :py:mod:`pickle` - instead values will be passed to / returned
81+
from Sqlite3 as-is, with no serialisation (this may require you to manually serialize complex types such
82+
as ``dict`` and ``Decimal`` before insertion, and un-serialise after retrieval).
83+
"""
84+
85+
last_purged_expired: Optional[int] = None
86+
87+
def __init__(self, db_file: str = None, memory_persist=False, use_pickle: bool = None, connection_kwargs: dict = None, *args, **kwargs):
88+
"""
89+
:class:`.SqliteCache` uses an auto-generated database filename / path by default, based on the name of the currently running
90+
script ( retrieved from ``sys.argv[0]`` ), allowing for persistent caching - without any manual configuration of the adapter,
91+
nor the requirement for any running background services such as ``redis`` / ``memcached``.
92+
93+
94+
95+
:param str db_file: (Optional) Name of / path to Sqlite3 database file to create/use for the cache.
96+
97+
:param bool memory_persist: Use a shared in-memory database, which can be accessed by other instances of this class (in this
98+
process) - which is cleared after all memory connections are closed.
99+
Shortcut for ``db_file='file::memory:?cache=shared'``
100+
101+
:param bool use_pickle: (Default: ``True``) Use the built-in ``pickle`` to serialise values before
102+
storing in Sqlite3, and un-serialise when loading from Sqlite3
103+
104+
:param dict connection_kwargs: (Optional) Additional / overriding kwargs to pass to :meth:`sqlite3.connect` when
105+
:class:`.SqliteCacheManager` initialises it's sqlite3 connection.
106+
107+
:keyword int purge_every: (Default: 300) Expired + abandoned cache records are purged using the DB manager method
108+
:meth:`.SqliteCacheManager.purge_expired` during :meth:`.get` / :meth:`.set` calls. To avoid
109+
performance issues, the actual :meth:`.SqliteCacheManager.purge_expired` method is only called
110+
if at least ``purge_every`` seconds have passed since the last purge was
111+
triggered ( :attr:`.last_purged_expired` )
112+
113+
"""
114+
from privex.helpers.cache.post_deps import SqliteCacheManager
115+
super().__init__(*args, **kwargs)
116+
self.db_file: str = empty_if(db_file, SqliteCacheManager.DEFAULT_DB)
117+
self.db_folder = None
118+
if ':memory:' not in self.db_file:
119+
if not isabs(self.db_file): self.db_file = join(SqliteCacheManager.DEFAULT_DB_FOLDER, self.db_file)
120+
self.db_folder = dirname(self.db_file)
121+
if not exists(self.db_folder):
122+
log.debug("Folder for database doesn't exist. Creating: %s", self.db_folder)
123+
makedirs(self.db_folder)
124+
self.connection_kwargs = empty_if(connection_kwargs, {}, itr=True)
125+
self.memory_persist = is_true(memory_persist)
126+
self._wrapper = None
127+
self.purge_every = kwargs.get('purge_every', 300)
128+
self.use_pickle = self.pickle_default if use_pickle is None else use_pickle
129+
130+
@property
131+
def purge_due(self) -> bool:
132+
lpe = SqliteCache.last_purged_expired
133+
return empty(lpe, zero=True) or (time.time() - lpe) >= self.purge_every
134+
135+
@property
136+
def wrapper(self):
137+
if not self._wrapper:
138+
from privex.helpers.cache.post_deps import SqliteCacheManager
139+
self._wrapper = SqliteCacheManager(
140+
db=self.db_file, connection_kwargs=self.connection_kwargs, memory_persist=self.memory_persist
141+
)
142+
return self._wrapper
143+
144+
def purge_expired(self, force=False) -> Optional[int]:
145+
if self.purge_due or force:
146+
log.debug("%s - Expired items purge is due (or force is True). Purging expired cache items...", self.__class__.__name__)
147+
res = self.wrapper.purge_expired()
148+
log.debug("Finished purging expired items. Total expired cache items deleted: %s", res)
149+
SqliteCache.last_purged_expired = time.time()
150+
return res
151+
return None
152+
153+
def get(self, key: str, default: Any = None, fail: bool = False, _auto_purge=True) -> Any:
154+
key = str(key)
155+
_not_found_msg = f'Cache key "{key}" was not found.'
156+
if _auto_purge: self.purge_expired()
157+
res = self.wrapper.find_cache_key(key)
158+
if _cache_result_expired(res, _auto_purge=_auto_purge):
159+
log.debug("Caller attempted to retrieve expired key '%s', but _auto_purge is True - auto-removing expired key %s", key, key)
160+
self.remove(key)
161+
res = None
162+
_not_found_msg += ' (key was expired - auto-removed)'
163+
if empty(res):
164+
if fail: raise CacheNotFound(_not_found_msg)
165+
return default
166+
return pickle.loads(res.value) if self.use_pickle else res.value
167+
168+
def set(self, key: str, value: Any, timeout: Optional[int] = settings.DEFAULT_CACHE_TIMEOUT, _auto_purge=True):
169+
if _auto_purge: self.purge_expired()
170+
v = pickle.dumps(value) if self.use_pickle else value
171+
return self.wrapper.set_cache_key(str(key), v, expires_secs=timeout)
172+
173+
def remove(self, *key: str) -> bool:
174+
removed = 0
175+
for k in key:
176+
k = str(k)
177+
try:
178+
self.get(k, fail=True, _auto_purge=False)
179+
self.wrapper.delete_cache_key(k)
180+
removed += 1
181+
except CacheNotFound:
182+
pass
183+
184+
return removed == len(key)
185+
186+
def update_timeout(self, key: str, timeout: int = settings.DEFAULT_CACHE_TIMEOUT) -> Any:
187+
key, timeout = str(key), int(timeout)
188+
v = self.get(key=key, fail=True, _auto_purge=False)
189+
self.set(key=key, value=v, timeout=timeout, _auto_purge=False)
190+
return v
191+
192+
def connect(self, db=None, *args, connection_kwargs=None, memory_persist=None, **kwargs):
193+
c_kwargs = dict(
194+
connection_kwargs=empty_if(connection_kwargs, self.connection_kwargs),
195+
memory_persist=empty_if(memory_persist, self.memory_persist)
196+
)
197+
c_kwargs = {**c_kwargs, **kwargs}
198+
from privex.helpers.cache.post_deps import SqliteCacheManager
199+
self._wrapper = SqliteCacheManager(empty_if(db, self.db_file), *args, **c_kwargs)
200+
return self._wrapper
201+
202+
# noinspection PyProtectedMember
203+
def close(self):
204+
cls_name = self.__class__.__name__
205+
if self._wrapper is not None:
206+
log.debug("Closing Synchronous Sqlite3 instance %s._wrapper", cls_name)
207+
if self._wrapper._conn:
208+
try:
209+
self._wrapper._conn.close()
210+
except Exception:
211+
log.exception("Unexpected error while closing %s._wrapper._conn", cls_name)
212+
self._wrapper._conn = None
213+
self._wrapper = None

privex/helpers/cache/__init__.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -231,6 +231,7 @@
231231
import logging
232232
from inspect import isclass
233233

234+
from privex.helpers import plugin
234235
from privex.helpers.common import empty_if, LayeredContext
235236
from privex.helpers.asyncx import awaitable, await_if_needed, loop_run
236237

@@ -240,6 +241,20 @@
240241
from privex.helpers.cache.CacheAdapter import CacheAdapter
241242
from privex.helpers.cache.MemoryCache import MemoryCache
242243

244+
245+
if plugin.HAS_PRIVEX_DB in [True, None]:
246+
try:
247+
from privex.helpers.cache.SqliteCache import SqliteCache
248+
plugin.HAS_PRIVEX_DB = True
249+
except ImportError:
250+
plugin.HAS_PRIVEX_DB = False
251+
log.debug(
252+
"[%s] Failed to import %s from %s (missing package 'privex-db' maybe?)", __name__, 'SqliteCache', f'{__name__}.SqliteCache'
253+
)
254+
else:
255+
log.debug("[%s] Not attempting to import %s from %s as plugin check var '%s' is False.",
256+
__name__, 'SqliteCache', f'{__name__}.SqliteCache', 'HAS_PRIVEX_DB')
257+
243258
try:
244259
from privex.helpers.cache.RedisCache import RedisCache
245260
except ImportError:

0 commit comments

Comments
 (0)