diff --git a/src/openflexure_microscope_server/stitching.py b/src/openflexure_microscope_server/stitching.py index 2d68736b..9a69f7c1 100644 --- a/src/openflexure_microscope_server/stitching.py +++ b/src/openflexure_microscope_server/stitching.py @@ -225,6 +225,11 @@ class FinalStitcher(BaseStitcher): HistoricScanData for this scan as a dictionary. :param stitch_tiff: Whether to stitch a pyramidal TIFF. """ + if not isinstance(stitching_settings, StitchingSettings): + raise StitcherValidationError( + "Final stitcher requires settings to be set as a StitchingSettings " + "model" + ) self.logger = logger overlap = stitching_settings.overlap correlation_resize = stitching_settings.correlation_resize diff --git a/src/openflexure_microscope_server/things/autofocus.py b/src/openflexure_microscope_server/things/autofocus.py index 76779aa9..266d4bd1 100644 --- a/src/openflexure_microscope_server/things/autofocus.py +++ b/src/openflexure_microscope_server/things/autofocus.py @@ -146,7 +146,7 @@ class SmartStackParams(BaseModel): @dataclass class CaptureInfo: - """The information from a capture in a z_stack.""" + """The information from a capture in a smart_z_stack.""" buffer_id: int position: Mapping[str, int] diff --git a/tests/unit_tests/test_smart_scan.py b/tests/unit_tests/test_smart_scan.py index c35ef6f7..21901f7c 100644 --- a/tests/unit_tests/test_smart_scan.py +++ b/tests/unit_tests/test_smart_scan.py @@ -22,11 +22,13 @@ from typing import Callable, Optional import pytest from fastapi import HTTPException +from pydantic import BaseModel from labthings_fastapi.exceptions import InvocationCancelledError from labthings_fastapi.testing import create_thing_without_server from openflexure_microscope_server.scan_directories import NotEnoughFreeSpaceError +from openflexure_microscope_server.stitching import StitchingSettings from openflexure_microscope_server.things.smart_scan import ( ActiveScanData, ScanNotRunningError, @@ -221,21 +223,28 @@ MOCK_SCAN_DIR = "scans/test_name_0001/images/" MOCK_START_POS = {"x": 123, "y": 456, "z": 789} +class MockWorkflowSettingModel(BaseModel): + """A mock model to check that ActiveScanData can hold arbitrary models.""" + + foo: str = "bar" + bar: str = "foo" + dx: int = 123 + dy: int = 456 + + def _expected_scan_data(): """Return the expected ActiveScanData object for a SmartScan with default properties.""" expected_dict = { "scan_name": MOCK_SCAN_NAME, "starting_position": MOCK_START_POS, - "overlap": 0.45, - "max_dist": 45000, - "dx": 100, - "dy": 100, - "autofocus_dz": 1000, - "autofocus_on": True, - "skip_background": True, - "stitch_automatically": True, - "correlation_resize": 0.5, "save_resolution": (1640, 1232), + "stitch_automatically": True, + "stitching_settings": { + "overlap": 0.45, + "correlation_resize": 0.5, + }, + "workflow": "Mock", + "workflow_settings": MockWorkflowSettingModel(), } return ActiveScanData(start_time=datetime.now(), **expected_dict) @@ -245,14 +254,14 @@ def scan_thing_mocked_for_scan_data(smart_scan_thing, mocker): """Return a scan thing that is mocked so that _collect_scan_data will run.""" # Set the lock so it thinks the scan is running with smart_scan_thing._scan_lock: - mocker.patch.object( - smart_scan_thing, - "_calc_displacement_from_test_image", - return_value=[100, 100], - ) - smart_scan_thing._stage.position = MOCK_START_POS + smart_scan_thing._workflow.all_settings.return_value = ( + MockWorkflowSettingModel(), + StitchingSettings(correlation_resize=0.5, overlap=0.45), + ) + smart_scan_thing._workflow.save_resolution = (1640, 1232) + mock_ongoing_scan = mocker.Mock() mock_ongoing_scan.name = MOCK_SCAN_NAME mock_ongoing_scan.images_dir = MOCK_SCAN_DIR @@ -265,7 +274,7 @@ def test_collect_scan_data(scan_thing_mocked_for_scan_data): """Run _collect_scan_data, and check the ActiveScanData object has the expected values.""" scan_thing = scan_thing_mocked_for_scan_data - data = scan_thing._collect_scan_data() + data = scan_thing._collect_scan_data(scan_thing._workflow) expected_data = _expected_scan_data() time_diff = expected_data.start_time - data.start_time assert abs(time_diff.total_seconds()) < 1 @@ -278,7 +287,7 @@ def test_save_final_scan_data(scan_thing_mocked_for_scan_data): """Run _save_final_scan_data, check save is called with final results in ActiveScanData.""" scan_thing = scan_thing_mocked_for_scan_data - scan_thing._scan_data = scan_thing._collect_scan_data() + scan_thing._scan_data = scan_thing._collect_scan_data(scan_thing._workflow) scan_thing._scan_data.image_count = 44 scan_thing._save_final_scan_data("Mocked!") # _ongoing_scan is a mock so we can check that save_scan data was called and get @@ -320,10 +329,10 @@ def check_run_scan(scan_thing, caplog, expected_exception=None): """ if expected_exception is None: with caplog.at_level(logging.WARNING): - scan_thing._scan_data = scan_thing._run_scan() + scan_thing._scan_data = scan_thing._run_scan(scan_thing._workflow) else: with pytest.raises(expected_exception), caplog.at_level(logging.WARNING): - scan_thing._scan_data = scan_thing._run_scan() + scan_thing._scan_data = scan_thing._run_scan(scan_thing._workflow) # The preview stitcher object should still exist. And images dir should be set. assert scan_thing._preview_stitcher.images_dir == MOCK_SCAN_DIR diff --git a/tests/unit_tests/test_stack.py b/tests/unit_tests/test_stack.py index bcc048b0..7cc4fe7e 100644 --- a/tests/unit_tests/test_stack.py +++ b/tests/unit_tests/test_stack.py @@ -25,6 +25,7 @@ from openflexure_microscope_server.things.autofocus import ( _get_capture_index_by_id, _get_peak_turning_point, ) +from openflexure_microscope_server.things.scan_workflows import HistoScanWorkflow RANDOM_GENERATOR = np.random.default_rng() @@ -267,20 +268,28 @@ def autofocus_thing(): return create_thing_without_server(AutofocusThing, mock_all_slots=True) -def test_create_stack(autofocus_thing, caplog): +@pytest.fixture +def histo_scan_workflow(): + """Return an autofocus thing connected to a server.""" + return create_thing_without_server(HistoScanWorkflow, mock_all_slots=True) + + +def test_create_stack(histo_scan_workflow, caplog): """Run create stack with default values and check there is no coercion or logging.""" - initial_min_images_to_test = autofocus_thing.stack_min_images_to_test - initial_images_to_save = autofocus_thing.stack_images_to_save + initial_min_images_to_test = histo_scan_workflow.stack_min_images_to_test + initial_images_to_save = histo_scan_workflow.stack_images_to_save with caplog.at_level(logging.INFO): - stack_params = autofocus_thing.create_stack_params( + stack_params = histo_scan_workflow.create_smart_stack_params( autofocus_dz=2000, images_dir="/this/is/fake", save_resolution=(1640, 1232) ) assert len(caplog.records) == 0 - assert autofocus_thing.stack_min_images_to_test == initial_min_images_to_test - assert autofocus_thing.stack_images_to_save == initial_images_to_save - assert stack_params.min_images_to_test == autofocus_thing.stack_min_images_to_test - assert stack_params.images_to_save == autofocus_thing.stack_images_to_save + assert histo_scan_workflow.stack_min_images_to_test == initial_min_images_to_test + assert histo_scan_workflow.stack_images_to_save == initial_images_to_save + assert ( + stack_params.min_images_to_test == histo_scan_workflow.stack_min_images_to_test + ) + assert stack_params.images_to_save == histo_scan_workflow.stack_images_to_save @pytest.mark.parametrize( @@ -293,13 +302,13 @@ def test_create_stack(autofocus_thing, caplog): ], ) def test_coercing_stack_test_ims( - initial_test_ims, coerced_test_ims, expected_log_start, autofocus_thing, caplog + initial_test_ims, coerced_test_ims, expected_log_start, histo_scan_workflow, caplog ): """Run create stack with images to test set to values requiring coercion, and check result.""" - autofocus_thing.stack_min_images_to_test = initial_test_ims + histo_scan_workflow.stack_min_images_to_test = initial_test_ims with caplog.at_level(logging.WARNING): - stack_params = autofocus_thing.create_stack_params( + stack_params = histo_scan_workflow.create_smart_stack_params( autofocus_dz=2000, images_dir="/this/is/fake", save_resolution=(1640, 1232) ) @@ -308,7 +317,9 @@ def test_coercing_stack_test_ims( # Check the value is coerced in the stack_params assert stack_params.min_images_to_test == coerced_test_ims # Check that the setting in the Thing was updated to the coerced value - assert stack_params.min_images_to_test == autofocus_thing.stack_min_images_to_test + assert ( + stack_params.min_images_to_test == histo_scan_workflow.stack_min_images_to_test + ) @pytest.mark.parametrize( @@ -321,13 +332,13 @@ def test_coercing_stack_test_ims( ], ) def test_coercing_stack_save_ims( - initial_save_ims, coerced_save_ims, expected_log_start, autofocus_thing, caplog + initial_save_ims, coerced_save_ims, expected_log_start, histo_scan_workflow, caplog ): """Run create stack with images to save set to values requiring coercion, and check result.""" - autofocus_thing.stack_images_to_save = initial_save_ims + histo_scan_workflow.stack_images_to_save = initial_save_ims with caplog.at_level(logging.WARNING): - stack_params = autofocus_thing.create_stack_params( + stack_params = histo_scan_workflow.create_smart_stack_params( autofocus_dz=2000, images_dir="/this/is/fake", save_resolution=(1640, 1232) ) @@ -336,13 +347,13 @@ def test_coercing_stack_save_ims( # Check the value is coerced in the stack_params assert stack_params.images_to_save == coerced_save_ims # Check that the setting in the Thing was updated to the coerced value - assert stack_params.images_to_save == autofocus_thing.stack_images_to_save + assert stack_params.images_to_save == histo_scan_workflow.stack_images_to_save @pytest.mark.parametrize("pass_on", [1, 2, 3, 4]) -def test_run_smart_stack(pass_on, autofocus_thing, mocker): +def test_run_smart_stack(pass_on, histo_scan_workflow, autofocus_thing, mocker): """Test Running smart stack with the stack passing on different attempts.""" - stack_params = autofocus_thing.create_stack_params( + stack_params = histo_scan_workflow.create_smart_stack_params( autofocus_dz=2000, images_dir="/this/is/fake", save_resolution=(1640, 1232) ) assert stack_params.max_attempts == 3 @@ -364,8 +375,8 @@ def test_run_smart_stack(pass_on, autofocus_thing, mocker): failed_return = (False, fake_captures, "pick_me") return_list = [failed_return] * (pass_on - 1) + [successful_return] - # Mock z_stack and looping_autofocus - autofocus_thing.z_stack = mocker.Mock(side_effect=return_list) + # Mock smart_z_stack and looping_autofocus + autofocus_thing.smart_z_stack = mocker.Mock(side_effect=return_list) autofocus_thing.looping_autofocus = mocker.Mock() # Run it @@ -380,9 +391,10 @@ def test_run_smart_stack(pass_on, autofocus_thing, mocker): # Final z is the one from the id returned by the stack "pick_me" assert final_z == 555 - # z_stack should run up until the time it passes. Running no more than max_attempts + # smart_z_stack should run up until the time it passes. Running no more than + # max_attempts n_stacks = min(pass_on, stack_params.max_attempts) - assert autofocus_thing.z_stack.call_count == n_stacks + assert autofocus_thing.smart_z_stack.call_count == n_stacks # Move absolute should be 1 less time that the number of times z_stack_run assert autofocus_thing._stage.move_absolute.call_count == n_stacks - 1 # As should looping autofocus @@ -396,14 +408,16 @@ def test_run_smart_stack(pass_on, autofocus_thing, mocker): assert autofocus_thing._cam.save_from_memory.call_count == (1 if success else 0) -def setup_and_run_z_stack(check_returns, check_turning_points, autofocus_thing, mocker): - """Set up a z_stack, run it, and return the result. +def setup_and_run_smart_z_stack( + check_returns, check_turning_points, histo_scan_workflow, autofocus_thing, mocker +): + """Set up a smart_z_stack, run it, and return the result. :param check_returns: The return values from check_stack_result. Note that if this is a list, it will be set as a side effect (and should be a list of tuples of results). If it a tuple (or anything else), it is set as a return value. """ - stack_params = autofocus_thing.create_stack_params( + stack_params = histo_scan_workflow.create_smart_stack_params( autofocus_dz=2000, images_dir="/this/is/fake", save_resolution=(1640, 1232) ) stack_params.settling_time = 0 # Don't settle or tests take forever. @@ -413,58 +427,70 @@ def setup_and_run_z_stack(check_returns, check_turning_points, autofocus_thing, autofocus_thing.check_stack_result = mocker.Mock(side_effect=check_returns) else: autofocus_thing.check_stack_result = mocker.Mock(return_value=check_returns) - return autofocus_thing.z_stack( + return autofocus_thing.smart_z_stack( stack_parameters=stack_params, check_turning_points=check_turning_points, ) -def test_z_stack_turning_toggle_passed(autofocus_thing, mocker): +def test_z_stack_turning_toggle_passed(histo_scan_workflow, autofocus_thing, mocker): """Check that the toggling of turning points is passed to the check.""" check_returns = ("success", "mock_id") for check_turning in [True, False]: - setup_and_run_z_stack(check_returns, check_turning, autofocus_thing, mocker) + setup_and_run_smart_z_stack( + check_returns, check_turning, histo_scan_workflow, autofocus_thing, mocker + ) check_kwargs = autofocus_thing.check_stack_result.call_args.kwargs assert check_kwargs["check_turning_points"] == check_turning -def test_z_stack_returns_on_success_and_restart(autofocus_thing, mocker): +def test_z_stack_returns_on_success_and_restart( + histo_scan_workflow, autofocus_thing, mocker +): """Check that if the check returns success or restart then the stack exits with correct return value.""" for result in ["success", "restart"]: check_returns = (result, "mock_id") - ret = setup_and_run_z_stack(check_returns, True, autofocus_thing, mocker) + ret = setup_and_run_smart_z_stack( + check_returns, True, histo_scan_workflow, autofocus_thing, mocker + ) assert autofocus_thing.check_stack_result.call_count == 1 # Check the number of images taken is exactly the call count. ims_taken = autofocus_thing.capture_stack_image.call_count - assert ims_taken == autofocus_thing.stack_min_images_to_test + assert ims_taken == histo_scan_workflow.stack_min_images_to_test # And the result is as expected. assert ret[0] == (result == "success") -def test_z_stack_exits_if_focus_never_found(autofocus_thing, mocker): +def test_z_stack_exits_if_focus_never_found( + histo_scan_workflow, autofocus_thing, mocker +): """Check that if the check returns continue the stack exits eventually with a failure.""" check_returns = ("continue", "mock_id") - ret = setup_and_run_z_stack(check_returns, True, autofocus_thing, mocker) + ret = setup_and_run_smart_z_stack( + check_returns, True, histo_scan_workflow, autofocus_thing, mocker + ) assert autofocus_thing.check_stack_result.call_count == EXTRA_STACK_CAPTURES + 1 # Check the number of images taken is the maximum possible, set by the min images to # test and the number of extra images that can be taken ims_taken = autofocus_thing.capture_stack_image.call_count - max_ims = autofocus_thing.stack_min_images_to_test + EXTRA_STACK_CAPTURES + max_ims = histo_scan_workflow.stack_min_images_to_test + EXTRA_STACK_CAPTURES assert ims_taken == max_ims # And the result is as expected. assert not ret[0] -def test_z_stack_return(autofocus_thing, mocker): +def test_z_stack_return(histo_scan_workflow, autofocus_thing, mocker): """Check z-stack returns as expected for more complex cases the fixed results above.""" for i in range(2, EXTRA_STACK_CAPTURES): check_returns = [ ("restart" if j == i - 1 else "continue", f"id_{j}") for j in range(i) ] - ret = setup_and_run_z_stack(check_returns, True, autofocus_thing, mocker) + ret = setup_and_run_smart_z_stack( + check_returns, True, histo_scan_workflow, autofocus_thing, mocker + ) # Calculate images taken - images_taken = autofocus_thing.stack_min_images_to_test + i - 1 + images_taken = histo_scan_workflow.stack_min_images_to_test + i - 1 assert autofocus_thing.capture_stack_image.call_count == images_taken # Check it reports a failure assert not ret[0] @@ -473,7 +499,9 @@ def test_z_stack_return(autofocus_thing, mocker): check_returns = [ ("success" if j == i - 1 else "continue", f"id_{j}") for j in range(i) ] - ret = setup_and_run_z_stack(check_returns, True, autofocus_thing, mocker) + ret = setup_and_run_smart_z_stack( + check_returns, True, histo_scan_workflow, autofocus_thing, mocker + ) # Calculate images taken assert autofocus_thing.capture_stack_image.call_count == images_taken # Check it reports a success diff --git a/tests/unit_tests/test_stitching.py b/tests/unit_tests/test_stitching.py index 12bcfbfb..349d975c 100644 --- a/tests/unit_tests/test_stitching.py +++ b/tests/unit_tests/test_stitching.py @@ -11,15 +11,16 @@ import time from copy import copy import pytest +from pydantic import BaseModel import labthings_fastapi as lt from openflexure_microscope_server.stitching import ( - STITCHING_RESOLUTION, BaseStitcher, FinalStitcher, PreviewStitcher, StitcherValidationError, + StitchingSettings, ) from ..shared_utils.lt_test_utils import LabThingsTestEnv @@ -88,87 +89,41 @@ FINAL_EXPECTED_COMMAND = [ FAKE_DIR, ] - -def test_final_stitcher_command_defaults(caplog): - """Check the FinalStitcher stitches with expected default values. - - It should warn when default values are used as they are a fallback. - """ - n_logs = 0 - # Test with no dictionary data and with irrelevant dictionary data. - with caplog.at_level(logging.WARNING): - for data_dict in [None, {"irrelevant": "data"}]: - stitcher = FinalStitcher(FAKE_DIR, logger=LOGGER, scan_data_dict=data_dict) - # Should log for overlap being None and correlation_resize being None - n_logs += 2 - assert len(caplog.records) == n_logs - assert stitcher.command == FINAL_EXPECTED_COMMAND +DEFAULT_SETTINGS = StitchingSettings(correlation_resize=0.5, overlap=0.1) -def test_final_stitcher_command_tiff(caplog): +def test_final_stitcher_command_tiff(): """Check that the tiff can be requested.""" # Modify defaults expected_command = copy(FINAL_EXPECTED_COMMAND) expected_command[4] = "--stitch_tiff" - stitcher = FinalStitcher(FAKE_DIR, logger=LOGGER, stitch_tiff=True) - # Should log for overlap being None and correlation_resize being None - assert len(caplog.records) == 2 + stitcher = FinalStitcher( + FAKE_DIR, logger=LOGGER, stitching_settings=DEFAULT_SETTINGS, stitch_tiff=True + ) assert stitcher.command == expected_command -def test_final_stitcher_command_set_val_directly(): - """Check that values are set as expected when directly input.""" - # Modify defaults - expected_command = copy(FINAL_EXPECTED_COMMAND) - expected_command[8] = "0.36" - expected_command[10] = "0.25" - # Test with no data dictionary, irrelevant data, and also the wrong data - # When wrong data is submitted, it should take the directly input data. - dict_vals = [ - None, - {"irrelevant": "data"}, - {"overlap": 0.2, "save_resolution": [5, 5]}, - ] - for data_dict in dict_vals: - stitcher = FinalStitcher( - FAKE_DIR, - logger=LOGGER, - overlap=0.4, - correlation_resize=0.25, - scan_data_dict=data_dict, - ) - assert stitcher.command == expected_command - - -def test_final_stitcher_command_set_with_dict(): +def test_final_stitcher_command_with_settings(): """Check that values are set as expected when set from a ScanData dictionary.""" # Modify defaults expected_command = copy(FINAL_EXPECTED_COMMAND) expected_command[8] = "0.36" expected_command[10] = "0.25" - # Check same thing works with a dictionary, resize is calculated from the saved image - # resolution. Make 4x bigger than STITCHING_RESOLUTION to get 0.25 - resolution = [dim * 4 for dim in STITCHING_RESOLUTION] - # Check legacy key as well as current one: - for resolution_key in ["save_resolution", "capture resolution"]: - stitcher = FinalStitcher( - FAKE_DIR, - logger=LOGGER, - scan_data_dict={"overlap": 0.4, resolution_key: resolution}, - ) - assert stitcher.command == expected_command + + stitcher = FinalStitcher( + FAKE_DIR, + logger=LOGGER, + stitching_settings=StitchingSettings(correlation_resize=0.25, overlap=0.4), + ) + assert stitcher.command == expected_command def _validation_error_tester(scan_path, **kwargs): - """Check each type of stitcher throws a validation error for the given init args.""" - # If scan_data_dict is in the kwargs only test the scan_data_dict - if "scan_data_dict" not in kwargs: - with pytest.raises(StitcherValidationError): - BaseStitcher(scan_path, **kwargs).command - with pytest.raises(StitcherValidationError): - PreviewStitcher(scan_path, **kwargs).command + """Check stitcher throws a validation error for the given init args.""" with pytest.raises(StitcherValidationError): - FinalStitcher(scan_path, logger=LOGGER, **kwargs).command + BaseStitcher(scan_path, **kwargs).command + with pytest.raises(StitcherValidationError): + PreviewStitcher(scan_path, **kwargs).command def test_validation_error(): @@ -176,10 +131,23 @@ def test_validation_error(): The stitcher should throw a validation error each attempt. """ + # Tests for preview (and base) stitcher _validation_error_tester("/dir;rm -rf /;", overlap=".2", correlation_resize=".25") _validation_error_tester(FAKE_DIR, overlap=".2", correlation_resize=".25;rm -rf /;") _validation_error_tester(FAKE_DIR, overlap=".2;rm -rf /;", correlation_resize=".25") - _validation_error_tester(FAKE_DIR, scan_data_dict={"overlap": ".2;rm -rf /;"}) + + class EvilModel(BaseModel): + overlap: str + correlation_resize: str + + with pytest.raises(StitcherValidationError): + FinalStitcher( + FAKE_DIR, + logger=LOGGER, + stitching_settings=EvilModel( + overlap=".2;rm -rf /;", correlation_resize=".25" + ), + ) def test_extra_arg_validation(): @@ -188,7 +156,9 @@ def test_extra_arg_validation(): Currently extra args do not come from user input. But this makes checks more future-proof. """ - stitcher = FinalStitcher(FAKE_DIR, logger=LOGGER) + stitcher = FinalStitcher( + FAKE_DIR, logger=LOGGER, stitching_settings=DEFAULT_SETTINGS + ) stitcher._extra_args = ["&&rm -rf /&&"] with pytest.raises(StitcherValidationError): stitcher.command @@ -233,7 +203,9 @@ class StitchingTestThing(lt.Thing): @lt.action def run_final(self): """Run the final stitcher.""" - stitcher = FinalStitcher(FAKE_DIR, logger=self.logger) + stitcher = FinalStitcher( + FAKE_DIR, logger=self.logger, stitching_settings=DEFAULT_SETTINGS + ) # Send in the argument HANG to mock-stitch and it just hang for 10s stitcher._extra_args = ["HANG"] stitcher.run() @@ -277,9 +249,8 @@ def test_final_stitching_command(caplog, mocker): mocker.patch("openflexure_microscope_server.stitching.STITCHING_CMD", mock_cmd) with caplog.at_level(logging.INFO): - # Input values to prevent logging stitcher = FinalStitcher( - FAKE_DIR, logger=LOGGER, overlap=0.1, correlation_resize=0.5 + FAKE_DIR, logger=LOGGER, stitching_settings=DEFAULT_SETTINGS ) # For the final stitcher it will always complete before returning. stitcher.run() @@ -315,16 +286,17 @@ def test_final_stitching_command_cancelled(stitching_test_env, mocker): assert re.match(r"^Invocation [0-9a-f-]+ was cancelled", logs[-1]["message"]) -def test_final_stitching_command_error(caplog, mocker): +def test_final_stitching_command_error(mocker): """Check that ChildProcessError is raised if the final stitch errors.""" mock_cmd = f"python {MOCK_STITCHER}" mocker.patch("openflexure_microscope_server.stitching.STITCHING_CMD", mock_cmd) - with caplog.at_level(logging.INFO): - stitcher = FinalStitcher(FAKE_DIR, logger=LOGGER) - # Send in the argument ERROR to mock-stitch and it will raise an error rather - # than echo. - stitcher._extra_args = ["ERROR"] - with pytest.raises(ChildProcessError): - stitcher.run() + stitcher = FinalStitcher( + FAKE_DIR, logger=LOGGER, stitching_settings=DEFAULT_SETTINGS + ) + # Send in the argument ERROR to mock-stitch and it will raise an error rather + # than echo. + stitcher._extra_args = ["ERROR"] + with pytest.raises(ChildProcessError): + stitcher.run()