From 0f0daa3c0fc7464df65efbf250ff66c2f84735db Mon Sep 17 00:00:00 2001 From: August Vi Date: Tue, 10 Nov 2026 23:51:11 -0800 Subject: [PATCH] Let Cache take a dbname argument for its SQLite file name instead of the global cache.db. --- CHANGELOG.md | 6 ++ diskcache_community/core.py | 36 +++++++-- diskcache_community/core.pyi | 11 ++- diskcache_community/fanout.py | 3 + tests/test_core.py | 146 ++++++++++++++++++++++++++++++++++ tests/test_fanout.py | 7 ++ tests/test_readonly.py | 33 ++++++++ tests/typecheck_stubs.py | 4 + 8 files changed, 236 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0797604..834b191 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ diskcache-community forked from DiskCache at upstream commit ebfa37c (3 March 2024), two commits after the 5.6.3 release. Each entry names the pull request in this repository. Imported upstream pull requests are referenced by number, and their commits keep their original authors in the git history. +## Unreleased + +### Changes + +- `Cache` takes a keyword-only `dbname` argument, the file name of the SQLite database in the cache directory (default `None`, meaning `cache.db`) (PR #53). + ## 5.8.4 (2026-11-05) Extends `SafeDisk.with_numpy()` to numpy data in keys and to more numpy types in values, and fixes the data stored for short-lived array views. The `cache.db` schema and the file layout are unchanged, and entries 5.8.3 could store are written byte for byte as before. diff --git a/diskcache_community/core.py b/diskcache_community/core.py index 98d2cac..0c74bfd 100644 --- a/diskcache_community/core.py +++ b/diskcache_community/core.py @@ -1728,7 +1728,7 @@ def _after_fork_in_child(): ) -class Cache: +class Cache: # pylint: disable=too-many-instance-attributes """Disk and file backed cache.""" def __init__( @@ -1738,6 +1738,7 @@ def __init__( disk=None, *, read_only=False, + dbname=None, **settings ): """Initialize cache instance. @@ -1747,6 +1748,8 @@ def __init__( :param disk: Disk type or subclass for serialization (default None, the type :func:`default_disk` returns) :param bool read_only: open the cache read-only (default False) + :param str dbname: file name of the SQLite database in `directory` + (default None, meaning ``cache.db``) :param settings: any of DEFAULT_SETTINGS The cache directory, any missing parents and the subdirectories that @@ -1773,6 +1776,15 @@ def __init__( 'disk must subclass diskcache_community.Disk' ) from None + if dbname is None: + dbname = DBNAME + elif ( + not isinstance(dbname, str) + or dbname in ('', os.curdir, os.pardir) + or op.basename(dbname) != dbname + ): + raise ValueError('dbname must be a file name, not %r' % (dbname,)) + if directory is None: directory = tempfile.mkdtemp(prefix='diskcache-') directory = str(directory) @@ -1780,6 +1792,7 @@ def __init__( directory = op.expandvars(directory) self._directory = directory + self._dbname = dbname self._timeout = 0 # Manually handle retries during initialization. self._local = threading.local() self._cons = _Connections() @@ -1788,7 +1801,7 @@ def __init__( finalizer = weakref.finalize(self, self._cons.close_all) finalizer.atexit = False - self._read_only = self._resolve_read_only(directory, read_only) + self._read_only = self._resolve_read_only(directory, read_only, dbname) if not op.isdir(directory): try: @@ -1866,11 +1879,11 @@ def __init__( self._sql # pylint: disable=pointless-statement @staticmethod - def _resolve_read_only(directory, read_only): + def _resolve_read_only(directory, read_only, dbname): # A database that exists but cannot be written, or whose directory # cannot be written, is opened read-only. Opening read-only on # purpose needs an existing database, since nothing is created. - db_path = op.join(directory, DBNAME) + db_path = op.join(directory, dbname) db_exists = op.isfile(db_path) if read_only and not db_exists: @@ -2005,7 +2018,7 @@ def _connect(self): # it, once that thread has exited. Each connection is still used by # one thread at a time, which is all SQLite's multi-thread mode asks. - path = op.join(self._directory, DBNAME) + path = op.join(self._directory, self._dbname) if self._read_only: # mode=ro refuses writes at the SQLite level. A reader of a WAL @@ -3711,7 +3724,7 @@ def check(self, fix=False, retry=False): error = set(paths) - filenames for full_path in error: - if DBNAME in full_path: + if self._dbname in full_path: continue message = 'unknown file: %s' % full_path @@ -4154,10 +4167,17 @@ def __len__(self): return self.reset('count') def __getstate__(self): - return (self.directory, self.timeout, type(self.disk)) + state = (self.directory, self.timeout, type(self.disk)) + if self._dbname != DBNAME: + state += ({'dbname': self._dbname},) + return state def __setstate__(self, state): - self.__init__(*state) + named = len(state) > 3 and isinstance(state[3], dict) + if named and list(state[3]) == ['dbname']: + self.__init__(*state[:3], *state[4:], **state[3]) + else: + self.__init__(*state) def reset(self, key, value=ENOVAL, update=True): """Reset `key` and `value` item from Settings table. diff --git a/diskcache_community/core.pyi b/diskcache_community/core.pyi index a607ba3..a98a709 100644 --- a/diskcache_community/core.pyi +++ b/diskcache_community/core.pyi @@ -228,6 +228,7 @@ class Cache(BaseCache): disk: type[Disk] | None = ..., *, read_only: bool = ..., + dbname: str | None = ..., **settings: Unpack[InitSettings], ) -> None: ... @overload @@ -238,6 +239,7 @@ class Cache(BaseCache): disk: type[Disk] | None = ..., *, read_only: bool = ..., + dbname: str | None = ..., # FIXME # https://peps.python.org/pep-0728/ # diskcache allow "disk_*" args @@ -332,5 +334,10 @@ class Cache(BaseCache): def iterkeys( self, reverse: bool = ..., tag: Tag | Constant[EnovalType] = ... ) -> Generator[Any, None, None]: ... - def __getstate__(self) -> tuple[str, int, type[Disk]]: ... - def __setstate__(self, state: tuple[str, int, type[Disk]]) -> None: ... + def __getstate__( + self, + ) -> ( + tuple[str, int, type[Disk]] + | tuple[str, int, type[Disk], dict[str, str]] + ): ... + def __setstate__(self, state: tuple[Any, ...]) -> None: ... diff --git a/diskcache_community/fanout.py b/diskcache_community/fanout.py index 7bbc33c..81d27ef 100644 --- a/diskcache_community/fanout.py +++ b/diskcache_community/fanout.py @@ -49,6 +49,9 @@ def __init__( if disk is None: disk = default_disk() + if 'dbname' in settings: + raise TypeError('FanoutCache does not take dbname') + if directory is None: directory = tempfile.mkdtemp(prefix='diskcache-') directory = str(directory) diff --git a/tests/test_core.py b/tests/test_core.py index 1b42e7d..c0b9380 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -1,6 +1,7 @@ """Test diskcache_community.core.Cache.""" import _thread +import copy import errno import gc import hashlib @@ -2375,3 +2376,148 @@ def test_get_reads_only_value_columns_without_metadata(cache): selects = [text for text in statements if text.startswith('SELECT')] assert selects[0].startswith('SELECT mode, filename, value FROM') assert selects[1].startswith('SELECT rowid, expire_time, tag,') + + +def _database_names(directory): + return sorted( + name + for name in os.listdir(directory) + if not name.endswith(('-wal', '-shm')) + ) + + +def test_dbname(tmp_path): + with dc.Cache(tmp_path, dbname='credentials.db') as cache: + cache['key'] = 'value' + + assert _database_names(tmp_path) == ['credentials.db'] + + with dc.Cache(tmp_path, dbname='credentials.db') as cache: + assert cache['key'] == 'value' + with warnings.catch_warnings(): + warnings.simplefilter('always') + assert len(cache.check()) == 0 + + with dc.Cache(tmp_path) as cache: + assert 'key' not in cache + + +@pytest.mark.parametrize( + 'dbname', ['', '.', '..', op.join('sub', 'cache.db'), b'cache.db', 1] +) +def test_dbname_must_be_file_name(tmp_path, dbname): + with pytest.raises(ValueError): + dc.Cache(tmp_path, dbname=dbname) + + assert os.listdir(tmp_path) == [] + + +def test_dbname_pickle_and_copy(tmp_path): + with dc.Cache(tmp_path, dbname='other.db') as cache: + cache['key'] = 1 + assert cache.__getstate__()[3:] == ({'dbname': 'other.db'},) + others = [ + pickle.loads(pickle.dumps(cache)), + copy.copy(cache), + copy.deepcopy(cache), + ] + + for other in others: + with other: + assert other['key'] == 1 + + assert _database_names(tmp_path) == ['other.db'] + + +def test_default_dbname_pickle_state(tmp_path): + for dbname in (None, dc.core.DBNAME): + with dc.Cache(tmp_path, dbname=dbname) as cache: + assert cache.__getstate__() == ( + cache.directory, + cache.timeout, + type(cache.disk), + ) + + assert _database_names(tmp_path) == [dc.core.DBNAME] + + +def test_dbname_follows_module_constant(tmp_path, monkeypatch): + monkeypatch.setattr(dc.core, 'DBNAME', 'patched.db') + + with dc.Cache(tmp_path) as cache: + cache['key'] = 1 + assert len(cache.__getstate__()) == 3 + other = pickle.loads(pickle.dumps(cache)) + + with other: + assert other['key'] == 1 + + assert _database_names(tmp_path) == ['patched.db'] + + +class _AppendedStateCache(dc.Cache): + # Appends its own item to the pickle state and inherits __setstate__. + def __init__( + self, directory=None, timeout=60, disk=None, kind=None, **settings + ): + super().__init__(directory, timeout, disk, **settings) + self.kind = kind + + def __getstate__(self): + return (*super().__getstate__(), self.kind) + + +@pytest.mark.parametrize('kind', ['hashes', {'a': 1}]) +@pytest.mark.parametrize('dbname', [None, 'other.db']) +def test_subclass_appended_pickle_state(tmp_path, kind, dbname): + with _AppendedStateCache(tmp_path, kind=kind, dbname=dbname) as cache: + cache['key'] = 1 + other = pickle.loads(pickle.dumps(cache)) + + with other: + assert other.kind == kind + assert other['key'] == 1 + + assert _database_names(tmp_path) == [dbname or dc.core.DBNAME] + + +class _FixedInitCache(dc.Cache): + def __init__(self, directory=None, timeout=60, disk=None): + super().__init__(directory, timeout, disk) + + +def test_subclass_fixed_init_pickle(tmp_path): + with _FixedInitCache(tmp_path) as cache: + cache['key'] = 1 + other = pickle.loads(pickle.dumps(cache)) + + with other: + assert other['key'] == 1 + + +def test_check_fix_keeps_named_database(tmp_path): + with dc.Cache(tmp_path, dbname='other.db') as cache: + cache['key'] = 1 + (tmp_path / 'stray.txt').write_text('x') + with warnings.catch_warnings(): + warnings.simplefilter('always') + warns = cache.check(fix=True) + assert cache['key'] == 1 + + assert [str(warn.message) for warn in warns] == [ + 'unknown file: %s' % op.join(str(tmp_path), 'stray.txt') + ] + assert _database_names(tmp_path) == ['other.db'] + + +def test_check_fix_keeps_nested_cache_database(tmp_path): + with dc.Cache(tmp_path / 'nested') as nested: + nested['key'] = 1 + + with dc.Cache(tmp_path) as cache: + with warnings.catch_warnings(): + warnings.simplefilter('always') + assert len(cache.check(fix=True)) == 0 + + with dc.Cache(tmp_path / 'nested') as nested: + assert nested['key'] == 1 diff --git a/tests/test_fanout.py b/tests/test_fanout.py index fac3b26..91d7466 100644 --- a/tests/test_fanout.py +++ b/tests/test_fanout.py @@ -787,3 +787,10 @@ def test_private_directories(): finally: os.umask(umask) shutil.rmtree(root, ignore_errors=True) + + +def test_dbname_is_refused(tmp_path): + with pytest.raises(TypeError): + dc.FanoutCache(tmp_path, shards=2, dbname='other.db') + + assert os.listdir(tmp_path) == [] diff --git a/tests/test_readonly.py b/tests/test_readonly.py index da20886..e40ad13 100644 --- a/tests/test_readonly.py +++ b/tests/test_readonly.py @@ -408,3 +408,36 @@ def test_read_only_without_metadata_rows(directory): with dc.Cache(directory) as cache: assert cache.stats() == (0, 0) assert cache.reset('hits') == 0 + + +@needs_permissions +def test_read_only_directory_with_dbname(directory): + with dc.Cache(directory, dbname='other.db') as cache: + cache['key'] = 'value' + + path = op.join(directory, 'other.db') + os.chmod(path, 0o444) + os.chmod(directory, 0o555) + + try: + with dc.Cache(directory, dbname='other.db') as cache: + assert cache.read_only + assert cache['key'] == 'value' + with pytest.raises(dc.ReadOnlyError): + cache['key'] = 'other' + finally: + os.chmod(directory, 0o755) + os.chmod(path, 0o644) + + assert os.listdir(directory) == ['other.db'] + + +def test_read_only_argument_with_dbname(directory): + with pytest.raises(dc.ReadOnlyError): + dc.Cache(directory, read_only=True, dbname='other.db') + + with dc.Cache(directory, dbname='other.db') as cache: + cache['key'] = 'value' + + with dc.Cache(directory, read_only=True, dbname='other.db') as cache: + assert cache['key'] == 'value' diff --git a/tests/typecheck_stubs.py b/tests/typecheck_stubs.py index 79d2204..091d4b0 100644 --- a/tests/typecheck_stubs.py +++ b/tests/typecheck_stubs.py @@ -28,3 +28,7 @@ def with_types_one_module() -> type[dc.SafeDisk]: def with_types_module_list() -> type[dc.SafeDisk]: return dc.SafeDisk.with_types(int, modules=['myapp.models', 'myapp.api']) + + +def cache_with_dbname(directory: str) -> dc.Cache: + return dc.Cache(directory, dbname='credentials.db')