Skip to content

Commit e3a26f2

Browse files
committed
initial pass on adding integration test capability to snapblue
1 parent fb93568 commit e3a26f2

22 files changed

+2573
-528
lines changed

.gitmodules

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
[submodule "tests/data/snapred-data"]
2+
path = tests/data/snapred-data
3+
url = https://code.ornl.gov/sns-hfir-scse/infrastructure/test-data/snapred-data.git
4+
branch = main

environment.yml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,10 @@ channels:
66
- mantid-ornl/label/rc
77
- neutrons/label/rc
88
dependencies:
9-
- snapred==1.1.0rc5
109
# SNAPBlue specific dependencies
10+
- snapred==1.1.0rc5
1111
- scikit-image
12+
- git-lfs
1213
# -- Runtime dependencies
1314
# base: list all base dependencies here
1415
- python>=3.8 # please specify the minimum version of python here

pixi.lock

Lines changed: 1202 additions & 524 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pyproject.toml

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -74,15 +74,18 @@ packagename-cli = "packagenamepy.packagename:main"
7474
packagenamepy = "packagenamepy.packagename:gui"
7575

7676
[tool.pytest.ini_options]
77-
addopts = "-v --cov=packagenamepy --cov-report=term-missing"
77+
addopts = "-m 'not (integration or datarepo)' -v --cov=packagenamepy --cov-report=term-missing"
7878
pythonpath = [
7979
".", "src", "scripts"
8080
]
8181
testpaths = ["tests"]
8282
python_files = ["test*.py"]
8383
norecursedirs = [".git", "tmp*", "_tmp*", "__pycache__", "*dataset*", "*data_set*"]
8484
markers = [
85-
"mymarker: example markers goes here"
85+
"integration: mark a test as an integration test",
86+
"mount_snap: mark a test as using /SNS/SNAP/ data mount",
87+
"golden_data(*, path=None, short_name=None, date=None): mark golden data to use with a test",
88+
"datarepo: mark a test as using snapred-data repo"
8689
]
8790

8891
[tool.ruff]
@@ -124,7 +127,7 @@ test = { cmd = "pytest", description = "Run the tests" } # pytest config above
124127
clean-all = { description = "Clean all build artifacts", depends-on = ["clean-pypi", "clean-conda", "clean-docs"] }
125128

126129
[tool.pixi.dependencies]
127-
snapred = "==1.1.0rc4"
130+
snapred = "==1.1.0rc5"
128131
scikit-image = "*"
129132
python = ">=3.8"
130133
versioningit = "*"

src/snapblue/_version.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
__version__ = "0.1.0.dev41+d202503311554"

src/snapblue/meta/Config.py

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import importlib.resources as resources
2+
3+
import os
4+
import sys
5+
6+
from pathlib import Path
7+
8+
from snapred.meta.Config import Resource as RedResource, Config
9+
10+
def _find_root_dir():
11+
try:
12+
MODULE_ROOT = Path(sys.modules["snapblue"].__file__).parent
13+
14+
# Using `"test" in env` here allows different versions of "[category]_test.yml" to be used for different
15+
# test categories: e.g. unit tests use "test.yml" but integration tests use "integration_test.yml".
16+
env = os.environ.get("snapblue_env")
17+
if env and "test" in env and "conftest" in sys.modules:
18+
# WARNING: there are now multiple "conftest.py" at various levels in the test hierarchy.
19+
MODULE_ROOT = MODULE_ROOT.parent.parent / "tests"
20+
except Exception as e:
21+
raise RuntimeError("Unable to determine SNAPBlue module-root directory") from e
22+
23+
return str(MODULE_ROOT)
24+
25+
class _Resource:
26+
_packageMode: bool
27+
_resourcesPath: str
28+
29+
def __init__(self):
30+
# where the location of resources are depends on whether or not this is in package mode
31+
self._packageMode = not self._existsInPackage("application.yml")
32+
if self._packageMode:
33+
self._resourcesPath = "/resources/"
34+
else:
35+
self._resourcesPath = os.path.join(_find_root_dir(), "resources/")
36+
37+
def _existsInPackage(self, subPath) -> bool:
38+
with resources.path("snapblue.resources", subPath) as path:
39+
return os.path.exists(path)
40+
41+
def exists(self, subPath) -> bool:
42+
if self._packageMode:
43+
return self._existsInPackage(subPath)
44+
else:
45+
return os.path.exists(self.getPath(subPath))
46+
47+
def getPath(self, subPath):
48+
if subPath.startswith("/"):
49+
return os.path.join(self._resourcesPath, subPath[1:])
50+
else:
51+
return os.path.join(self._resourcesPath, subPath)
52+
53+
def read(self, subPath):
54+
with self.open(subPath, "r") as file:
55+
return file.read()
56+
57+
def open(self, subPath, mode): # noqa: A003
58+
if self._packageMode:
59+
with resources.path("snapblue.resources", subPath) as path:
60+
return open(path, mode)
61+
else:
62+
return open(self.getPath(subPath), mode)
63+
64+
65+
Resource = _Resource()
66+
RedResource._resourcesPath = Resource._resourcesPath
67+
RedResource._packageMode = Resource._packageMode
68+
# use refresh to do initial load, clearing shouldn't matter
69+
Config.refresh("application.yml")
70+
71+
# ---------- SNAPRed-internal values: --------------------------
72+
# allow "resources" relative paths to be entered into the "yml"
73+
# using "${module.root}"
74+
Config._config["module"] = {}
75+
Config._config["module"]["root"] = _find_root_dir()
76+
77+
Config._config["version"] = Config._config.get("version", {})
78+
Config._config["version"]["default"] = -1
79+
# ---------- end: internal values: -----------------------------
80+
81+
# see if user used environment injection to modify what is needed
82+
# this will get from the os environment or from the currently loaded one
83+
# first case wins
84+
env = os.environ.get("snapblue_env", Config._config.get("environment", None))
85+
if env is not None:
86+
Config.refresh(env)
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
# put snapred application.yml overrides here
2+
IPTS:
3+
default: /SNS
4+
root: /SNS

