diff --git a/.gitignore b/.gitignore
index eb688e2..0a0edb4 100644
--- a/.gitignore
+++ b/.gitignore
@@ -48,4 +48,5 @@ docs/generated/
*.nxspe
*.tiff
*.tif
+!tests/_regression_test_files/*.tiff
*.mtz
diff --git a/docs/user-guide/io.ipynb b/docs/user-guide/io.ipynb
index b87dde1..035a19d 100644
--- a/docs/user-guide/io.ipynb
+++ b/docs/user-guide/io.ipynb
@@ -438,6 +438,22 @@
"display(img_stdev_and_mask_resolved_at_once)\n",
"img_stdev_and_mask_resolved_at_once['t', 0]['z', 0].plot(title='Masked Image, T=0, Z=0')"
]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "# Backward Compatibility\n",
+ "\n",
+ "`Scitiff` IO modules should be backward-compatible.
\n",
+ "Any files **written by** `scitiff` can be **read by a newer version** of `scitiff`.\n",
+ "\n",
+ "On the other hand, `scitiff` may not be able to read an image
\n",
+ "if the image was **written by a newer version** of `scitiff`.
\n",
+ "i.e. a mandatory field became optional and was not included in the metadata.\n",
+ "\n",
+ "Please tell us if you experience any errors in reading a `scitiff` image with a newer version of `scitiff python package`."
+ ]
}
],
"metadata": {
diff --git a/tests/_regression_test_files/scitiff-25.12.0.tiff b/tests/_regression_test_files/scitiff-25.12.0.tiff
new file mode 100644
index 0000000..7488b97
Binary files /dev/null and b/tests/_regression_test_files/scitiff-25.12.0.tiff differ
diff --git a/tests/_regression_test_files/scitiff-25.3.0.tiff b/tests/_regression_test_files/scitiff-25.3.0.tiff
new file mode 100644
index 0000000..2130aa4
Binary files /dev/null and b/tests/_regression_test_files/scitiff-25.3.0.tiff differ
diff --git a/tests/_regression_test_files/scitiff-25.4.0.tiff b/tests/_regression_test_files/scitiff-25.4.0.tiff
new file mode 100644
index 0000000..2130aa4
Binary files /dev/null and b/tests/_regression_test_files/scitiff-25.4.0.tiff differ
diff --git a/tests/_regression_test_files/scitiff-25.5.0.tiff b/tests/_regression_test_files/scitiff-25.5.0.tiff
new file mode 100644
index 0000000..2130aa4
Binary files /dev/null and b/tests/_regression_test_files/scitiff-25.5.0.tiff differ
diff --git a/tests/_regression_test_files/scitiff-25.6.0.tiff b/tests/_regression_test_files/scitiff-25.6.0.tiff
new file mode 100644
index 0000000..2130aa4
Binary files /dev/null and b/tests/_regression_test_files/scitiff-25.6.0.tiff differ
diff --git a/tests/_regression_test_files/scitiff-25.7.0.tiff b/tests/_regression_test_files/scitiff-25.7.0.tiff
new file mode 100644
index 0000000..9fa3ff4
Binary files /dev/null and b/tests/_regression_test_files/scitiff-25.7.0.tiff differ
diff --git a/tests/_regression_test_files/scitiff-26.1.0.tiff b/tests/_regression_test_files/scitiff-26.1.0.tiff
new file mode 100644
index 0000000..9e43b30
Binary files /dev/null and b/tests/_regression_test_files/scitiff-26.1.0.tiff differ
diff --git a/tests/backward_compatibility_test.py b/tests/backward_compatibility_test.py
new file mode 100644
index 0000000..c59c55a
--- /dev/null
+++ b/tests/backward_compatibility_test.py
@@ -0,0 +1,139 @@
+# SPDX-License-Identifier: BSD-3-Clause
+# Copyright (c) 2026 Scipp(ESS) contributors (https://github.com/scipp)
+import json
+import pathlib
+from contextlib import contextmanager
+from dataclasses import dataclass
+from datetime import UTC, datetime
+
+import pydantic
+import pytest
+import requests
+from packaging.version import Version
+
+from scitiff.io import load_scitiff
+
+_LOWER_BOUND_VERSION = Version('25.1.0')
+_SCITIFF_TEST_CACHE = pathlib.Path.home() / '.cache' / 'scitiff-test'
+_SCITIFF_TEST_CACHE.mkdir(parents=True, exist_ok=True)
+_CACHED_PACKAGE_INFO_PATH = _SCITIFF_TEST_CACHE / 'scitiff-package-info.json'
+
+
+def _utc_now() -> datetime:
+ return datetime.now(tz=UTC)
+
+
+class ScitiffPackageInfoCache(pydantic.BaseModel):
+ last_updated: datetime = pydantic.Field(default_factory=_utc_now)
+ versions: tuple[str, ...]
+
+ @property
+ def testing_versions(self) -> tuple[str, ...]:
+ return tuple(
+ _v.base_version
+ for _v in sorted(
+ Version(version)
+ for version in self.versions
+ if Version(version) >= _LOWER_BOUND_VERSION
+ )
+ )
+
+ @classmethod
+ def from_pypi(cls) -> 'ScitiffPackageInfoCache':
+ url = "https://pypi.org/pypi/scitiff/json"
+ response = requests.get(url, timeout=1)
+ data = response.json()
+ pacakge_info = ScitiffPackageInfoCache(versions=tuple(data['releases'].keys()))
+ # Save the info if possible
+ try:
+ _CACHED_PACKAGE_INFO_PATH.write_text(
+ data=pacakge_info.model_dump_json(indent=True)
+ )
+ except Exception as err:
+ import warnings
+
+ warnings.warn(
+ 'Could not save scitiff package info into '
+ f'{_CACHED_PACKAGE_INFO_PATH.as_posix()}.\n'
+ f'An error raised: {err}\n'
+ 'Skipping saving file... use `from_pypi` instead '
+ 'if you do not need to save the info.',
+ RuntimeWarning,
+ stacklevel=3,
+ )
+
+ return pacakge_info
+
+ @classmethod
+ def maybe_from_cache(cls) -> 'ScitiffPackageInfoCache':
+ if _CACHED_PACKAGE_INFO_PATH.exists():
+ try:
+ latest = ScitiffPackageInfoCache(
+ **json.loads(_CACHED_PACKAGE_INFO_PATH.read_text())
+ )
+ except Exception:
+ ...
+ else:
+ if (_utc_now() - latest.last_updated).seconds <= 300:
+ return latest
+
+ return cls.from_pypi()
+
+
+SCITIFF_PACKAGE_INFO = ScitiffPackageInfoCache.maybe_from_cache()
+
+
+@dataclass
+class KnownError:
+ error_type: type
+ """Type of error. i.e. RuntimeError."""
+ error_match: str
+ """Match description of the error for pytest."""
+
+
+_KNOWN_ERRORS: dict[str, tuple[KnownError]] = {} # No known errors yet
+
+
+@contextmanager
+def known_backward_compatibility_issues(_errors: tuple[KnownError, ...]):
+ if len(_errors) == 1:
+ with pytest.raises(_errors[0].error_type, match=_errors[0].error_match):
+ yield
+ elif len(_errors) >= 1:
+ with pytest.raises(_errors[0].error_type, match=_errors[0].error_match):
+ with known_backward_compatibility_issues(_errors[1:]):
+ yield
+ else:
+ yield
+
+
+def _get_scitiff_example_file_path(version: str) -> pathlib.Path:
+ _test_files_dir = pathlib.Path(__file__).parent / '_regression_test_files'
+ return _test_files_dir / f'scitiff-{version}.tiff'
+
+
+@pytest.mark.parametrize(
+ argnames=('scitiff_version'),
+ argvalues=SCITIFF_PACKAGE_INFO.testing_versions,
+)
+def test_example_files_for_all_releases_exist(scitiff_version) -> None:
+ cur_version_file = _get_scitiff_example_file_path(scitiff_version)
+ if not cur_version_file.exists():
+ raise RuntimeError(
+ f"Example file for version {scitiff_version} does not exist. "
+ "Use `tools/dump_scitiff_example.py` to create a new one "
+ "with the missing release version.\n"
+ "Creating an example file for a new release is not automated.\n"
+ "Therefore a developer will have to create a new scitiff file "
+ "and push it to the repo manually."
+ )
+
+
+@pytest.mark.parametrize(
+ argnames=('scitiff_version'),
+ argvalues=SCITIFF_PACKAGE_INFO.testing_versions,
+)
+def test_loading_old_version_files(scitiff_version) -> None:
+ _known_erros = _KNOWN_ERRORS.get(scitiff_version, ())
+ with known_backward_compatibility_issues(_known_erros):
+ load_scitiff(_get_scitiff_example_file_path(scitiff_version))
diff --git a/tools/dump_scitiff_example.py b/tools/dump_scitiff_example.py
new file mode 100644
index 0000000..4f97278
--- /dev/null
+++ b/tools/dump_scitiff_example.py
@@ -0,0 +1,107 @@
+# SPDX-License-Identifier: BSD-3-Clause
+# Copyright (c) 2026 Scipp(ESS) contributors (https://github.com/scipp)
+import logging
+import pathlib
+
+import scipp as sc
+
+from scitiff.data import hyperstack_example
+from scitiff.io import save_scitiff
+
+
+def _require_rich() -> None:
+ try:
+ import rich # noqa: F401 - just for checking
+ except ImportError as e:
+ raise ImportError(
+ "You need `rich` to run this script.\n"
+ "Please install `rich` or `scitiff` with `GUI` "
+ "optional dependencies.\n"
+ "Recommended command: pip install scitiff[gui]."
+ ) from e
+
+
+def _get_rich_logger() -> logging.Logger:
+ _require_rich()
+ from rich.logging import RichHandler
+
+ logger = logging.getLogger(__name__)
+ if not logger.handlers:
+ logger.addHandler(RichHandler())
+ logger.setLevel(logging.INFO)
+ return logger
+
+
+def _get_scitiff_version() -> str:
+ from scitiff import __version__
+
+ if 'dev' in __version__ or __version__.startswith('0.'):
+ raise RuntimeError(
+ "Only release versions must be used for dumping an example image."
+ )
+ else:
+ return __version__
+
+
+def _example_image_after_2610() -> sc.DataGroup:
+ from scitiff._schema import DAQMetadata
+ from scitiff.data import hyperstack_example_with_variances_and_mask
+
+ # Trimmed the example image
+ example_image = hyperstack_example_with_variances_and_mask()['x', :10]['y', :10]
+ daq_metadata = DAQMetadata(
+ facility='scitiff-dev',
+ instrument='computer',
+ detector_type='computer',
+ simulated=True,
+ )
+ extra = {
+ 'string-value': 'string-value',
+ 'int-value': 1,
+ 'float-value': 1.2,
+ 'scipp-scalar-number': sc.scalar(1, unit='count'),
+ 'scipp-scalar-datetime': sc.datetime('now'),
+ }
+ return sc.DataGroup(image=example_image, daq=daq_metadata, extra=extra)
+
+
+def _example_image(version: str) -> sc.DataArray | sc.DataGroup:
+ from packaging.version import Version
+
+ cur_version = Version(version)
+ if cur_version < Version('25.12.0'): # When saving mask and stdev was introduced.
+ return hyperstack_example()['x', :10]['y', :10]
+ elif cur_version < Version('26.1.0'): # When saving data group was introduced.
+ from scitiff.data import hyperstack_example_with_variances_and_mask
+
+ return hyperstack_example_with_variances_and_mask()['x', :10]['y', :10]
+ else:
+ return _example_image_after_2610()
+
+
+def dump_example_scitiff():
+ """Dump an example scitiff file with all possible metadata fields."""
+
+ logger = _get_rich_logger()
+ version = _get_scitiff_version()
+ default_dir = pathlib.Path(__file__).parent.parent / pathlib.Path(
+ 'tests/_regression_test_files'
+ )
+ prefix = 'scitiff_'
+ suffix = '.tiff'
+ new_file_name = ''.join([prefix, version, suffix])
+ new_file_path = default_dir / new_file_name
+ logger.info("Dumping new example scitiff at %s", new_file_path.as_posix())
+ image = _example_image(version=version)
+ logger.info(image)
+ logger.info("Dumping image for version %s", version)
+ save_scitiff(dg=image, file_path=new_file_path)
+ logger.info(
+ "Successfully saved image for version %s in %s",
+ version,
+ new_file_path.as_posix(),
+ )
+
+
+if __name__ == "__main__":
+ dump_example_scitiff()