Writing a Backend¶
A backend is where ditto keeps snapshot data. Out of the box, ditto can store snapshots on the local filesystem and anywhere fsspec can reach: S3, Google Cloud Storage, Azure, an in-memory filesystem and more (see Storage Backends). You only need a custom backend to store snapshots somewhere fsspec doesn't cover, such as Redis, PostgreSQL or DuckDB.
A custom backend has two parts:
- A factory: a function that takes a target URI and returns a mapping object that ditto reads and writes snapshots through.
- A scheme: the name at the start of a target URI, such as the
redisinredis://localhost:6379/0, which tells ditto to call your factory.
Schemes and target URIs¶
Every snapshot target is a URI. ditto uses the URI's scheme, the part before
the first :, to decide which backend stores the snapshots:
Everything after the scheme belongs to the backend. ditto passes the whole URI
to your factory unchanged (only file:// URIs are rewritten, to make relative
paths absolute), so your factory decides what the host, path and query mean.
The URI is the same string users write in target=, ditto_target or a
target profile:
How ditto chooses a backend¶
For each target, ditto checks, in order:
| Order | Scheme | Stored by |
|---|---|---|
| 1 | file |
ditto itself, on the local filesystem |
| 2 | a scheme with a registered backend | that backend's factory |
| 3 | an fsspec protocol, such as s3 or memory |
fsspec |
| 4 | anything else | nothing: the test fails with ValueError: Unknown backend scheme |
Because registered backends come before fsspec, registering a scheme that
fsspec also knows, such as s3, replaces fsspec for those targets. file
can't be replaced.
Choosing a scheme¶
A scheme is a lowercase letter followed by any mix of lowercase letters,
digits, +, - and .. This is the rule URIs themselves follow
(RFC 3986), restricted to
lowercase:
| Scheme | Valid | Why |
|---|---|---|
redis |
yes | |
postgresql+psycopg |
yes | + often names a driver or transport |
my-store, acme.kv, kv2 |
yes | -, . and digits are allowed after the first letter |
Redis |
no | must be lowercase |
2fa |
no | must start with a letter |
my_store |
no | _ is not allowed |
file |
no | ditto stores file:// targets itself |
Targets are matched case-insensitively, so REDIS://localhost also reaches the
redis backend.
All installed packages share one set of scheme names, and two packages that
register the same scheme conflict. Before
using a generic name such as redis in a package you publish, consider a more
specific one, such as acme-redis.
Writing a factory¶
A factory has this signature:
from collections.abc import MutableMapping
def create_backend(uri: str, **storage_options) -> MutableMapping[str, bytes]: ...
uriis the target URI, exactly as the user wrote it.storage_optionsare keyword arguments for this target, usually credentials or connection settings. For a URI given withtarget=orditto_target, they are the entry for your scheme in theditto_storage_optionsfixture. For a target profile, they are the profile's ownstorage_options. Without either, there are none.- The return value is the mapping that ditto stores snapshots in, described in the next section.
ditto calls a factory once per session for each distinct URI and set of storage options, and reuses the mapping it returns for every test with the same target. The same URI with two different sets of storage options gets two separate mappings.
The mapping¶
The factory returns a collections.abc.MutableMapping[str, bytes]: an object
with the same interface as a dict of strings to bytes. Subclass
MutableMapping and implement its five abstract methods; it provides in,
get and the other dict methods on top of them. ditto uses them as follows:
| Method | ditto uses it to |
|---|---|
__getitem__ |
read a snapshot. Raise KeyError for a missing key: in relies on it to tell ditto a snapshot doesn't exist yet. |
__setitem__ |
write a snapshot, replacing any previous value whole: see Failed writes |
__delitem__ |
remove a snapshot, in ditto prune |
__iter__ |
list the stored keys, in ditto verify, ditto prune and the --live mode of ditto list, status, stats and lint |
__len__ |
nothing directly, but MutableMapping requires it; sum(1 for _ in self) is enough |
Each key names one snapshot by its test module, test, snapshot key and a short hash of them, and ends with the name of the recorder that wrote it:
tests/api/test_users/test_create@response~90e755f5c20755bd.json
└────────┬─────────┘ └────┬────┘ └──┬───┘ └──────┬───────┘ └┬─┘
test module test key hash recorder
Keys are ASCII apart from the test module, which is the test file's path. They
contain /, @, ~ and ., so store them verbatim or encode them in a way
you can reverse. Values are the bytes the recorder produced; store and return
them unchanged.
Failed writes¶
--ditto-update overwrites existing baselines, so a reader must always see
either the whole previous value or the whole new one, never part of either.
That includes after a write fails partway through (a full disk, a dropped
connection, an interrupted process). When __setitem__ raises, either value
may be stored: a remote store can save the new value and then lose the reply.
ditto's file:// backend meets this by writing a temporary file in the same
directory and os.replace-ing it over the snapshot. S3, GCS and Azure meet it
because they replace a whole object at once. For other fsspec filesystems, it
depends on the implementation. On a filesystem of your own, don't open the
destination for writing, because that empties it first.
Beyond that, the mapping needs no transactions and no compare-and-set: ditto
doesn't expect several writes to succeed or fail together. It also doesn't
support maintaining the lock concurrently: running ditto lock, ditto update
or ditto prune at the same time against the same checkout or store can lose
changes.
ditto verify and ditto prune compare the keys that __iter__ lists with
ditto.lock. A listed key under one of the project's test modules that the
lock doesn't record is reported by verify and deleted by prune; keys
outside those modules are left alone. If several projects share one store,
their test modules can have the same paths, so keep their keys apart with
ditto.backends.PrefixedMapping. It adds a prefix to every key on the way in,
strips it on the way out, and lists only keys that carry the prefix:
from ditto.backends import PrefixedMapping
return PrefixedMapping(RedisMapping(client), prefix="my-project:")
Opening and closing connections¶
If the mapping holds a connection, make it a context manager by giving it
__enter__ and __exit__ methods. ditto enters it the first time a test uses
its target, and exits it when the test session ends:
from collections.abc import MutableMapping
class DbMapping(MutableMapping[str, bytes]):
def __init__(self, uri: str, **options) -> None:
self._conn = connect(uri, **options)
def __enter__(self) -> "DbMapping":
return self
def __exit__(self, *exc_info) -> None:
self._conn.close()
... # the five mapping methods
def create_db_backend(uri: str, **storage_options) -> MutableMapping[str, bytes]:
return DbMapping(uri, **storage_options)
ditto uses whatever __enter__ returns as the mapping, so __enter__
normally returns self. PrefixedMapping passes __enter__ and __exit__
through to the mapping it wraps.
Registering the backend¶
There are two ways to connect a scheme to a factory:
- In a package's metadata, for a backend you install or publish. This is the usual way.
- In code, usually in a
conftest.py, for a backend that lives with one project's tests, or to override an installed one.
From a package¶
Add an entry point in the ditto_backends group to the package's
pyproject.toml. The entry-point name is the scheme, and the value is the
factory, written as module:function:
Entry points are read from the metadata of installed packages, so the package
must be installed, and reinstalled whenever this table changes. An editable
install (pip install -e .) counts. Once it is installed, any test in the
environment can use target="redis://...".
ditto reads scheme names without importing anything, and imports the factory only when a target first uses its scheme. An installed backend therefore adds nothing to test runs that don't use it.
From code¶
ditto.backends.BACKEND_REGISTRY maps each registered scheme to its factory;
file and the fsspec protocols are handled separately and aren't in it. It is
read-only. To register a factory in code, set it on
BACKEND_REGISTRY.overrides, which behaves like a dict:
# conftest.py
import pytest
from ditto.backends import BACKEND_REGISTRY
from my_package.backends import create_redis_backend
@pytest.fixture(scope="session", autouse=True)
def redis_backend():
BACKEND_REGISTRY.overrides["redis"] = create_redis_backend
yield
del BACKEND_REGISTRY.overrides["redis"]
Setting an override checks that the factory is callable and that the scheme is valid. An override takes precedence over any installed backend for the same scheme, including one that fails to import or conflicts with another package. Deleting the override makes the installed backend available again.
Set overrides before any test uses the scheme, as the session fixture above
does. For the whole session, ditto builds every backend for a target URI with
the same factory. ditto.lock, ditto verify and ditto prune treat a URI as
one store, so every backend for it, including ones with different storage
options, must reach the same data. Once a test has used a target, a different
factory for its scheme fails the next test that uses it with
DittoBackendChangedError, rather than being silently ignored or splitting the
target's snapshots across two stores.
To give one test a throwaway store, give it a target of its own instead of
overriding the factory, for example @ditto.record("json", target="memory://").
When a backend can't be used¶
ditto never guesses which backend to use, and a registered scheme never falls back to fsspec:
- The factory fails to import. Tests that use the scheme fail with
DittoBackendLoadError, which names the scheme, the package that registered it and the original error. Other tests are unaffected. - Two packages register the same scheme. Tests that use the scheme fail
with
DittoBackendConflictError, naming both packages. Uninstall one, or choose one by setting an override for the scheme. - A scheme's factory changes after a test has used one of its targets.
Later tests that use the target fail with
DittoBackendChangedError. Set overrides before the first test that uses the scheme. - A package registers an invalid scheme, or
file. No target can reach it, andditto doctorreports it.
ditto doctor imports every registered backend and
reports import failures, conflicts and invalid schemes without running any
tests. A factory that changes during a test run only shows up when the tests
run.
Example: Redis¶
A complete backend that stores snapshots in Redis, using the redis client:
# my_package/backends.py
from collections.abc import Iterator, MutableMapping
import redis
from ditto.backends import PrefixedMapping
class RedisMapping(MutableMapping[str, bytes]):
def __init__(self, client: redis.Redis) -> None:
self._client = client
def __getitem__(self, key: str) -> bytes:
value = self._client.get(key)
if value is None:
raise KeyError(key)
return value
def __setitem__(self, key: str, value: bytes) -> None:
self._client.set(key, value)
def __delitem__(self, key: str) -> None:
if not self._client.delete(key):
raise KeyError(key)
def __iter__(self) -> Iterator[str]:
return (key.decode() for key in self._client.scan_iter())
def __len__(self) -> int:
return sum(1 for _ in self)
def __enter__(self) -> "RedisMapping":
return self
def __exit__(self, *exc_info: object) -> None:
self._client.close()
def create_redis_backend(uri: str, **storage_options) -> MutableMapping[str, bytes]:
# redis-py reads host, port and database number from the URI:
# redis://[user@]host[:port][/db]. ditto refuses a URI with a password.
client = redis.Redis.from_url(uri, **storage_options)
return PrefixedMapping(RedisMapping(client), prefix="ditto:")
Register it:
Pass the password as a storage option: ditto refuses a target URI that contains
one, because target URIs are recorded in ditto.lock.
# conftest.py
import os
import pytest
@pytest.fixture(scope="session")
def ditto_storage_options():
return {"redis": {"password": os.environ["REDIS_PASSWORD"]}}
Then point the tests at it:
Runnable examples¶
The examples directory
has self-contained PostgreSQL, Redis and DuckDB backends, each registered from a
conftest.py.