From dc35bfd7780ada596f75e0317f4c35f356aeed33 Mon Sep 17 00:00:00 2001 From: August Vi Date: Sun, 18 Oct 2026 10:50:58 -0700 Subject: [PATCH] Add configure() and DISKCACHE_DISK so caches created without a disk use a process-wide default, with an explicit disk always winning. --- CHANGELOG.md | 4 + diskcache_community/__init__.py | 4 + diskcache_community/core.py | 140 ++++++++++++++- diskcache_community/djangocache.py | 14 +- diskcache_community/fanout.py | 18 +- diskcache_community/persistent.py | 23 +-- docs/api.rst | 9 + docs/tutorial.rst | 42 ++++- tests/test_default_disk.py | 265 +++++++++++++++++++++++++++++ 9 files changed, 495 insertions(+), 24 deletions(-) create mode 100644 tests/test_default_disk.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 386c11f..e42cb47 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,10 @@ diskcache-community forked from DiskCache at upstream commit ebfa37c (3 March 20 - The README is rewritten in Markdown around install, usage and the fork story; the comparison tables move to a new `comparisons` page in the documentation, which gets its own landing page, and the 2016 testimonials are dropped (PR #19). - The documentation is published on Read the Docs at https://diskcache-community.readthedocs.io/, built from `master` on every push; the README links point there instead of at the `.rst` sources (PR #18). +### Features + +- `configure(disk)` sets the disk type of every `Cache`, `FanoutCache`, `Deque`, `Index` and `DjangoCache` created afterwards without a disk, and `default_disk()` returns the type in effect. Without `configure`, the environment decides: `DISKCACHE_DISK=safe` selects `SafeDisk`, with `DISKCACHE_SAFE_TYPES` and `DISKCACHE_SAFE_MODULES` passed to `SafeDisk.with_types`, and `DISKCACHE_DISK` also takes `default` or the import path of a disk class. A disk passed explicitly always wins, so libraries that choose their own disk keep it; caches that already exist keep theirs, and nothing is persisted. The `disk` arguments now default to `None`, meaning that type (PR #24). + ### Packaging - **Breaking:** the package is imported as `diskcache_community` instead of `diskcache`, so it installs next to the upstream `diskcache` distribution without overwriting it, and a project can move to the fork while other dependencies still require `diskcache`. The public API, the `cache.db` schema, the file layout and the serialization are unchanged, so cache directories are shared with 5.6.3 in both directions. Django settings name `diskcache_community.DjangoCache` and `diskcache_community.SafeDisk`. The import-time warning about an upstream `diskcache` installed in the same environment is gone, and a CI job installs both distributions together, runs the golden tests in both directions and checks that uninstalling either leaves the other working (PR #22). diff --git a/diskcache_community/__init__.py b/diskcache_community/__init__.py index a9d7c7d..f312fd4 100644 --- a/diskcache_community/__init__.py +++ b/diskcache_community/__init__.py @@ -18,6 +18,8 @@ SafeDisk, Timeout, UnknownFileWarning, + configure, + default_disk, ) from .fanout import FanoutCache from .persistent import Deque, Index @@ -52,6 +54,8 @@ 'UNKNOWN', 'UnknownFileWarning', 'barrier', + 'configure', + 'default_disk', 'memoize_stampede', 'throttle', ] diff --git a/diskcache_community/core.py b/diskcache_community/core.py index d1e73d1..101722b 100644 --- a/diskcache_community/core.py +++ b/diskcache_community/core.py @@ -990,6 +990,138 @@ def remove(self, file_path): super().remove(file_path) +ENV_DISK = 'DISKCACHE_DISK' +ENV_SAFE_TYPES = 'DISKCACHE_SAFE_TYPES' +ENV_SAFE_MODULES = 'DISKCACHE_SAFE_MODULES' + +_configured_disk = None # pylint: disable=invalid-name + + +def _check_disk_type(disk, source='disk'): + if not (isinstance(disk, type) and issubclass(disk, Disk)): + raise ValueError( + '%s must subclass diskcache_community.Disk, not %r' + % (source, disk) + ) + + +def configure(disk): + """Set the disk type of caches created without a `disk` argument. + + The configured type applies to every :class:`Cache`, :class:`FanoutCache + `, :class:`Deque + `, :class:`Index ` + and :class:`DjangoCache ` created + afterwards in this process that is not given a disk. A disk passed + explicitly always wins, and caches that already exist keep theirs. + ``configure(None)`` removes the setting, so the environment variables + apply again. The choice is never persisted in a cache directory. + + >>> previous = configure(SafeDisk) + >>> type(Cache().disk) is SafeDisk + True + >>> configure(previous) is SafeDisk + True + + :param disk: Disk type or subclass, or None + :return: the previously configured disk type, or None + :raises ValueError: if `disk` is not a Disk subclass + + """ + global _configured_disk # pylint: disable=global-statement + + if disk is not None: + _check_disk_type(disk) + + previous, _configured_disk = _configured_disk, disk + return previous + + +def default_disk(): + """Return the disk type for a cache created without a `disk` argument. + + That is the type set with :func:`configure`, else the type selected by + the environment variables, else :class:`Disk`. + + ``DISKCACHE_DISK`` is empty or ``default`` for :class:`Disk`, ``safe`` + for :class:`SafeDisk`, or the import path of a Disk subclass, such as + ``myapp.cache:AppDisk``. With ``safe``, ``DISKCACHE_SAFE_TYPES`` lists + classes to allow by import path and ``DISKCACHE_SAFE_MODULES`` lists + module names whose classes to allow, both comma-separated, as + :meth:`SafeDisk.with_types` takes them. + + :return: Disk type or subclass + :raises ValueError: if an environment variable is invalid + + """ + if _configured_disk is not None: + return _configured_disk + + environ = os.environ + return _disk_from_environment( + environ.get(ENV_DISK, ''), + environ.get(ENV_SAFE_TYPES, ''), + environ.get(ENV_SAFE_MODULES, ''), + ) + + +def _split_list(value): + return [entry.strip() for entry in value.split(',') if entry.strip()] + + +def _import_object(path, source): + """Return the object at import path `path`, module:name or module.name.""" + if ':' in path: + module_name, _, name = path.partition(':') + else: + module_name, _, name = path.rpartition('.') + + try: + if not module_name or not name: + raise ImportError(path) + __import__(module_name) + obj = sys.modules[module_name] + for part in name.split('.'): + obj = getattr(obj, part) + except (ImportError, AttributeError) as error: + raise ValueError( + '%s: cannot import %r: %s' % (source, path, error) + ) from None + + return obj + + +@ft.lru_cache(maxsize=16) +def _disk_from_environment(name, types, modules): + """Return the disk type that the environment variable values select.""" + name = name.strip() + keyword = name.lower() + extras = types.strip() or modules.strip() + + if keyword == 'safe': + classes = [ + _import_object(entry, ENV_SAFE_TYPES) + for entry in _split_list(types) + ] + prefixes = _split_list(modules) + if not (classes or prefixes): + return SafeDisk + return SafeDisk.with_types(*classes, modules=prefixes) + + if extras: + raise ValueError( + '%s and %s apply only with %s=safe' + % (ENV_SAFE_TYPES, ENV_SAFE_MODULES, ENV_DISK) + ) + + if keyword in ('', 'default'): + return Disk + + disk = _import_object(name, ENV_DISK) + _check_disk_type(disk, ENV_DISK) + return disk + + def _meta_result(value, expire_time, tag, store_time, meta=(None, None, None)): """Return `value`, or a tuple of `value` and the requested metadata. @@ -1329,7 +1461,7 @@ def __init__( self, directory=None, timeout=60, - disk=Disk, + disk=None, *, read_only=False, **settings @@ -1338,7 +1470,8 @@ def __init__( :param str directory: cache directory :param float timeout: SQLite connection timeout - :param disk: Disk type or subclass for serialization + :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 settings: any of DEFAULT_SETTINGS @@ -1356,6 +1489,9 @@ def __init__( ` for details. """ + if disk is None: + disk = default_disk() + try: assert issubclass(disk, Disk) except (TypeError, AssertionError): diff --git a/diskcache_community/djangocache.py b/diskcache_community/djangocache.py index 6364476..214c3c2 100644 --- a/diskcache_community/djangocache.py +++ b/diskcache_community/djangocache.py @@ -11,7 +11,7 @@ # For older versions of Django simply use 300 seconds. DEFAULT_TIMEOUT = 300 -from .core import ENOVAL, Disk, args_to_key, full_name, resolve_ignore +from .core import ENOVAL, args_to_key, full_name, resolve_ignore from .fanout import FanoutCache @@ -23,10 +23,12 @@ def __init__(self, directory, params): The `params` mapping holds the keys of the Django ``CACHES`` setting. ``SHARDS`` and ``DATABASE_TIMEOUT`` configure the underlying - :class:`FanoutCache `, ``DISK`` selects the - :class:`Disk ` type or subclass used for serialization - as a class or a dotted import path such as ``'diskcache_community.SafeDisk'``, - and ``OPTIONS`` passes further settings through. + :class:`FanoutCache `, ``DISK`` + selects the :class:`Disk ` type or subclass + used for serialization as a class or a dotted import path such as + ``'diskcache_community.SafeDisk'``, by default the type + :func:`default_disk ` returns, and + ``OPTIONS`` passes further settings through. :param str directory: cache directory :param dict params: cache parameters @@ -36,7 +38,7 @@ def __init__(self, directory, params): shards = params.get('SHARDS', 8) timeout = params.get('DATABASE_TIMEOUT', 0.010) options = dict(params.get('OPTIONS', {})) - disk = params.get('DISK', options.pop('disk', Disk)) + disk = params.get('DISK', options.pop('disk', None)) if isinstance(disk, str): disk = import_string(disk) self._cache = FanoutCache( diff --git a/diskcache_community/fanout.py b/diskcache_community/fanout.py index 4a7a6ae..7bbc33c 100644 --- a/diskcache_community/fanout.py +++ b/diskcache_community/fanout.py @@ -9,7 +9,14 @@ import tempfile import time -from .core import DEFAULT_SETTINGS, ENOVAL, Cache, Disk, Timeout, _meta_result +from .core import ( + DEFAULT_SETTINGS, + ENOVAL, + Cache, + Timeout, + _meta_result, + default_disk, +) from .persistent import Deque, Index @@ -22,14 +29,16 @@ class FanoutCache: """Cache that shards keys and values.""" def __init__( - self, directory=None, shards=8, timeout=0.010, disk=Disk, **settings + self, directory=None, shards=8, timeout=0.010, disk=None, **settings ): """Initialize cache instance. :param str directory: cache directory :param int shards: number of shards to distribute writes :param float timeout: SQLite connection timeout - :param disk: `Disk` instance for serialization + :param disk: `Disk` type or subclass for serialization (default None, + the type :func:`default_disk + ` returns) :param settings: any of `DEFAULT_SETTINGS` The cache directory, the shard directories and the directories of @@ -37,6 +46,9 @@ def __init__( :class:`Cache` directories; existing directories keep their mode. """ + if disk is None: + disk = default_disk() + if directory is None: directory = tempfile.mkdtemp(prefix='diskcache-') directory = str(directory) diff --git a/diskcache_community/persistent.py b/diskcache_community/persistent.py index eda2740..2bb477a 100644 --- a/diskcache_community/persistent.py +++ b/diskcache_community/persistent.py @@ -13,7 +13,7 @@ from contextlib import contextmanager from shutil import rmtree -from .core import ENOVAL, Cache, Disk +from .core import ENOVAL, Cache def _make_compare(seq_op, doc): @@ -75,7 +75,7 @@ class Deque(Sequence): """ - def __init__(self, iterable=(), directory=None, maxlen=None, disk=Disk): + def __init__(self, iterable=(), directory=None, maxlen=None, disk=None): """Initialize deque instance. If directory is None then temporary directory created. The directory @@ -84,8 +84,9 @@ def __init__(self, iterable=(), directory=None, maxlen=None, disk=Disk): :param iterable: iterable of items to append to deque :param directory: deque directory (default None) :param maxlen: max length (default None, no max) - :param disk: `Disk` type or subclass for serialization (default - :class:`Disk `) + :param disk: `Disk` type or subclass for serialization (default None, + the type :func:`default_disk ` + returns) """ self._cache = Cache(directory, disk=disk, eviction_policy='none') @@ -682,7 +683,7 @@ class Index(MutableMapping): where items are stored. Hashing protocol is not used. Keys are looked up by their serialized - format. See ``diskcache.Disk`` for details. + format. See ``diskcache_community.Disk`` for details. >>> index = Index() >>> index.update([('a', 1), ('b', 2), ('c', 3)]) @@ -698,16 +699,18 @@ class Index(MutableMapping): """ - def __init__(self, *args, disk=Disk, **kwargs): + def __init__(self, *args, disk=None, **kwargs): """Initialize index in directory and update items. Optional first argument may be string specifying directory where items are stored. When None or not given, temporary directory is created. - The keyword argument `disk` selects the :class:`Disk ` - type or subclass used for serialization. Every other keyword argument - is stored as an item, so an item named ``disk`` has to be set after - initialization. + The keyword argument `disk` selects the :class:`Disk + ` type or subclass used for serialization, + by default the type :func:`default_disk + ` returns. Every other keyword + argument is stored as an item, so an item named ``disk`` has to be set + after initialization. >>> index = Index({'a': 1, 'b': 2, 'c': 3}) >>> len(index) diff --git a/docs/api.rst b/docs/api.rst index 91e6add..099450f 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -157,6 +157,15 @@ Read the :ref:`SafeDisk tutorial ` for details. ` refuses to allow, because unpickling them is not memory-safe. +Default disk +------------ + +Read the :ref:`default disk tutorial ` for details. + +.. autofunction:: diskcache_community.configure + +.. autofunction:: diskcache_community.default_disk + Timeout ------- diff --git a/docs/tutorial.rst b/docs/tutorial.rst index 35b64f1..061b4c3 100644 --- a/docs/tutorial.rst +++ b/docs/tutorial.rst @@ -33,15 +33,18 @@ Get the Code :doc:`DiskCache ` is actively developed on GitHub, where the code is always available. -You can either clone the `DiskCache repository `_:: +You can either clone the `DiskCache repository +`_:: $ git clone https://github.com/secondwindoss/diskcache-community.git -Download the `tarball `_:: +Download the `tarball +`_:: $ curl -OL https://github.com/secondwindoss/diskcache-community/tarball/master -Or, download the `zipball `_:: +Or, download the `zipball +`_:: $ curl -OL https://github.com/secondwindoss/diskcache-community/zipball/master @@ -1016,6 +1019,39 @@ value pickles a class outside the allowlist raises :exc:`TypeError` that names the class. Calling ``with_types`` on the returned class adds to its allowlist, and a cache that uses it can be pickled as usual. +.. _tutorial-default-disk: + +Default disk +............ + +A cache created without a ``disk`` argument uses the type that +:func:`default_disk ` returns. That is +:class:`Disk ` unless the process configures +another, which reaches caches that other libraries create for you, as long as +they do not pass a disk of their own. Set it in code, before the caches are +created: + +.. code-block:: python + + import diskcache_community + + diskcache_community.configure(diskcache_community.SafeDisk) + +or in the environment, without changing any code: + +.. code-block:: shell + + DISKCACHE_DISK=safe python app.py + +``DISKCACHE_DISK`` takes ``safe``, ``default``, or the import path of a disk +class such as ``myapp.cache:AppDisk``. With ``safe``, ``DISKCACHE_SAFE_TYPES`` +and ``DISKCACHE_SAFE_MODULES`` list classes and module names, comma-separated, +passed to :meth:`SafeDisk.with_types +`. A disk passed explicitly always +wins, then :func:`configure `, then the +environment, then :class:`Disk `. Caches that already +exist keep their disk, and the choice is never stored in the cache directory. + .. _tutorial-read-only: Read-Only Caches diff --git a/tests/test_default_disk.py b/tests/test_default_disk.py new file mode 100644 index 0000000..73ebacd --- /dev/null +++ b/tests/test_default_disk.py @@ -0,0 +1,265 @@ +"""Test diskcache_community.configure and default_disk.""" + +import os.path as op +import pickle +import shutil +import tempfile + +import pytest + +import diskcache_community as dc +from diskcache_community import core +from tests.safedisk_models import models + +pytestmark = pytest.mark.filterwarnings('ignore', category=dc.EmptyDirWarning) + +ENV_NAMES = ( + core.ENV_DISK, + core.ENV_SAFE_TYPES, + core.ENV_SAFE_MODULES, +) + + +@pytest.fixture(autouse=True) +def clean_default(monkeypatch): + for name in ENV_NAMES: + monkeypatch.delenv(name, raising=False) + previous = dc.configure(None) + core._disk_from_environment.cache_clear() + yield + dc.configure(previous) + core._disk_from_environment.cache_clear() + + +@pytest.fixture +def directory(): + directory = tempfile.mkdtemp(prefix='diskcache-') + yield directory + shutil.rmtree(directory, ignore_errors=True) + + +def disk_types(directory): + """Return the disk type of every container created without a disk.""" + types = {} + + with dc.Cache(op.join(directory, 'cache')) as cache: + types['cache'] = type(cache.disk) + + with dc.FanoutCache(op.join(directory, 'fanout'), shards=2) as fanout: + types['fanout'] = {type(shard.disk) for shard in fanout._shards} + types['fanout.cache'] = type(fanout.cache('sub').disk) + + deque = dc.Deque(directory=op.join(directory, 'deque')) + types['deque'] = type(deque.cache.disk) + deque.cache.close() + + index = dc.Index(op.join(directory, 'index')) + types['index'] = type(index.cache.disk) + index.cache.close() + + return types + + +def assert_all(types, disk): + assert types['cache'] is disk + assert types['fanout'] == {disk} + assert types['fanout.cache'] is disk + assert types['deque'] is disk + assert types['index'] is disk + + +def test_default_is_disk(directory): + assert dc.default_disk() is dc.Disk + assert_all(disk_types(directory), dc.Disk) + + +def test_configure_applies_to_every_container(directory): + assert dc.configure(dc.SafeDisk) is None + assert dc.default_disk() is dc.SafeDisk + assert_all(disk_types(directory), dc.SafeDisk) + + +def test_configure_returns_the_previous_type_and_resets(): + disk = dc.SafeDisk.with_types(models.Point) + + assert dc.configure(dc.SafeDisk) is None + assert dc.configure(disk) is dc.SafeDisk + assert dc.default_disk() is disk + assert dc.configure(None) is disk + assert dc.default_disk() is dc.Disk + + +@pytest.mark.parametrize('value', [int, 'SafeDisk', dc.Disk('/tmp'), object]) +def test_configure_rejects_non_disks(value): + with pytest.raises(ValueError, match='must subclass'): + dc.configure(value) + assert dc.default_disk() is dc.Disk + + +def test_explicit_disk_wins(directory): + dc.configure(dc.SafeDisk) + + with dc.Cache(op.join(directory, 'cache'), disk=dc.Disk) as cache: + assert type(cache.disk) is dc.Disk + + with dc.FanoutCache(op.join(directory, 'fanout'), disk=dc.JSONDisk) as f: + assert type(f.cache('sub').disk) is dc.JSONDisk + + deque = dc.Deque(directory=op.join(directory, 'deque'), disk=dc.Disk) + assert type(deque.cache.disk) is dc.Disk + deque.cache.close() + + index = dc.Index(op.join(directory, 'index'), disk=dc.Disk) + assert type(index.cache.disk) is dc.Disk + index.cache.close() + + +def test_existing_caches_keep_their_disk(directory): + with dc.Cache(directory) as cache: + dc.configure(dc.SafeDisk) + assert type(cache.disk) is dc.Disk + cache['custom'] = models.Point(1, 2) + + +def test_pickled_cache_keeps_its_disk(directory): + with dc.Cache(directory) as cache: + data = pickle.dumps(cache) + + dc.configure(dc.SafeDisk) + copy = pickle.loads(data) + try: + assert type(copy.disk) is dc.Disk + finally: + copy.close() + + +def test_django_uses_the_default(directory): + pytest.importorskip('django') + dc.configure(dc.SafeDisk) + + django_cache = dc.DjangoCache(directory, {}) + try: + assert type(django_cache._cache.disk) is dc.SafeDisk + finally: + django_cache.close() + + django_cache = dc.DjangoCache(directory, {'DISK': dc.Disk}) + try: + assert type(django_cache._cache.disk) is dc.Disk + finally: + django_cache.close() + + +@pytest.mark.parametrize('value', ['safe', 'SAFE', ' Safe ']) +def test_environment_safe(monkeypatch, directory, value): + monkeypatch.setenv(core.ENV_DISK, value) + assert dc.default_disk() is dc.SafeDisk + assert_all(disk_types(directory), dc.SafeDisk) + + +@pytest.mark.parametrize('value', ['', 'default', 'Default']) +def test_environment_default(monkeypatch, value): + monkeypatch.setenv(core.ENV_DISK, value) + assert dc.default_disk() is dc.Disk + + +@pytest.mark.parametrize( + 'value', + [ + 'diskcache_community:JSONDisk', + 'diskcache_community.JSONDisk', + 'diskcache_community.core:JSONDisk', + ], +) +def test_environment_import_path(monkeypatch, value): + monkeypatch.setenv(core.ENV_DISK, value) + assert dc.default_disk() is dc.JSONDisk + + +@pytest.mark.parametrize( + 'value', ['no.such.module:Disk', 'diskcache_community:NoSuchDisk', 'x'] +) +def test_environment_bad_import_path(monkeypatch, value): + monkeypatch.setenv(core.ENV_DISK, value) + with pytest.raises(ValueError, match=core.ENV_DISK): + dc.default_disk() + with pytest.raises(ValueError, match=core.ENV_DISK): + dc.Cache() + + +def test_environment_import_path_must_be_a_disk(monkeypatch): + monkeypatch.setenv(core.ENV_DISK, 'os.path:join') + with pytest.raises(ValueError, match='must subclass'): + dc.default_disk() + + +def test_environment_safe_types(monkeypatch, directory): + monkeypatch.setenv(core.ENV_DISK, 'safe') + monkeypatch.setenv( + core.ENV_SAFE_TYPES, + 'tests.safedisk_models.models:Point, tests.safedisk_models.models.Pair', + ) + monkeypatch.setenv(core.ENV_SAFE_MODULES, 'litellm.types,openai.types') + + disk = dc.default_disk() + + assert issubclass(disk, dc.SafeDisk) + assert disk.safe_types == frozenset({models.Point, models.Pair}) + assert disk.safe_modules == ('litellm.types', 'openai.types') + assert dc.default_disk() is disk + + with dc.Cache(directory) as cache: + cache['point'] = models.Point(1, 2) + assert cache['point'] == models.Point(1, 2) + with pytest.raises(TypeError, match='Shape'): + cache['shape'] = models.Shape('line', []) + + +def test_environment_bad_safe_type(monkeypatch): + monkeypatch.setenv(core.ENV_DISK, 'safe') + monkeypatch.setenv( + core.ENV_SAFE_TYPES, 'tests.safedisk_models.models:Nope' + ) + with pytest.raises(ValueError, match=core.ENV_SAFE_TYPES): + dc.default_disk() + + +@pytest.mark.parametrize('disk', ['', 'default', 'diskcache_community:Disk']) +def test_environment_extras_need_safe(monkeypatch, disk): + monkeypatch.setenv(core.ENV_DISK, disk) + monkeypatch.setenv(core.ENV_SAFE_MODULES, 'litellm.types') + with pytest.raises(ValueError, match='only with DISKCACHE_DISK=safe'): + dc.default_disk() + + +def test_environment_is_read_at_each_creation(monkeypatch, directory): + with dc.Cache(op.join(directory, 'first')) as cache: + assert type(cache.disk) is dc.Disk + + monkeypatch.setenv(core.ENV_DISK, 'safe') + + with dc.Cache(op.join(directory, 'second')) as cache: + assert type(cache.disk) is dc.SafeDisk + + +def test_configure_beats_the_environment(monkeypatch): + monkeypatch.setenv(core.ENV_DISK, 'safe') + dc.configure(dc.JSONDisk) + assert dc.default_disk() is dc.JSONDisk + dc.configure(None) + assert dc.default_disk() is dc.SafeDisk + + +def test_default_disk_not_persisted(directory): + dc.configure(dc.SafeDisk) + + with dc.Cache(directory) as cache: + cache['key'] = 'value' + names = {name for (name,) in cache._sql('SELECT key FROM Settings')} + + assert not any('disk' in name and 'disk_' not in name for name in names) + dc.configure(None) + + with dc.Cache(directory) as cache: + assert type(cache.disk) is dc.Disk + assert cache['key'] == 'value'