Storage Backends¶
ditto stores each snapshot in a target: a location written as a URI, such
as a local directory, an S3 bucket or a Redis database. By default the target
is a .ditto/ directory next to each test file. This guide explains how to
store snapshots somewhere else, for one test or for a whole project, and how to
pass the credentials that remote storage needs.
Targets are URIs¶
A target URI has two parts. The scheme, before the first :, says what
kind of storage it is. The rest says where in that storage the snapshots go:
s3://my-bucket/snapshots/
^^ ^^^^^^^^^^^^^^^^^^^^
│ └ where: here, a bucket and a prefix within it
└ scheme
The scheme picks the backend, the code that reads and writes snapshots for that kind of storage. ditto chooses the backend in this order:
fileis always the local filesystem.- A scheme with a registered backend uses that backend. Packages register backends for storage such as Redis or PostgreSQL; see Custom Backends to write one.
- Any fsspec protocol, such as
s3,gs,azormemory, uses fsspec. - Anything else fails the test with
ValueError: Unknown backend scheme.
Common targets:
| Target | Stores snapshots in | Needs |
|---|---|---|
file://.ditto |
a .ditto/ directory next to each test file (the default) |
nothing |
file://snapshots/api |
a snapshots/api/ directory next to each test file |
nothing |
file:///var/snapshots |
the absolute directory /var/snapshots |
nothing |
memory:// |
the memory of the current Python process | nothing |
s3://my-bucket/snapshots/ |
the snapshots/ prefix of an S3 bucket |
s3fs |
gs://my-bucket/snapshots/ |
the snapshots/ prefix of a Google Cloud Storage bucket |
gcsfs |
az://my-container/snapshots/ |
the snapshots/ prefix of an Azure Blob Storage container |
adlfs |
redis://localhost:6379/0 |
database 0 of a Redis server | a package that registers a redis backend |
Local files: file://¶
A file:// target names a directory. Count the slashes:
- Two slashes, a relative path.
file://snapshots/apiis resolved against the directory of each test file, not the project root. Tests intests/api/andtests/cli/store snapshots intests/api/snapshots/api/andtests/cli/snapshots/api/. - Three slashes, an absolute path.
file:///var/snapshotsis the same directory for every test.
The default, file://.ditto, is a relative path, which is why each test
directory gets its own .ditto/.
Each snapshot is one file in the directory, such as
tests/api/.ditto/tests.api.test_users.test_create@response~90e755f5c20755bd.json.
A file is written to a temporary file and renamed into place, so a failed
write leaves the previous snapshot intact. See
Snapshot Names for how names are built.
fsspec: cloud storage and memory¶
fsspec itself only understands a few protocols, such as memory. Most cloud
storage needs an extra package; the table above lists the common ones, and
fsspec's list of implementations
has the rest. Without the package, tests that use the target fail with an
error that names it, such as ImportError: Install s3fs to access S3.
Snapshots are stored under the URI's path, one object per snapshot. The snapshot
above, with target="s3://my-bucket/snapshots/", is stored as
s3://my-bucket/snapshots/tests/api/test_users/test_create@response~90e755f5c20755bd.json:
the module path keeps its slashes. See Snapshot Names.
memory:// stores snapshots in the current Python process. A fresh process
starts empty, so it can't keep a baseline between ordinary command-line runs,
which makes it unsuitable for persistent regression snapshots.
Choosing the target for a test¶
ditto uses the first of these that is set:
target=on the test's marktarget_profile=on the test's mark- The
ditto_targetini option - The
ditto_target_profileini option file://.ditto
A mark takes either target= or target_profile=, not both; a test with both
fails. Likewise, ditto_target and ditto_target_profile can't both be set,
and pytest refuses to start if they are. The profile options are described in
Named profiles.
For one test: target=¶
import ditto
@ditto.record("json", target="s3://my-bucket/snapshots/")
def test_create_user(snapshot): ...
For a whole project: ditto_target¶
Every test without its own target= or target_profile= then uses this
target. A relative file:// path here is still resolved against each test
file's directory.
Every branch shares a remote target's snapshots
Snapshots are named after test modules, so every checkout using a target,
whether another branch or another project with the same test paths, reads
and writes the same snapshots, and can't tell another's from its own stale
ones. Give each project its own path, such as s3://my-bucket/<project>/,
and keep snapshots in the repository when branches change them
independently. See
Sharing a target.
Credentials and connection settings: ditto_storage_options¶
Remote storage usually needs credentials or connection settings. They can't go
in target URIs: ditto writes every target URI to ditto.lock, which you
commit, so a password in a URI would end up in version control. ditto refuses a
target URI that contains a password (redis://alice:secret@host) or a query
parameter that holds a secret (password, passwd, pwd, secret,
secret_key, token, access_token, api_key, apikey, sig, signature,
X-Amz-Signature or X-Amz-Security-Token, in any case), including in any
part of a chained fsspec URL such as simplecache::s3://…. A test using one
errors, and a ditto_target with one stops the run. The error masks the secret
and is reported without a traceback, so the URI doesn't appear in pytest's
output (except under --full-trace). A username alone is fine. Return
credentials from a ditto_storage_options fixture in conftest.py instead, or
for a profile, from its own storage_options. The fixture
returns a dictionary keyed by scheme:
# conftest.py
import os
import pytest
@pytest.fixture(scope="session")
def ditto_storage_options():
return {
"s3": {"key": os.environ["AWS_KEY"], "secret": os.environ["AWS_SECRET"]},
"redis": {"password": os.environ["REDIS_PASSWORD"]},
}
For each target, ditto looks up the entry for the target's scheme and passes
its items as keyword arguments to the backend. For an fsspec scheme, they go
to the fsspec filesystem, so the valid options are those of that filesystem,
such as s3fs's key, secret and endpoint_url. For a registered backend,
they go to the backend's factory; its documentation lists the options it
accepts.
- Options are chosen by scheme alone, so every
s3://target gets the same options. To give two targets with the same scheme different credentials, use profiles. - Options are never written to
ditto.lock. - Options may contain nested dictionaries, lists, tuples and sets. Values
inside those containers must otherwise be hashable, because ditto uses the
options to decide when two tests can share a backend; the backend still
receives them unchanged. An unhashable value, such as a
bytearray, fails withDittoUnhashableStorageOptionsError.
Named profiles¶
A profile is a target with a name, and optionally its own storage options. Profiles help when:
- several tests share one of a few targets, and the name says more than the URI
- two targets use the same scheme but need different credentials
Defining profiles¶
A profile is either a URI, or a table with a uri and, optionally,
storage_options.
A profile's storage options replace ditto_storage_options entirely: a profile
target gets only the options in its own definition, even if
ditto_storage_options has an entry for its scheme, and a URI-only profile
gets none. The storage library can still find credentials the way it normally
does; s3fs, for example, reads the AWS_* environment variables and
~/.aws/credentials.
Define profiles in a ditto_target_profiles fixture when they need values from
the environment, such as secrets:
# conftest.py
import os
import pytest
@pytest.fixture(scope="session")
def ditto_target_profiles():
return {
"golden": "s3://my-bucket/golden/",
"s3_east": {
"uri": "s3://east-bucket/golden/",
"storage_options": {
"key": os.environ["AWS_EAST_KEY"],
"secret": os.environ["AWS_EAST_SECRET"],
},
},
}
Or define them in pyproject.toml when they don't:
[tool.pytest-ditto.target_profiles]
golden = "s3://my-bucket/golden/"
[tool.pytest-ditto.target_profiles.s3_east]
uri = "s3://east-bucket/golden/"
storage_options = { endpoint_url = "https://s3.us-east-1.amazonaws.com" }
A name defined in both places is an error.
Using profiles¶
For one test:
For a whole project:
An unknown profile name fails the test, and the error lists the defined profiles.
Static profiles are only read from pyproject.toml
The [tool.pytest-ditto.target_profiles] table is read from the
pyproject.toml in pytest's rootdir,
and nowhere else:
pytest.ini,tox.iniandsetup.cfgcannot hold the table.- If pytest is configured by one of those files, a
pyproject.tomlnext to it (in the rootdir) is still read. - A
pyproject.tomlin any other directory, such as a subdirectory of the rootdir, is ignored.
Projects without a pyproject.toml in the rootdir can define the same
profiles in the ditto_target_profiles fixture instead. Selecting a
default profile with the ditto_target_profile ini option works from any
pytest configuration file.