diff --git a/Makefile b/Makefile index f66297ff3..f7295f3bf 100644 --- a/Makefile +++ b/Makefile @@ -59,7 +59,7 @@ lint: ## check style with flake8 flake8 tiatoolbox tests test: ## run tests quickly with the default Python - pytest + pytest -n auto coverage: ## check code coverage quickly with the default Python pytest --cov=tiatoolbox --cov-report=term --cov-report=html --cov-report=xml diff --git a/requirements/requirements_dev.txt b/requirements/requirements_dev.txt index c80c553f5..3d64a29ed 100644 --- a/requirements/requirements_dev.txt +++ b/requirements/requirements_dev.txt @@ -10,6 +10,7 @@ pre-commit>=2.20.0 pytest>=7.2.0 pytest-cov>=4.0.0 pytest-runner>=6.0 +pytest-xdist[psutil] ruff==0.0.285 # This will be updated by pre-commit bot to latest version toml>=0.10.2 twine>=4.0.1 diff --git a/tests/conftest.py b/tests/conftest.py index 9ab307e39..87ea7f317 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,5 +1,5 @@ """pytest fixtures.""" - +import os import shutil from pathlib import Path from typing import Callable @@ -525,3 +525,36 @@ def moving_mask(remote_sample: Callable) -> Path: Download moving mask for pytest. """ return remote_sample("moving_mask") + + +@pytest.fixture(scope="session") +def chdir() -> Callable: + """Return a context manager to change the current working directory. + + Todo: switch to chdir from contextlib when Python 3.11 is required + + """ + try: + from contextlib import chdir + except ImportError: + from contextlib import AbstractContextManager + + class chdir(AbstractContextManager): # noqa: N801 + """Non thread-safe context manager to change the current working directory. + + See Also: https://github.com/python/cpython/blob/main/Lib/contextlib.py. + + """ + + def __init__(self, path): + self.path = path + self._old_cwd = [] + + def __enter__(self): + self._old_cwd.append(os.getcwd()) # noqa: PTH109 + os.chdir(self.path) + + def __exit__(self, *excinfo): + os.chdir(self._old_cwd.pop()) + + return chdir diff --git a/tests/models/test_hovernetplus.py b/tests/models/test_hovernetplus.py index 86c22a928..96d0f9d23 100644 --- a/tests/models/test_hovernetplus.py +++ b/tests/models/test_hovernetplus.py @@ -1,6 +1,5 @@ """Unit test package for HoVerNet+.""" -from pathlib import Path from typing import Callable import torch @@ -11,9 +10,8 @@ from tiatoolbox.utils.transforms import imresize -def test_functionality(remote_sample: Callable, tmp_path: Path) -> None: +def test_functionality(remote_sample: Callable) -> None: """Functionality test.""" - tmp_path = str(tmp_path) sample_patch = str(remote_sample("stainnorm-source")) patch_pre = imread(sample_patch) patch_pre = imresize(patch_pre, scale_factor=0.5) diff --git a/tests/models/test_patch_predictor.py b/tests/models/test_patch_predictor.py index 0f2dfc06e..3bb0c214f 100644 --- a/tests/models/test_patch_predictor.py +++ b/tests/models/test_patch_predictor.py @@ -471,7 +471,7 @@ def test_io_patch_predictor_config() -> None: # ------------------------------------------------------------------------------------- -def test_predictor_crash() -> None: +def test_predictor_crash(tmp_path: Path) -> None: """Test for crash when making predictor.""" # without providing any model with pytest.raises(ValueError, match=r"Must provide.*"): @@ -489,20 +489,19 @@ def test_predictor_crash() -> None: predictor = PatchPredictor(pretrained_model="resnet18-kather100k", batch_size=32) with pytest.raises(ValueError, match=r".*not a valid mode.*"): - predictor.predict("aaa", mode="random") + predictor.predict("aaa", mode="random", save_dir=tmp_path) # remove previously generated data - if Path.exists(Path("output")): - shutil.rmtree("output", ignore_errors=True) + shutil.rmtree(tmp_path / "output", ignore_errors=True) with pytest.raises(TypeError, match=r".*must be a list of file paths.*"): - predictor.predict("aaa", mode="wsi") + predictor.predict("aaa", mode="wsi", save_dir=tmp_path) # remove previously generated data - shutil.rmtree("output", ignore_errors=True) + shutil.rmtree(tmp_path / "output", ignore_errors=True) with pytest.raises(ValueError, match=r".*masks.*!=.*imgs.*"): - predictor.predict([1, 2, 3], masks=[1, 2], mode="wsi") + predictor.predict([1, 2, 3], masks=[1, 2], mode="wsi", save_dir=tmp_path) with pytest.raises(ValueError, match=r".*labels.*!=.*imgs.*"): - predictor.predict([1, 2, 3], labels=[1, 2], mode="patch") + predictor.predict([1, 2, 3], labels=[1, 2], mode="patch", save_dir=tmp_path) # remove previously generated data - shutil.rmtree("output", ignore_errors=True) + shutil.rmtree(tmp_path / "output", ignore_errors=True) def test_io_config_delegation(remote_sample: Callable, tmp_path: Path) -> None: @@ -622,27 +621,33 @@ def test_patch_predictor_api(sample_patch1, sample_patch2, tmp_path: Path) -> No output = predictor.predict( inputs, on_gpu=ON_GPU, + save_dir=save_dir_path, ) assert sorted(output.keys()) == ["predictions"] assert len(output["predictions"]) == 2 + shutil.rmtree(save_dir_path, ignore_errors=True) output = predictor.predict( inputs, labels=[1, "a"], return_labels=True, on_gpu=ON_GPU, + save_dir=save_dir_path, ) assert sorted(output.keys()) == sorted(["labels", "predictions"]) assert len(output["predictions"]) == len(output["labels"]) assert output["labels"] == [1, "a"] + shutil.rmtree(save_dir_path, ignore_errors=True) output = predictor.predict( inputs, return_probabilities=True, on_gpu=ON_GPU, + save_dir=save_dir_path, ) assert sorted(output.keys()) == sorted(["predictions", "probabilities"]) assert len(output["predictions"]) == len(output["probabilities"]) + shutil.rmtree(save_dir_path, ignore_errors=True) output = predictor.predict( inputs, @@ -650,6 +655,7 @@ def test_patch_predictor_api(sample_patch1, sample_patch2, tmp_path: Path) -> No labels=[1, "a"], return_labels=True, on_gpu=ON_GPU, + save_dir=save_dir_path, ) assert sorted(output.keys()) == sorted(["labels", "predictions", "probabilities"]) assert len(output["predictions"]) == len(output["labels"]) @@ -693,13 +699,14 @@ def test_patch_predictor_api(sample_patch1, sample_patch2, tmp_path: Path) -> No labels=[1, "a"], return_labels=True, on_gpu=ON_GPU, + save_dir=save_dir_path, ) assert sorted(output.keys()) == sorted(["labels", "predictions", "probabilities"]) assert len(output["predictions"]) == len(output["labels"]) assert len(output["predictions"]) == len(output["probabilities"]) -def test_wsi_predictor_api(sample_wsi_dict, tmp_path: Path) -> None: +def test_wsi_predictor_api(sample_wsi_dict, tmp_path: Path, chdir: Callable) -> None: """Test normal run of wsi predictor.""" save_dir_path = tmp_path @@ -711,6 +718,8 @@ def test_wsi_predictor_api(sample_wsi_dict, tmp_path: Path) -> None: patch_size = np.array([224, 224]) predictor = PatchPredictor(pretrained_model="resnet18-kather100k", batch_size=32) + save_dir = f"{save_dir_path}/model_wsi_output" + # wrapper to make this more clean kwargs = { "return_probabilities": True, @@ -720,6 +729,7 @@ def test_wsi_predictor_api(sample_wsi_dict, tmp_path: Path) -> None: "stride_shape": patch_size, "resolution": 1.0, "units": "baseline", + "save_dir": save_dir, } # ! add this test back once the read at `baseline` is fixed # sanity check, both output should be the same with same resolution read args @@ -730,6 +740,8 @@ def test_wsi_predictor_api(sample_wsi_dict, tmp_path: Path) -> None: **kwargs, ) + shutil.rmtree(save_dir, ignore_errors=True) + tile_output = predictor.predict( [mini_wsi_jpg], masks=[mini_wsi_msk], @@ -744,7 +756,6 @@ def test_wsi_predictor_api(sample_wsi_dict, tmp_path: Path) -> None: assert accuracy > 0.9, np.nonzero(~diff) # remove previously generated data - save_dir = save_dir_path / "model_wsi_output" shutil.rmtree(save_dir, ignore_errors=True) kwargs = { @@ -793,26 +804,26 @@ def test_wsi_predictor_api(sample_wsi_dict, tmp_path: Path) -> None: ) # remove previously generated data shutil.rmtree(_kwargs["save_dir"], ignore_errors=True) - shutil.rmtree("output", ignore_errors=True) - # test reading of multiple whole-slide images - _kwargs = copy.deepcopy(kwargs) - _kwargs["save_dir"] = None # default coverage - _kwargs["return_probabilities"] = False - output = predictor.predict( - [mini_wsi_svs, mini_wsi_svs], - masks=[mini_wsi_msk, mini_wsi_msk], - mode="wsi", - **_kwargs, - ) - assert Path.exists(Path("output")) - for output_info in output.values(): - assert Path(output_info["raw"]).exists() - assert "merged" in output_info - assert Path(output_info["merged"]).exists() + with chdir(save_dir_path): + # test reading of multiple whole-slide images + _kwargs = copy.deepcopy(kwargs) + _kwargs["save_dir"] = None # default coverage + _kwargs["return_probabilities"] = False + output = predictor.predict( + [mini_wsi_svs, mini_wsi_svs], + masks=[mini_wsi_msk, mini_wsi_msk], + mode="wsi", + **_kwargs, + ) + assert Path.exists(Path("output")) + for output_info in output.values(): + assert Path(output_info["raw"]).exists() + assert "merged" in output_info + assert Path(output_info["merged"]).exists() - # remove previously generated data - shutil.rmtree("output", ignore_errors=True) + # remove previously generated data + shutil.rmtree("output", ignore_errors=True) def test_wsi_predictor_merge_predictions(sample_wsi_dict) -> None: diff --git a/tests/models/test_semantic_segmentation.py b/tests/models/test_semantic_segmentation.py index b90875948..48a57f0ac 100644 --- a/tests/models/test_semantic_segmentation.py +++ b/tests/models/test_semantic_segmentation.py @@ -253,7 +253,7 @@ def test_functional_wsi_stream_dataset(remote_sample: Callable) -> None: # ------------------------------------------------------------------------------------- -def test_crash_segmentor(remote_sample: Callable) -> None: +def test_crash_segmentor(remote_sample: Callable, tmp_path: Path) -> None: """Functional crash tests for segmentor.""" # # convert to pathlib Path to prevent wsireader complaint mini_wsi_svs = Path(remote_sample("wsi2_4k_4k_svs")) @@ -261,7 +261,10 @@ def test_crash_segmentor(remote_sample: Callable) -> None: mini_wsi_msk = Path(remote_sample("wsi2_4k_4k_msk")) model = _CNNTo1() + + save_dir = tmp_path / "test_crash_segmentor" semantic_segmentor = SemanticSegmentor(batch_size=BATCH_SIZE, model=model) + # fake injection to trigger Segmentor to create parallel # post processing workers because baseline Semantic Segmentor does not support # post processing out of the box. It only contains condition to create it @@ -269,7 +272,6 @@ def test_crash_segmentor(remote_sample: Callable) -> None: semantic_segmentor.num_postproc_workers = 1 # * test basic crash - shutil.rmtree("output", ignore_errors=True) # default output dir test with pytest.raises(TypeError, match=r".*`mask_reader`.*"): semantic_segmentor.filter_coordinates(mini_wsi_msk, np.array(["a", "b", "c"])) with pytest.raises(ValueError, match=r".*ndarray.*integer.*"): @@ -286,7 +288,7 @@ def test_crash_segmentor(remote_sample: Callable) -> None: auto_get_mask=True, ) - shutil.rmtree("output", ignore_errors=True) # default output dir test + shutil.rmtree(save_dir, ignore_errors=True) # default output dir test with pytest.raises(ValueError, match=r".*provide.*"): SemanticSegmentor() with pytest.raises(ValueError, match=r".*valid mode.*"): @@ -299,10 +301,16 @@ def test_crash_segmentor(remote_sample: Callable) -> None: mode="tile", on_gpu=ON_GPU, crash_on_exception=True, + save_dir=save_dir, ) with pytest.raises(ValueError, match=r".*already exists.*"): - semantic_segmentor.predict([], mode="tile", patch_input_shape=(2048, 2048)) - shutil.rmtree("output", ignore_errors=True) # default output dir test + semantic_segmentor.predict( + [], + mode="tile", + patch_input_shape=(2048, 2048), + save_dir=save_dir, + ) + shutil.rmtree(save_dir, ignore_errors=True) # * test not providing any io_config info when not using pretrained model with pytest.raises(ValueError, match=r".*provide either `ioconfig`.*"): @@ -311,8 +319,9 @@ def test_crash_segmentor(remote_sample: Callable) -> None: mode="tile", on_gpu=ON_GPU, crash_on_exception=True, + save_dir=save_dir, ) - shutil.rmtree("output", ignore_errors=True) # default output dir test + shutil.rmtree(save_dir, ignore_errors=True) # * Test crash propagation when parallelize post-processing semantic_segmentor.num_postproc_workers = 2 @@ -324,8 +333,10 @@ def test_crash_segmentor(remote_sample: Callable) -> None: mode="wsi", on_gpu=ON_GPU, crash_on_exception=True, + save_dir=save_dir, ) - shutil.rmtree("output", ignore_errors=True) + shutil.rmtree(save_dir, ignore_errors=True) + # test ignore crash semantic_segmentor.predict( [mini_wsi_svs], @@ -333,8 +344,8 @@ def test_crash_segmentor(remote_sample: Callable) -> None: mode="wsi", on_gpu=ON_GPU, crash_on_exception=False, + save_dir=save_dir, ) - shutil.rmtree("output", ignore_errors=True) def test_functional_segmentor_merging(tmp_path: Path) -> None: @@ -444,7 +455,11 @@ def test_functional_segmentor_merging(tmp_path: Path) -> None: del canvas # skipcq -def test_functional_segmentor(remote_sample: Callable, tmp_path: Path) -> None: +def test_functional_segmentor( + remote_sample: Callable, + tmp_path: Path, + chdir: Callable, +) -> None: """Functional test for segmentor.""" save_dir = tmp_path / "dump" # # convert to pathlib Path to prevent wsireader complaint @@ -458,7 +473,7 @@ def test_functional_segmentor(remote_sample: Callable, tmp_path: Path) -> None: imwrite(mini_wsi_msk, (thumb > 0).astype(np.uint8)) # preemptive clean up - shutil.rmtree("output", ignore_errors=True) # default output dir test + shutil.rmtree(save_dir, ignore_errors=True) model = _CNNTo1() semantic_segmentor = SemanticSegmentor(batch_size=BATCH_SIZE, model=model) # fake injection to trigger Segmentor to create parallel @@ -476,9 +491,10 @@ def test_functional_segmentor(remote_sample: Callable, tmp_path: Path) -> None: resolution=resolution, units="mpp", crash_on_exception=False, + save_dir=save_dir, ) - shutil.rmtree("output", ignore_errors=True) # default output dir test + shutil.rmtree(save_dir, ignore_errors=True) semantic_segmentor.predict( [mini_wsi_jpg], mode="tile", @@ -487,23 +503,28 @@ def test_functional_segmentor(remote_sample: Callable, tmp_path: Path) -> None: resolution=1 / resolution, units="baseline", crash_on_exception=True, + save_dir=save_dir, ) - shutil.rmtree("output", ignore_errors=True) # default output dir test + shutil.rmtree(save_dir, ignore_errors=True) - # * check exception bypass in the log - # there should be no exception, but how to check the log? - semantic_segmentor.predict( - [mini_wsi_jpg], - mode="tile", - on_gpu=ON_GPU, - patch_input_shape=(512, 512), - patch_output_shape=(512, 512), - stride_shape=(512, 512), - resolution=1 / resolution, - units="baseline", - crash_on_exception=False, - ) - shutil.rmtree("output", ignore_errors=True) # default output dir test + with chdir(tmp_path): + # * check exception bypass in the log + # there should be no exception, but how to check the log? + semantic_segmentor.predict( + [mini_wsi_jpg], + mode="tile", + on_gpu=ON_GPU, + patch_input_shape=(512, 512), + patch_output_shape=(512, 512), + stride_shape=(512, 512), + resolution=1 / resolution, + units="baseline", + crash_on_exception=False, + ) + shutil.rmtree( + tmp_path / "output", + ignore_errors=True, + ) # default output dir test # * test basic running and merging prediction # * should dumping all 1 in the output diff --git a/tests/test_init.py b/tests/test_init.py index 1fbe1ecf3..7b59b745b 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -12,14 +12,14 @@ from tiatoolbox import DuplicateFilter, logger -def test_set_root_dir() -> None: +def test_set_root_dir(tmp_path: Path) -> None: """Test for setting new root dir.""" # skipcq importlib.reload(tiatoolbox) from tiatoolbox import rcParam old_root_dir = rcParam["TIATOOLBOX_HOME"] - test_dir_path = Path.cwd() / "tmp_check" + test_dir_path = tmp_path / "tmp_check" # clean up previous test if Path.exists(test_dir_path): Path.rmdir(test_dir_path) diff --git a/tests/test_utils.py b/tests/test_utils.py index cb417ffb9..4eb2d42cd 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -16,7 +16,7 @@ from shapely.geometry import Polygon from tests.test_annotation_stores import cell_polygon -from tiatoolbox import rcParam, utils +from tiatoolbox import utils from tiatoolbox.models.architecture import fetch_pretrained_weights from tiatoolbox.utils import misc from tiatoolbox.utils.exceptions import FileNotSupportedError @@ -1012,13 +1012,12 @@ def test_grab_files_from_dir(sample_visual_fields) -> None: assert len(out) == 0 -def test_download_unzip_data() -> None: +def test_download_unzip_data(tmp_path: Path) -> None: """Test download and unzip data from utils.misc.""" url = "https://tiatoolbox.dcs.warwick.ac.uk/testdata/utils/test_directory.zip" - save_dir_path = rcParam["TIATOOLBOX_HOME"] / "tmp/" - if Path.exists(save_dir_path): - shutil.rmtree(save_dir_path, ignore_errors=True) - save_dir_path.mkdir(parents=True) + save_dir_path = tmp_path / "tmp" + + save_dir_path.mkdir() save_zip_path1 = misc.download_data(url, save_dir=save_dir_path) save_zip_path2 = misc.download_data( url, @@ -1040,12 +1039,11 @@ def test_download_unzip_data() -> None: shutil.rmtree(save_dir_path, ignore_errors=True) -def test_download_data() -> None: +def test_download_data(tmp_path: Path) -> None: """Test download data from utils.misc.""" url = "https://tiatoolbox.dcs.warwick.ac.uk/testdata/utils/test_directory.zip" - save_dir_path = rcParam["TIATOOLBOX_HOME"] / "tmp/" - if Path.exists(save_dir_path): - shutil.rmtree(save_dir_path, ignore_errors=True) + + save_dir_path = tmp_path / "downloads" save_zip_path = save_dir_path / "test_directory.zip" misc.download_data(url, save_zip_path, overwrite=True) # overwrite diff --git a/tests/test_wsireader.py b/tests/test_wsireader.py index c4ccdc520..2d5fb1657 100644 --- a/tests/test_wsireader.py +++ b/tests/test_wsireader.py @@ -8,7 +8,6 @@ import shutil from copy import deepcopy from pathlib import Path -from time import time # When no longer supporting Python <3.9 this should be collections.abc.Iterable from typing import Callable, Iterable @@ -25,7 +24,7 @@ from skimage.morphology import binary_dilation, disk, remove_small_objects from skimage.registration import phase_cross_correlation -from tiatoolbox import cli, rcParam, utils +from tiatoolbox import cli, utils from tiatoolbox.annotation import SQLiteStore from tiatoolbox.utils import imread from tiatoolbox.utils.exceptions import FileNotSupportedError @@ -77,11 +76,6 @@ # ------------------------------------------------------------------------------------- -def _get_temp_folder_path(prefix: str = "temp") -> Path: - """Return unique temp folder path.""" - return rcParam["TIATOOLBOX_HOME"] / f"{prefix}-{int(time())}" - - def strictly_increasing(sequence: Iterable) -> bool: """Return True if sequence is strictly increasing. @@ -1459,6 +1453,7 @@ def test_wsireader_open( sample_jp2: Path, sample_ome_tiff, source_image, + tmp_path, ) -> None: """Test WSIReader.open() to return correct object.""" with pytest.raises(FileNotSupportedError): @@ -1492,13 +1487,10 @@ def test_wsireader_open( assert isinstance(wsi_out, wsi_type) # test loading .npy - temp_dir = _get_temp_folder_path() - temp_dir.mkdir() - temp_file = f"{temp_dir}/sample.npy" + temp_file = str(tmp_path / "sample.npy") np.save(temp_file, RNG.integers(1, 255, [5, 5, 5])) wsi_out = WSIReader.open(temp_file) assert isinstance(wsi_out, VirtualWSIReader) - shutil.rmtree(temp_dir) def test_jp2_missing_cod(sample_jp2: Path, caplog: pytest.LogCaptureFixture) -> None: