Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
36 changes: 28 additions & 8 deletions diskcache_community/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__(
Expand All @@ -1738,6 +1738,7 @@ def __init__(
disk=None,
*,
read_only=False,
dbname=None,
**settings
):
"""Initialize cache instance.
Expand All @@ -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
Expand All @@ -1773,13 +1776,23 @@ 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)
directory = op.expanduser(directory)
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()
Expand All @@ -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:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
11 changes: 9 additions & 2 deletions diskcache_community/core.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,7 @@ class Cache(BaseCache):
disk: type[Disk] | None = ...,
*,
read_only: bool = ...,
dbname: str | None = ...,
**settings: Unpack[InitSettings],
) -> None: ...
@overload
Expand All @@ -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
Expand Down Expand Up @@ -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: ...
3 changes: 3 additions & 0 deletions diskcache_community/fanout.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
146 changes: 146 additions & 0 deletions tests/test_core.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""Test diskcache_community.core.Cache."""

import _thread
import copy
import errno
import gc
import hashlib
Expand Down Expand Up @@ -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
7 changes: 7 additions & 0 deletions tests/test_fanout.py
Original file line number Diff line number Diff line change
Expand Up @@ -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) == []
33 changes: 33 additions & 0 deletions tests/test_readonly.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'
4 changes: 4 additions & 0 deletions tests/typecheck_stubs.py
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Loading