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()