tests/conftest.py

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
import os
2+
3+
import pytest
4+
import unittest.mock as mock
5+
6+
from snapred.meta.decorators import Resettable
7+
from snapred.meta.decorators.Singleton import reset_Singletons
8+
9+
# import sys
10+
# sys.path.append('.')
11+
# os.environ['PYTHONPATH'] = './src'
12+
13+
# Allow override: e.g. `env=dev pytest ...`
14+
# if not os.environ.get("env"):
15+
# os.environ["env"] = "test"
16+
17+
from mantid.kernel import ConfigService # noqa: E402
18+
from snapblue.meta.Config import ( # noqa: E402
19+
Config, # noqa: E402
20+
Resource, # noqa: E402
21+
)
22+
23+
24+
# PATCH the `unittest.mock.Mock` class: BANNED FUNCTIONS
25+
def banned_function(function_name: str):
26+
_error_message: str = f"`Mock.{function_name}` is a mock, it always evaluates to True. Use `Mock.assert_{function_name}` instead."
27+
28+
def _banned_function(self, *args, **kwargs):
29+
nonlocal _error_message # this line should not be necessary!
30+
31+
# Ensure that the complete message is in the pytest-captured output stream:
32+
print(_error_message)
33+
34+
raise RuntimeError(_error_message)
35+
36+
return _banned_function
37+
38+
# `mock.Mock.called` is OK: it exists as a boolean attribute
39+
mock.Mock.called_once = banned_function("called_once")
40+
mock.Mock.called_once_with = banned_function("called_once_with")
41+
mock.Mock.called_with = banned_function("called_with")
42+
mock.Mock.not_called = banned_function("not_called")
43+
44+
45+
def mock_decorator(orig_cls):
46+
return orig_cls
47+
48+
###### PATCH THE DECORATORS HERE ######
49+
50+
mockResettable = mock.Mock()
51+
mockResettable.Resettable = mock_decorator
52+
mock.patch.dict("sys.modules", {"snapred.meta.decorators.Resettable": mockResettable}).start()
53+
mock.patch.dict("sys.modules", {"snapred.meta.decorators._Resettable": Resettable}).start()
54+
55+
mantidConfig = config = ConfigService.Instance()
56+
mantidConfig["CheckMantidVersion.OnStartup"] = "0"
57+
mantidConfig["UpdateInstrumentDefinitions.OnStartup"] = "0"
58+
mantidConfig["usagereports.enabled"] = "0"
59+
60+
#######################################
61+
62+
# this at teardown removes the loggers, eliminating logger-related error printouts
63+
# see https://github.com/pytest-dev/pytest/issues/5502#issuecomment-647157873
64+
@pytest.fixture(autouse=True, scope="session")
65+
def clear_loggers(): # noqa: PT004
66+
"""Remove handlers from all loggers"""
67+
import logging
68+
69+
yield # ... teardown follows:
70+
loggers = [logging.getLogger()] + list(logging.Logger.manager.loggerDict.values())
71+
for logger in loggers:
72+
handlers = getattr(logger, "handlers", [])
73+
for handler in handlers:
74+
logger.removeHandler(handler)
75+
76+
########################################################################################################################
77+
# In combination, the following autouse fixtures allow unit tests and integration tests
78+
# to be run successfully without mocking out the `@Singleton` decorator.
79+
#
80+
# * The main objective is to allow the `@Singleton` classes to function as singletons, for the
81+
# duration of the single-test scope. This functionality is necessary, for example, in order for
82+
# the `Indexer` class to function correctly during the state-initialization sequence.
83+
#
84+
# * There are some fine points involved with using `@Singleton` classes during testing at class and module scope.
85+
# Such usage should probably be avoided whenever possible. It's a bit tricky to get this to work correctly
86+
# within the test framework.
87+
#
88+
# * TODO: Regardless of these fixtures, at the moment the `@Singleton` decorator must be completely turned ON during
89+
# integration tests, without any modification (e.g. or "reset"). There is something going on at "session" scope
90+
# with specific singletons not being deleted between tests, which results in multiple singleton instances when the
91+
# fixtures are used. This behavior does not seem to be an issue for the unit tests.
92+
# We can track this down by turning on the garbage collector `gc`, but this work has not yet been completed.
93+
#
94+
# Implementation notes:
95+
#
96+
# * Right now, there are > 36 `@Singleton` decorated classes. Probably, there should be far fewer.
97+
# Almost none of these classes are compute-intensive to initialize, or retain any cached data.
98+
# These would be the normal justifications for the use of this pattern.
99+
#
100+
# * Applying the `@Singleton` decorator changes the behavior of the classes,
101+
# so we don't want to mock the decorator out during testing. At present, the key class where this is important
102+
# is the `Indexer` class, which is not itself a singleton, but which is owned and cached
103+
# by the `LocalDataService` singleton. `Indexer` instances retain local data about indexing events
104+
# that have occurred since their initialization.
105+
#
106+
107+
@pytest.fixture(autouse=True)
108+
def _reset_Singletons(request):
109+
if not "integration" in request.keywords:
110+
reset_Singletons()
111+
yield
112+
113+
@pytest.fixture(scope="class", autouse=True)
114+
def _reset_class_scope_Singletons(request):
115+
if not "integration" in request.keywords:
116+
reset_Singletons()
117+
yield
118+
119+
@pytest.fixture(scope="module", autouse=True)
120+
def _reset_module_scope_Singletons(request):
121+
if not "integration" in request.keywords:
122+
reset_Singletons()
123+
yield
124+
125+
########################################################################################################################
126+
127+
128+
## Import various `pytest.fixture` defined in separate `tests/util` modules:
129+
# -------------------------------------------------------------------------
130+
# *** IMPORTANT WARNING: these must be included _after_ the `Singleton` decorator is patched ! ***
131+
# * Otherwise, the modules imported by these will not have the patched decorator applied to them.
132+
133+
# from util.golden_data import goldenData, goldenDataFilePath
134+
# from util.state_helpers import state_root_fixture
135+
# from util.IPTS_override import IPTS_override_fixture
136+
from util.Config_helpers import Config_override_fixture
137+
from util.pytest_helpers import (
138+
calibration_home_from_mirror,
139+
cleanup_workspace_at_exit,
140+
cleanup_class_workspace_at_exit,
141+
get_unique_timestamp,
142+
reduction_home_from_mirror
143+
)

tests/data/snapred-data

Submodule snapred-data added at 60ee8c5
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
2+
3+
import pytest
4+
from util.pytest_helpers import calibration_home_from_mirror, handleStateInit, reduction_home_from_mirror # noqa: F401
5+
from snapblue.meta.Config import Config
6+
from pathlib import Path
7+
8+
9+
@pytest.mark.integration
10+
@pytest.mark.datarepo
11+
def test_calibrationHomeExists(calibration_home_from_mirror):
12+
tmpCalibrationHomeDirectory = calibration_home_from_mirror()
13+
calibrationHomePath = Path(Config["instrument.calibration.home"])
14+
assert calibrationHomePath.exists()
15+
iptsHomePath = Path(Config["IPTS.root"])
16+
assert iptsHomePath.exists()

0 commit comments

Comments
 (0)