From 5f3c634c05efadad8dbd1667ef2da6799597abda Mon Sep 17 00:00:00 2001 From: Constantinos Eleftheriou Date: Tue, 13 Oct 2026 14:53:29 +0100 Subject: [PATCH 1/4] add context manager to interim working dir --- src/mesoscopy/io.py | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/src/mesoscopy/io.py b/src/mesoscopy/io.py index 0696193..1454e59 100644 --- a/src/mesoscopy/io.py +++ b/src/mesoscopy/io.py @@ -20,9 +20,12 @@ # SOFTWARE. from __future__ import annotations +import contextlib import csv +import tempfile import typing from collections import OrderedDict +from collections.abc import Iterator from pathlib import Path import h5py @@ -183,6 +186,32 @@ def store_interim( return zarr.load(interim_path) +@contextlib.contextmanager +def interim_workdir(interim_dir: str | Path, prefix: str = "") -> Iterator[str]: + """Create a fresh directory for interim files inside an interim directory, removed on exit. + + Only the fresh directory is removed, so files already in the interim directory are kept. The interim directory + itself is removed only if it was created here and is left empty. + + Args: + interim_dir (str or PathLike object): Directory to create the fresh directory in. Created if missing. + prefix (str, optional): Prefix for the fresh directory name. Defaults to "". + + Yields: + str: Path to the fresh directory. + """ + parent = Path(interim_dir) + created = not parent.is_dir() + parent.mkdir(parents=True, exist_ok=True) + try: + with tempfile.TemporaryDirectory(prefix=prefix, dir=parent) as workdir: + yield workdir + finally: + if created: + with contextlib.suppress(OSError): + parent.rmdir() + + def load_deltaf(path: str, nwb: bool = False) -> tuple[str, np.ndarray, np.ndarray]: """Load preprocessed dF/F from an HDF5 or NWB file. From c8d904da1a6c01bc9f7a07d2a308283676d645d3 Mon Sep 17 00:00:00 2001 From: Constantinos Eleftheriou Date: Tue, 13 Oct 2026 14:56:05 +0100 Subject: [PATCH 2/4] use interim dir with context manager --- src/mesoscopy/preprocess/__init__.py | 400 ++++++++++++++------------- 1 file changed, 201 insertions(+), 199 deletions(-) diff --git a/src/mesoscopy/preprocess/__init__.py b/src/mesoscopy/preprocess/__init__.py index acdebf8..b944db2 100644 --- a/src/mesoscopy/preprocess/__init__.py +++ b/src/mesoscopy/preprocess/__init__.py @@ -21,7 +21,6 @@ from __future__ import annotations import os -import shutil import time import typing @@ -82,7 +81,7 @@ type=click.Path(dir_okay=True), default="interim/", show_default=True, - help="Directory for intermediate files, removed when preprocessing ends.", + help="Directory under which intermediate files are kept until preprocessing ends.", ) @click.option( "--skip-start", @@ -145,7 +144,7 @@ def run_preprocessing( use_means (bool, optional): Use means histogram instead of standard deviation to separate channels. Defaults to False. flip_channels (bool, optional): Flip extracted channel order. Defaults to False. - interim_dir (str, optional): Path to the interim directory. Defaults to "interim/". + interim_dir (str, optional): Directory under which intermediate files are kept. Defaults to "interim/". skip_start (int, optional): Number of frames to skip at the start of the recording. Defaults to None. skip_end (int, optional): Number of frames to skip at the end of the recording. Defaults to None. no_qa (bool, optional): Skip automatic quality control checks. Defaults to False. @@ -161,7 +160,6 @@ def run_preprocessing( preprocessing_start = time.time() os.makedirs(out_dir, exist_ok=True) - os.makedirs(interim_dir, exist_ok=True) click.echo("Loading data...") @@ -195,201 +193,208 @@ def run_preprocessing( raw_frames = raw_frames[:, crop:-crop, crop:-crop] click.echo(f"Cropping to shape {raw_frames.shape}") - with timer.Timer( - f"Binning frames to shape {raw_frames.shape[1] // bins} by {raw_frames.shape[2] // bins} ({bins}x{bins})" - ): - binned_frames = calc.bin_array( - raw_frames, - bins=bins, - interim_dir=interim_dir, - session_id=session_id, - ) - - del raw_frames - - # Channel separation - # Get the global mean and std values for each frame - with timer.Timer("Calculating frame means & standard deviations"): - frame_means, frame_stds = calc.frame_statistics(binned_frames) - - # Generate the channel separation filters - with timer.Timer("Generating channel separation filters"): - gcamp_filter, isosb_filter = calc.channel_separation_filters( - frame_means, - frame_stds, - use_means=use_means, - flip_channels=flip_channels, - ) - - with timer.Timer("Calculating channel mean timeseries"): - gcamp_mean, isosb_mean = dask.compute( - binned_frames[gcamp_filter].mean(axis=(1, 2), dtype=np.float32), - binned_frames[isosb_filter].mean(axis=(1, 2), dtype=np.float32), - ) - - if channel_means_only: - np.savetxt( - os.path.join(out_dir, f"{session_id}_gcamp_mean.txt"), - gcamp_mean, - fmt="%.4f", - ) - np.savetxt( - os.path.join(out_dir, f"{session_id}_isosb_mean.txt"), - isosb_mean, - fmt="%.4f", - ) - click.echo("Channel means saved as txt files. Exiting.") - return - - gcamp_channel = binned_frames[gcamp_filter] - isosb_channel = binned_frames[isosb_filter] - - with timer.Timer("Generating projection images per channel"): - gcamp_projections = calc.projections(gcamp_channel) - isosb_projections = calc.projections(isosb_channel) - - # Calculate the dff per channel using a rolling baseline (mean in a 30s window) - window_width = 30 * 25 - - if window_width > binned_frames.shape[0]: - click.echo( - f"WARNING: Default window width is larger than the number of frames. Setting window width to { - binned_frames.shape[0] // 4 - }." - ) - window_width = binned_frames.shape[0] // 4 - - with timer.Timer("Calculating dF/F per channel"): - gcamp_dff = calc.rolling_dff( - gcamp_channel, - window_width=window_width, - channel_name="gcamp", - interim_dir=interim_dir, - session_id=session_id, - ) - isosb_dff = calc.rolling_dff( - isosb_channel, - window_width=window_width, - channel_name="isosb", - interim_dir=interim_dir, - session_id=session_id, - ) - - with timer.Timer("Calculating mean ∂F per frame per channel"): - gcamp_signal_mean, isosb_signal_mean = da.compute(gcamp_dff.mean(axis=(1, 2)), isosb_dff.mean(axis=(1, 2))) - - # Max common index (to avoid array overflow) - if len(gcamp_mean) != len(isosb_mean): - click.echo("WARNING: GCaMP & Isosb channels have mismatching indexes") - max_idx = min(len(gcamp_mean), len(isosb_mean)) - - click.echo("Extracting corrected F signal (gcamp - isosb)...") - - with timer.Timer("Calculating F signal"): - f_signal = da.subtract( - gcamp_dff[:max_idx], - isosb_dff[:max_idx], - ) - - with timer.Timer("Calculating mean F timeseries"): - f_mean_timeseries = f_signal.mean(axis=(1, 2)) - - with timer.Timer("Generating maximum intensity projection for F signal"): - f_maxip = calc.projections(f_signal).get("maxip") - - timestamps = da.from_array(np.array(ts[gcamp_filter[:max_idx]], dtype="S25"), chunks="auto") - - # Quality control checks - qa_histogram_separation = None - qa_timestamp_consistency = None - qa_timestamp_jump = None - qa_check_noise, qa_noise_levels = None, None - qa_check_snr, qa_snr = None, None - qa_check_bleaching, qa_bleaching = None, None - if not no_qa: - click.echo("Running QA checks...") - - qa_histogram_separation = qa.check_histogram_separation(np.array(frame_stds if not use_means else frame_means)) - if qa_histogram_separation: - click.echo("✅ Histogram separation check passed.") - else: - click.echo("❌ ERROR - histogram separation failed, channels could not be fully separated!") - - qa_timestamp_consistency = qa.check_timestamp_consistency(timestamps) - if qa_timestamp_consistency: - click.echo("✅ Timestamp consistency check passed, low interval standard deviation.") - else: - click.echo("❌ ERROR - timestamp consistency check failed, timestamp drift detected!!") - - qa_timestamp_jump = qa.check_timestamp_jumps(timestamps) - if qa_timestamp_jump: - click.echo("✅ Timestamp jump check passed, no timestamp interval above two standard deviations.") - else: - click.echo("❌ ERROR - timestamp jump check failed, intervals above two standard deviations detected!") - - qa_check_noise, qa_noise_levels = qa.check_noise(f_signal, return_noise=True) - if qa_check_noise: - click.echo(f"✅ Noise level check passed, median recording shot noise {np.median(qa_noise_levels):.2f} %.") - else: + with io.interim_workdir(interim_dir, prefix=f"{session_id}_") as workdir: + with timer.Timer( + f"Binning frames to shape {raw_frames.shape[1] // bins} by {raw_frames.shape[2] // bins} ({bins}x{bins})" + ): + binned_frames = calc.bin_array( + raw_frames, + bins=bins, + interim_dir=workdir, + session_id=session_id, + ) + + del raw_frames + + # Channel separation + # Get the global mean and std values for each frame + with timer.Timer("Calculating frame means & standard deviations"): + frame_means, frame_stds = calc.frame_statistics(binned_frames) + + # Generate the channel separation filters + with timer.Timer("Generating channel separation filters"): + gcamp_filter, isosb_filter = calc.channel_separation_filters( + frame_means, + frame_stds, + use_means=use_means, + flip_channels=flip_channels, + ) + + with timer.Timer("Calculating channel mean timeseries"): + gcamp_mean, isosb_mean = dask.compute( + binned_frames[gcamp_filter].mean(axis=(1, 2), dtype=np.float32), + binned_frames[isosb_filter].mean(axis=(1, 2), dtype=np.float32), + ) + + if channel_means_only: + np.savetxt( + os.path.join(out_dir, f"{session_id}_gcamp_mean.txt"), + gcamp_mean, + fmt="%.4f", + ) + np.savetxt( + os.path.join(out_dir, f"{session_id}_isosb_mean.txt"), + isosb_mean, + fmt="%.4f", + ) + click.echo("Channel means saved as txt files. Exiting.") + return + + gcamp_channel = binned_frames[gcamp_filter] + isosb_channel = binned_frames[isosb_filter] + + with timer.Timer("Generating projection images per channel"): + gcamp_projections = calc.projections(gcamp_channel) + isosb_projections = calc.projections(isosb_channel) + + # Calculate the dff per channel using a rolling baseline (mean in a 30s window) + window_width = 30 * 25 + + if window_width > binned_frames.shape[0]: click.echo( - "❌ ERROR - noise level check failed, recording has high noise " - f"(median {np.median(qa_noise_levels):.2f} %)!" + f"WARNING: Default window width is larger than the number of frames. Setting window width to { + binned_frames.shape[0] // 4 + }." + ) + window_width = binned_frames.shape[0] // 4 + + with timer.Timer("Calculating dF/F per channel"): + gcamp_dff = calc.rolling_dff( + gcamp_channel, + window_width=window_width, + channel_name="gcamp", + interim_dir=workdir, + session_id=session_id, ) + isosb_dff = calc.rolling_dff( + isosb_channel, + window_width=window_width, + channel_name="isosb", + interim_dir=workdir, + session_id=session_id, + ) + + with timer.Timer("Calculating mean ∂F per frame per channel"): + gcamp_signal_mean, isosb_signal_mean = da.compute(gcamp_dff.mean(axis=(1, 2)), isosb_dff.mean(axis=(1, 2))) + + # Max common index (to avoid array overflow) + if len(gcamp_mean) != len(isosb_mean): + click.echo("WARNING: GCaMP & Isosb channels have mismatching indexes") + max_idx = min(len(gcamp_mean), len(isosb_mean)) + + click.echo("Extracting corrected F signal (gcamp - isosb)...") + + with timer.Timer("Calculating F signal"): + f_signal = da.subtract( + gcamp_dff[:max_idx], + isosb_dff[:max_idx], + ) + + with timer.Timer("Calculating mean F timeseries"): + f_mean_timeseries = f_signal.mean(axis=(1, 2)) - qa_check_snr, qa_snr = qa.check_snr(f_signal, return_snr=True) - if qa_check_snr: - click.echo(f"✅ SNR check passed (recording SNR is {qa_snr:.2f}).") - else: - click.echo(f"❌ ERROR - SNR check failed (recording SNR is {qa_snr:.2f})!") - - qa_check_bleaching, qa_bleaching = qa.check_bleaching(f_mean_timeseries, return_slope=True) - if qa_check_bleaching: - click.echo("✅ Photobleaching check passed.") - else: - click.echo(f"❌ ERROR - photobleaching check failed (bleaching factor is {qa_bleaching})") - - outpath = out_dir + os.sep + session_id + "_preprocessed.h5" - with timer.Timer(f"Writing data to {outpath}"): - # QA results are None when the checks are skipped, and cannot be written. - io.write_h5( - path=outpath, - data=_drop_none( - { - "/F": f_signal, - "/timestamps": timestamps, - "/qa/frame_means_timeseries": frame_means, - "/qa/frame_stds_timeseries": frame_stds, - "/qa/gcamp_mean_timeseries": gcamp_mean[:max_idx], - "/qa/isosb_mean_timeseries": isosb_mean[:max_idx], - "/qa/gcamp_dff_timeseries": gcamp_signal_mean[:max_idx], - "/qa/isosb_dff_timeseries": isosb_signal_mean[:max_idx], - "/qa/gcamp_filter": gcamp_filter[:max_idx], - "/qa/isosb_filter": isosb_filter[:max_idx], - "/qa/gcamp_mean_projection": gcamp_projections.get("mean"), - "/qa/gcamp_std_projection": gcamp_projections.get("std"), - "/qa/gcamp_maxip_projection": gcamp_projections.get("maxip"), - "/qa/isosb_mean_projection": isosb_projections.get("mean"), - "/qa/isosb_std_projection": isosb_projections.get("std"), - "/qa/isosb_maxip_projection": isosb_projections.get("maxip"), - "/qa/f_mean_timeseries": f_mean_timeseries, - "/qa/f_maxip": f_maxip, - "/qa/noise_levels": qa_noise_levels, - } - ), - attributes=_drop_none( - { - "/qa/checks/histogram_separation": qa_histogram_separation, - "/qa/checks/timestamp_consistency": qa_timestamp_consistency, - "/qa/checks/timestamp_jump": qa_timestamp_jump, - "/qa/checks/noise_check": qa_check_noise, - "/qa/checks/snr_check": qa_check_snr, - "/qa/checks/snr": qa_snr, - "/qa/checks/bleaching_check": qa_check_bleaching, - "/qa/checks/bleaching_factor": qa_bleaching, - } - ), - compression="lzf", - ) + with timer.Timer("Generating maximum intensity projection for F signal"): + f_maxip = calc.projections(f_signal).get("maxip") + + timestamps = da.from_array(np.array(ts[gcamp_filter[:max_idx]], dtype="S25"), chunks="auto") + + # Quality control checks + qa_histogram_separation = None + qa_timestamp_consistency = None + qa_timestamp_jump = None + qa_check_noise, qa_noise_levels = None, None + qa_check_snr, qa_snr = None, None + qa_check_bleaching, qa_bleaching = None, None + if not no_qa: + click.echo("Running QA checks...") + + qa_histogram_separation = qa.check_histogram_separation( + np.array(frame_stds if not use_means else frame_means) + ) + if qa_histogram_separation: + click.echo("✅ Histogram separation check passed.") + else: + click.echo("❌ ERROR - histogram separation failed, channels could not be fully separated!") + + qa_timestamp_consistency = qa.check_timestamp_consistency(timestamps) + if qa_timestamp_consistency: + click.echo("✅ Timestamp consistency check passed, low interval standard deviation.") + else: + click.echo("❌ ERROR - timestamp consistency check failed, timestamp drift detected!!") + + qa_timestamp_jump = qa.check_timestamp_jumps(timestamps) + if qa_timestamp_jump: + click.echo("✅ Timestamp jump check passed, no timestamp interval above two standard deviations.") + else: + click.echo("❌ ERROR - timestamp jump check failed, intervals above two standard deviations detected!") + + qa_check_noise, qa_noise_levels = qa.check_noise(f_signal, return_noise=True) + if qa_check_noise: + click.echo( + f"✅ Noise level check passed, median recording shot noise {np.median(qa_noise_levels):.2f} %." + ) + else: + click.echo( + "❌ ERROR - noise level check failed, recording has high noise " + f"(median {np.median(qa_noise_levels):.2f} %)!" + ) + + qa_check_snr, qa_snr = qa.check_snr(f_signal, return_snr=True) + if qa_check_snr: + click.echo(f"✅ SNR check passed (recording SNR is {qa_snr:.2f}).") + else: + click.echo(f"❌ ERROR - SNR check failed (recording SNR is {qa_snr:.2f})!") + + qa_check_bleaching, qa_bleaching = qa.check_bleaching(f_mean_timeseries, return_slope=True) + if qa_check_bleaching: + click.echo("✅ Photobleaching check passed.") + else: + click.echo(f"❌ ERROR - photobleaching check failed (bleaching factor is {qa_bleaching})") + + outpath = out_dir + os.sep + session_id + "_preprocessed.h5" + with timer.Timer(f"Writing data to {outpath}"): + # QA results are None when the checks are skipped, and cannot be written. + io.write_h5( + path=outpath, + data=_drop_none( + { + "/F": f_signal, + "/timestamps": timestamps, + "/qa/frame_means_timeseries": frame_means, + "/qa/frame_stds_timeseries": frame_stds, + "/qa/gcamp_mean_timeseries": gcamp_mean[:max_idx], + "/qa/isosb_mean_timeseries": isosb_mean[:max_idx], + "/qa/gcamp_dff_timeseries": gcamp_signal_mean[:max_idx], + "/qa/isosb_dff_timeseries": isosb_signal_mean[:max_idx], + "/qa/gcamp_filter": gcamp_filter[:max_idx], + "/qa/isosb_filter": isosb_filter[:max_idx], + "/qa/gcamp_mean_projection": gcamp_projections.get("mean"), + "/qa/gcamp_std_projection": gcamp_projections.get("std"), + "/qa/gcamp_maxip_projection": gcamp_projections.get("maxip"), + "/qa/isosb_mean_projection": isosb_projections.get("mean"), + "/qa/isosb_std_projection": isosb_projections.get("std"), + "/qa/isosb_maxip_projection": isosb_projections.get("maxip"), + "/qa/f_mean_timeseries": f_mean_timeseries, + "/qa/f_maxip": f_maxip, + "/qa/noise_levels": qa_noise_levels, + } + ), + attributes=_drop_none( + { + "/qa/checks/histogram_separation": qa_histogram_separation, + "/qa/checks/timestamp_consistency": qa_timestamp_consistency, + "/qa/checks/timestamp_jump": qa_timestamp_jump, + "/qa/checks/noise_check": qa_check_noise, + "/qa/checks/snr_check": qa_check_snr, + "/qa/checks/snr": qa_snr, + "/qa/checks/bleaching_check": qa_check_bleaching, + "/qa/checks/bleaching_factor": qa_bleaching, + } + ), + compression="lzf", + ) + + click.echo("Cleaning up...") preprocessing_end = time.time() click.echo(f"Preprocessing took a total of {(preprocessing_end - preprocessing_start) / 60} mins.") @@ -399,9 +404,6 @@ def run_preprocessing( update_nwb(path, outpath) click.echo(f"Updated NWB file at {path}") - click.echo("Cleaning up...") - shutil.rmtree(interim_dir) - def load_raw(raw_path: str, nwb: bool = False) -> tuple[str, da.Array | np.ndarray, da.Array | np.ndarray]: """Load raw imaging data from an HDF5 or NWB file. From ff9230f9c985ab06309de37bf709199364da913f Mon Sep 17 00:00:00 2001 From: Constantinos Eleftheriou Date: Tue, 13 Oct 2026 14:56:15 +0100 Subject: [PATCH 3/4] tests for new interim dir --- tests/test_io.py | 31 +++++++++++++++++++++++++ tests/test_preprocessing.py | 45 ++++++++++++++++++++++++++++++++++++- 2 files changed, 75 insertions(+), 1 deletion(-) diff --git a/tests/test_io.py b/tests/test_io.py index bf9ecf5..4debfcb 100644 --- a/tests/test_io.py +++ b/tests/test_io.py @@ -1,5 +1,6 @@ from datetime import datetime from importlib import resources +from pathlib import Path from uuid import uuid4 import dask.array as da @@ -133,6 +134,36 @@ def test_load_interim(tmp_path): assert io.store_interim(data, str(path)).any() +def test_interim_workdir_removes_created_dir(tmp_path): + interim = tmp_path / "interim" + with io.interim_workdir(interim, prefix="session_") as workdir: + assert Path(workdir).parent == interim + assert Path(workdir).name.startswith("session_") + (Path(workdir) / "binned.zarr").touch() + assert not interim.exists() + + +def test_interim_workdir_keeps_existing_dir(tmp_path): + (tmp_path / "recording.h5").touch() + with io.interim_workdir(tmp_path) as workdir: + (Path(workdir) / "binned.zarr").touch() + assert [p.name for p in tmp_path.iterdir()] == ["recording.h5"] + + +def test_interim_workdir_keeps_created_dir_with_other_files(tmp_path): + interim = tmp_path / "interim" + with io.interim_workdir(interim): + (interim / "other.txt").touch() + assert [p.name for p in interim.iterdir()] == ["other.txt"] + + +def test_interim_workdir_cleans_up_on_error(tmp_path): + interim = tmp_path / "interim" + with pytest.raises(RuntimeError), io.interim_workdir(interim): + raise RuntimeError + assert not interim.exists() + + def test_read_points_fiji(): assert io.read_points(str(resources.files(mesoscopy.resources).joinpath("ccf_template_top_140x142.points"))) diff --git a/tests/test_preprocessing.py b/tests/test_preprocessing.py index b3d9608..0734008 100644 --- a/tests/test_preprocessing.py +++ b/tests/test_preprocessing.py @@ -1,4 +1,6 @@ import pathlib +import shutil +from unittest import mock import h5py import numpy as np @@ -262,6 +264,48 @@ def test_preprocess_nwb_channelmeansonly(nwbfile, output_dir): ) +def test_preprocess_h5_interim_in_output_dir(raw_h5, tmp_path): + """An interim directory holding the input and output keeps both.""" + session_dir = tmp_path / "session" + session_dir.mkdir() + shutil.copy(raw_h5, session_dir) + + preprocess.run_preprocessing( + path=str(session_dir / "preproc_test.h5"), out_dir=str(session_dir), interim_dir=str(session_dir) + ) + + assert sorted(p.name for p in session_dir.iterdir()) == ["preproc_test.h5", "preproc_test_preprocessed.h5"] + with h5py.File(session_dir / "preproc_test_preprocessed.h5", "r") as f: + assert "/F" in f + + +def test_preprocess_nwb_interim_in_output_dir(nwbfile): + """An interim directory holding the NWB input and output keeps both, and the NWB file links to the output.""" + session_dir = pathlib.Path(nwbfile).parent + + preprocess.run_preprocessing(path=nwbfile, out_dir=str(session_dir), interim_dir=str(session_dir)) + + assert sorted(p.name for p in session_dir.iterdir()) == ["session_1234_preprocessed.h5", "test.nwb"] + with h5py.File(nwbfile, "r") as f: + link = f["/processing/ophys/DeltaFSeries"].get("data", getlink=True) + assert (session_dir / link.filename).is_file() + + +def test_preprocess_channelmeansonly_removes_interim(raw_h5, output_dir, tmp_path): + interim = tmp_path / "interim" + preprocess.run_preprocessing(path=raw_h5, out_dir=output_dir, interim_dir=str(interim), channel_means_only=True) + assert not interim.exists() + + +def test_preprocess_failure_removes_interim(raw_h5, output_dir, tmp_path, monkeypatch): + # Fails after the binned frames are written to the interim directory. + monkeypatch.setattr(compute, "channel_separation_filters", mock.Mock(side_effect=RuntimeError)) + interim = tmp_path / "interim" + with pytest.raises(RuntimeError): + preprocess.run_preprocessing(path=raw_h5, out_dir=output_dir, interim_dir=str(interim)) + assert not interim.exists() + + def test_cmd_parity(raw_h5, output_dir): """Check if click preprocess cmd caller works""" runner = CliRunner() @@ -275,7 +319,6 @@ def test_cmd_parity(raw_h5, output_dir): def test_cmd_no_qa_writes_the_output(raw_h5, output_dir, tmp_path): """Skipped QA checks leave None results, which must not reach the HDF5 writer.""" runner = CliRunner() - # The interim directory is removed on completion, so it must not be the output directory. result = runner.invoke( mesoscopy.cli, args=f"preprocess --no-qa --out_dir {output_dir} --interim_dir {tmp_path / 'interim'} {raw_h5}", From ca4fea0feadb8f570d5e84bd516465e115e9dd9a Mon Sep 17 00:00:00 2001 From: Constantinos Eleftheriou Date: Tue, 13 Oct 2026 14:56:36 +0100 Subject: [PATCH 4/4] update changelog --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 507bafa..007216e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ Versions follow [Semantic Versioning](https://semver.org) (`..