diff --git a/docs/source/api/index.rst b/docs/source/api/index.rst index 5d5e2183a..d524c7c8e 100644 --- a/docs/source/api/index.rst +++ b/docs/source/api/index.rst @@ -18,6 +18,9 @@ API .. toctree:: spots/index.rst +.. toctree:: + recipe/index.rst + .. toctree:: types/index.rst diff --git a/docs/source/api/recipe/index.rst b/docs/source/api/recipe/index.rst new file mode 100644 index 000000000..220f249d1 --- /dev/null +++ b/docs/source/api/recipe/index.rst @@ -0,0 +1,13 @@ +.. _Recipe: + +Runnable +======== + +.. autoclass:: starfish.recipe.Runnable + :members: + +FileProvider +============ + +.. autoclass:: starfish.recipe.filesystem.FileProvider + :members: diff --git a/starfish/recipe/__init__.py b/starfish/recipe/__init__.py new file mode 100644 index 000000000..b9761a6c2 --- /dev/null +++ b/starfish/recipe/__init__.py @@ -0,0 +1,9 @@ +from .errors import ( + ConstructorError, + ConstructorExtraParameterWarning, + ExecutionError, + RecipeError, + RunInsufficientParametersError, + TypeInferenceError, +) +from .runnable import Runnable diff --git a/starfish/recipe/errors.py b/starfish/recipe/errors.py new file mode 100644 index 000000000..82f8a1ef3 --- /dev/null +++ b/starfish/recipe/errors.py @@ -0,0 +1,30 @@ +class RecipeWarning(RuntimeWarning): + pass + + +class RecipeError(Exception): + pass + + +class ConstructorExtraParameterWarning(RecipeWarning): + """Raised when a recipe contains parameters that an algorithms constructor does not expect.""" + + +class TypeInferenceError(RecipeError): + """Raised when we cannot infer the type of object an algorithm expects in its constructor or + its run method. This can be fixed by ensuring all the parameters to the constructor and the run + method have type hints.""" + + +class ConstructorError(RecipeError): + """Raised when there is an error raised during the construction of an algorithm class.""" + pass + + +class RunInsufficientParametersError(RecipeError): + """Raised when the recipe does not provide sufficient parameters for the run method.""" + + +class ExecutionError(RecipeError): + """Raised when there is an error raised during the execution of an algorithm.""" + pass diff --git a/starfish/recipe/filesystem.py b/starfish/recipe/filesystem.py new file mode 100644 index 000000000..497e2e402 --- /dev/null +++ b/starfish/recipe/filesystem.py @@ -0,0 +1,108 @@ +import enum +from typing import Any, Callable, Type + +from starfish.codebook.codebook import Codebook +from starfish.expression_matrix.expression_matrix import ExpressionMatrix +from starfish.imagestack.imagestack import ImageStack +from starfish.intensity_table.intensity_table import IntensityTable +from starfish.util.indirectfile import ( + convert, + GetCodebook, + GetCodebookFromExperiment, + GetImageStack, + GetImageStackFromExperiment, +) + + +def imagestack_convert(indirect_path_or_url: str) -> ImageStack: + """Converts a path or URL to an ImageStack. This supports the indirect syntax, where a user + provides a string like @[fov_name][image_name]. If the indirect + syntax is used, the experiment.json is automatically fetched and traversed to find the specified + image in the specified field of view.""" + return convert( + indirect_path_or_url, + [ + GetImageStack(), + GetImageStackFromExperiment(), + ], + ) + + +def codebook_convert(indirect_path_or_url: str) -> Codebook: + """Converts a path or URL to a Codebook. This supports the indirect syntax, where a user + provides a string like @. If the indirect syntax is used, the + experiment.json is automatically fetched to find the codebook.""" + return convert( + indirect_path_or_url, + [ + GetCodebook(), + GetCodebookFromExperiment(), + ], + ) + + +class FileTypes(enum.Enum): + """These are the filetypes supported as inputs and outputs for recipes. Each filetype is + associated with the implementing class, the method to invoke to load such a filetype, and the + method to invoke to save back to the filetype. + + The load method is expected to be called with a string, which is the file or url to load from, + and is expected to return an instantiated object. + + The save method is expected to be called with the object and a string, which is the path to + write the object to. + """ + IMAGESTACK = (ImageStack, imagestack_convert, ImageStack.export) + INTENSITYTABLE = (IntensityTable, IntensityTable.open_netcdf, IntensityTable.to_netcdf) + EXPRESSIONMATRIX = (ExpressionMatrix, ExpressionMatrix.load, ExpressionMatrix.save) + CODEBOOK = (Codebook, codebook_convert, Codebook.to_json) + + def __init__(self, cls: Type, loader: Callable[[str], Any], saver: Callable[[Any, str], None]): + self._cls = cls + self._load = loader + self._save = saver + + @property + def load(self) -> Callable[[str], Any]: + return self._load + + @property + def save(self) -> Callable[[Any, str], None]: + return self._save + + @staticmethod + def resolve_by_class(cls: Type) -> "FileTypes": + for member in FileTypes.__members__.values(): + if cls == member.value[0]: + return member + raise TypeError(f"filetype {cls} not supported.") + + @staticmethod + def resolve_by_instance(instance) -> "FileTypes": + for member in FileTypes.__members__.values(): + if isinstance(instance, member.value[0]): + return member + raise TypeError(f"filetype of {instance.__class__} not supported.") + + +class FileProvider: + """This is used to wrap paths or URLs that are passed into Runnables via the `file_inputs` magic + variable. This is so we can differentiate between strings and `file_inputs` values, which must + be first constructed into a starfish object via its loader.""" + def __init__(self, path_or_url: str) -> None: + self.path_or_uri = path_or_url + + def __str__(self): + return f"FileProvider(\"{self.path_or_uri}\")" + + +class TypedFileProvider: + """Like :py:class:`FileProvider`, this is used to wrap paths or URLs that are passed into + Runnables via the `file_inputs` magic variable. In this case, the object type has been + resolved by examining the type annotation.""" + def __init__(self, backing_file_provider: FileProvider, object_class: Type) -> None: + self.backing_file_provider = backing_file_provider + self.type = FileTypes.resolve_by_class(object_class) + + def load(self) -> Any: + return self.type.load(self.backing_file_provider.path_or_uri) diff --git a/starfish/recipe/runnable.py b/starfish/recipe/runnable.py new file mode 100644 index 000000000..8bcb4e414 --- /dev/null +++ b/starfish/recipe/runnable.py @@ -0,0 +1,247 @@ +import inspect +import warnings +from typing import ( + Any, + Callable, + cast, + Mapping, + MutableMapping, + MutableSequence, + Sequence, + Set, + Type, +) + +from starfish.pipeline.algorithmbase import AlgorithmBase +from .errors import ( + ConstructorError, + ConstructorExtraParameterWarning, + ExecutionError, + RunInsufficientParametersError, + TypeInferenceError, +) +from .filesystem import FileProvider, TypedFileProvider + + +class Runnable: + """Runnable represents a single invocation of a pipeline component, with a specific algorithm + implementation. For arguments to the algorithm's constructor and run method, it can accept + :py:class:`~starfish.recipe.filesystem.FileProvider` objects, which represent a file path or + url. For arguments to the algorithm's run method, it can accept the results of other Runnables. + + One can compose any starfish pipeline run using a directed acyclic graph of Runnables objects. + """ + def __init__( + self, + algorithm_cls: Type, + *inputs, + **algorithm_options + ) -> None: + self._pipeline_component_cls: Type = \ + cast(AlgorithmBase, algorithm_cls).get_pipeline_component_class() + self._algorithm_cls: Type = algorithm_cls + self._raw_inputs = inputs + self._raw_algorithm_options = algorithm_options + + # retrieve the actual __init__ method + signature = Runnable._get_actual_method_signature( + self._algorithm_cls.__init__) + formatted_algorithm_options = self._format_algorithm_constructor_arguments( + signature, self._raw_algorithm_options, self.__str__) + + try: + self._algorithm_instance: AlgorithmBase = self._algorithm_cls( + **formatted_algorithm_options) + except Exception as ex: + raise ConstructorError(f"Error instantiating the algorithm for {str(self)}") from ex + + # retrieve the actual run method + signature = Runnable._get_actual_method_signature( + self._algorithm_instance.run) # type: ignore + self._inputs = self._format_run_arguments(signature, self._raw_inputs, self.__str__) + + @staticmethod + def _get_actual_method_signature(run_method: Callable) -> inspect.Signature: + if hasattr(run_method, "__closure__"): + # it's a closure, probably because of AlgorithmBaseType.run_with_logging. Unwrap to + # find the underlying method. + closure = run_method.__closure__ # type: ignore + if closure is not None: + run_method = closure[0].cell_contents + + return inspect.signature(run_method) + + @staticmethod + def _format_algorithm_constructor_arguments( + constructor_signature: inspect.Signature, + algorithm_options: Mapping[str, Any], + str_callable: Callable[[], str], + ) -> Mapping[str, Any]: + """Given the constructor's signature and a mapping of keyword argument names to values, + format them such that the constructor can be invoked. + + Some parameters may be :py:class:`starfish.recipe.filesystem.FileProvider` instances. Use + the type hints in the constructor's signature to identify the expected file type, and load + them into memory accordingly. + + Parameters + ---------- + constructor_signature : inspect.Signature + The signature for the constructor. + algorithm_options : Mapping[str, Any] + The parameters for the constructor, as provided to the Runnable. + str_callable : Callable[[], str] + A callable that can be invoked to provide a user-friendly representation of the + Runnable, in case any errors or warnings are generated. + + Returns + ------- + Mapping[str, Any] : The parameters for the constructor, ready to be passed into the + constructor. + """ + parameters = constructor_signature.parameters + + formatted_algorithm_options: MutableMapping[str, Any] = {} + for algorithm_option_name, algorithm_option_value in algorithm_options.items(): + if isinstance(algorithm_option_value, Runnable): + raise RuntimeError("Runnable's constructors cannot depend on another runnable") + + try: + option_class = parameters[algorithm_option_name].annotation + except KeyError: + warnings.warn( + f"Constructor for {str_callable()} does not have an explicitly typed " + + f"parameter {algorithm_option_name}.", + category=ConstructorExtraParameterWarning, + ) + continue + + if isinstance(algorithm_option_value, FileProvider): + try: + provider = TypedFileProvider(algorithm_option_value, option_class) + except TypeError as ex: + raise TypeInferenceError( + f"Error inferring the types for the parameters to the algorithm's" + + f" constructor for {str_callable()}") from ex + formatted_algorithm_options[algorithm_option_name] = provider.load() + else: + formatted_algorithm_options[algorithm_option_name] = algorithm_option_value + + return formatted_algorithm_options + + @staticmethod + def _format_run_arguments( + run_signature: inspect.Signature, + inputs: Sequence, + str_callable: Callable[[], str], + ) -> Sequence: + """Given the run method's signature and a sequence of parameters, format them such that the + run method can be invoked. + + Some parameters may be :py:class:`starfish.recipe.filesystem.FileProvider` instances. Use + the type hints in the run method's signature to identify the expected file type, and load + them into memory accordingly. + + Parameters that are the outputs of other Runnables are not resolved to their values until + the run method is invoked. Therefore, the sequence of parameters returned may include + the dependent Runnable objects. + + Parameters + ---------- + run_signature : inspect.Signature + The signature for the run method. + inputs : Sequence + The parameters for the run method, as provided to the Runnable. + str_callable : Callable[[], str] + A callable that can be invoked to provide a user-friendly representation of the + Runnable, in case any errors or warnings are generated. + + Returns + ------- + Sequence : The parameters for the run method, ready to be passed into the constructor, + except for dependent Runnables, which are resolved later. + """ + formatted_inputs: MutableSequence = [] + + keys_iter = iter(run_signature.parameters.keys()) + inputs_iter = iter(inputs) + + # first parameter to the run method should be "self" + assert next(keys_iter) == "self" + + # match up the parameters as best as we can. + for _input, key in zip(inputs_iter, keys_iter): + if isinstance(_input, FileProvider): + annotation = run_signature.parameters[key].annotation + try: + provider = TypedFileProvider(_input, annotation) + except TypeError as ex: + raise TypeInferenceError( + f"Error inferring the types for the parameters to the algorithm's" + + f" run method for {str_callable()}") from ex + formatted_inputs.append(provider) + else: + formatted_inputs.append(_input) + + # are there any parameters left in the signature? if so, they must have default values + # because we don't have values. + no_default = inspect._empty # type: ignore + + for key in keys_iter: + if (run_signature.parameters[key].default == no_default + and run_signature.parameters[key].kind not in ( + inspect.Parameter.VAR_POSITIONAL, + inspect.Parameter.VAR_KEYWORD, + )): + raise RunInsufficientParametersError(f"No value for parameter {key}") + + return formatted_inputs + + @property + def runnable_dependencies(self) -> Set["Runnable"]: + """Retrieves a set of Runnables that this Runnable depends on.""" + return set(runnable for runnable in self._inputs if isinstance(runnable, Runnable)) + + def run(self, previous_results: Mapping["Runnable", Any]) -> Any: + """Invoke the run method. Results for dependent Runnables are retrieved from the + `previous_results` mapping. + + Parameters + ---------- + previous_results : Mapping[Runnable, Any] + The results calculated thus far in an execution run. + + Returns + ------- + The result from invoking the run method. + """ + inputs = list() + for _input in self._inputs: + if isinstance(_input, Runnable): + inputs.append(previous_results[_input]) + elif isinstance(_input, TypedFileProvider): + inputs.append(_input.load()) + else: + inputs.append(_input) + try: + return self._algorithm_instance.run(*inputs) # type: ignore + except Exception as ex: + raise ExecutionError(f"Error running the algorithm for {str(self)}") from ex + + def __str__(self): + inputs_arr = [""] + inputs_arr.extend([str(raw_input) for raw_input in self._raw_inputs]) + algorithm_options_arr = [""] + algorithm_options_arr.extend([ + f"{algorithm_option_name}={str(algorithm_option_value)}" + for algorithm_option_name, algorithm_option_value in + self._raw_algorithm_options.items() + ]) + + inputs_str = ", ".join(inputs_arr) + algorithm_options_str = ", ".join(algorithm_options_arr) + + return (f"compute(" + + f"{self._pipeline_component_cls.__name__}.{self._algorithm_cls.__name__}" + + f"{inputs_str}" + + f"{algorithm_options_str})") diff --git a/starfish/recipe/test/__init__.py b/starfish/recipe/test/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/starfish/recipe/test/fakefilter.py b/starfish/recipe/test/fakefilter.py new file mode 100644 index 000000000..9d93ce0be --- /dev/null +++ b/starfish/recipe/test/fakefilter.py @@ -0,0 +1,81 @@ +from starfish import ImageStack +from starfish.image._filter._base import FilterAlgorithmBase +from starfish.util import click + + +class SimpleFilterAlgorithm(FilterAlgorithmBase): + def __init__(self, multiplicand: float): + self.multiplicand = multiplicand + + def run(self, stack: ImageStack, *args) -> ImageStack: + numpy_array = stack.xarray + numpy_array = numpy_array * self.multiplicand + return ImageStack.from_numpy(numpy_array) + + @staticmethod + @click.command("SimpleFilterAlgorithm") + @click.option( + "--multiplicand", default=1.0, type=float) + @click.pass_context + def _cli(ctx, multiplicand): + ctx.obj["component"]._cli_run(ctx, SimpleFilterAlgorithm(multiplicand=multiplicand)) + + +class AdditiveFilterAlgorithm(FilterAlgorithmBase): + def __init__(self, additive: ImageStack): + self.additive = additive + + def run(self, stack: ImageStack, *args) -> ImageStack: + numpy_array = stack.xarray + numpy_array = numpy_array + stack.xarray + return ImageStack.from_numpy(numpy_array) + + @staticmethod + @click.command("AdditiveFilterAlgorithm") + @click.option( + "--imagestack", type=click.Path(exists=True)) + @click.pass_context + def _cli(ctx, imagestack): + ctx.obj["component"]._cli_run( + ctx, + AdditiveFilterAlgorithm(additive=ImageStack.from_path_or_url(imagestack))) + + +class FilterAlgorithmWithMissingConstructorTyping(FilterAlgorithmBase): + def __init__(self, additive): + self.additive = additive + + def run(self, stack: ImageStack, *args) -> ImageStack: + numpy_array = stack.xarray + numpy_array = numpy_array + stack.xarray + return ImageStack.from_numpy(numpy_array) + + @staticmethod + @click.command("FilterAlgorithmWithMissingConstructorTyping") + @click.option( + "--imagestack", type=click.Path(exists=True)) + @click.pass_context + def _cli(ctx, imagestack): + ctx.obj["component"]._cli_run( + ctx, + FilterAlgorithmWithMissingConstructorTyping( + additive=ImageStack.from_path_or_url(imagestack))) + + +class FilterAlgorithmWithMissingRunTyping(FilterAlgorithmBase): + def __init__(self, multiplicand: float): + self.multiplicand = multiplicand + + def run(self, stack, *args) -> ImageStack: + numpy_array = stack.xarray + numpy_array = numpy_array * self.multiplicand + return ImageStack.from_numpy(numpy_array) + + @staticmethod + @click.command("FilterAlgorithmWithMissingRunTyping") + @click.option( + "--multiplicand", default=1.0, type=float) + @click.pass_context + def _cli(ctx, multiplicand): + ctx.obj["component"]._cli_run( + ctx, FilterAlgorithmWithMissingRunTyping(multiplicand=multiplicand)) diff --git a/starfish/recipe/test/test_runnable.py b/starfish/recipe/test/test_runnable.py new file mode 100644 index 000000000..13fa7132d --- /dev/null +++ b/starfish/recipe/test/test_runnable.py @@ -0,0 +1,250 @@ +import warnings + +import numpy as np +import pytest + +from starfish import ImageStack +from starfish.recipe import ( + ConstructorError, + ConstructorExtraParameterWarning, + ExecutionError, + RunInsufficientParametersError, + Runnable, + TypeInferenceError, +) +from starfish.recipe.filesystem import FileProvider +from .fakefilter import ( + AdditiveFilterAlgorithm, + FilterAlgorithmWithMissingConstructorTyping, + FilterAlgorithmWithMissingRunTyping, + SimpleFilterAlgorithm, +) + + +BASE_EXPECTED = np.array([ + [0.227543, 0.223117, 0.217014, 0.221241, 0.212863, 0.211963, 0.210575, + 0.198611, 0.194827, 0.181964], + [0.216617, 0.214710, 0.212467, 0.218158, 0.211429, 0.210361, 0.205737, + 0.190814, 0.182010, 0.165667], + [0.206744, 0.204685, 0.208774, 0.212909, 0.215274, 0.206180, 0.196674, + 0.179080, 0.169207, 0.157549], + [0.190845, 0.197131, 0.188540, 0.195361, 0.196765, 0.200153, 0.183627, + 0.167590, 0.159930, 0.150805], + [0.181231, 0.187457, 0.182910, 0.179416, 0.175357, 0.172137, 0.165072, + 0.156344, 0.153735, 0.150378], + [0.169924, 0.184604, 0.182422, 0.174441, 0.159823, 0.157229, 0.157259, + 0.151690, 0.147265, 0.139940], + [0.164874, 0.169467, 0.178012, 0.173129, 0.161425, 0.155978, 0.152712, + 0.150286, 0.145159, 0.140658], + [0.164508, 0.165042, 0.171420, 0.174990, 0.162951, 0.152422, 0.149325, + 0.151675, 0.141588, 0.139010], + [0.162448, 0.156451, 0.158419, 0.162722, 0.160388, 0.152865, 0.142885, + 0.142123, 0.140093, 0.135836], + [0.150072, 0.147295, 0.145495, 0.153216, 0.156085, 0.149981, 0.145571, + 0.141878, 0.138857, 0.136965]], + dtype=np.float32) +URL = "https://d2nhj9g34unfro.cloudfront.net/20181005/ISS-TEST/fov_001/hybridization.json" + + +def test_str(): + """Verify that we can get a sane string for a runnable.""" + filter_runnable = Runnable( + SimpleFilterAlgorithm, + FileProvider(URL), + multiplicand=.5, + ) + assert str(filter_runnable) == ("compute(Filter.SimpleFilterAlgorithm," + + f" FileProvider(\"{URL}\"), multiplicand=0.5)") + + +def test_constructor_error(): + """Verify that we get a properly typed error when the constructor does not execute correctly. + In this case, we do not provide enough parameters to `SimpleFilterAlgorithm`.""" + with pytest.raises(ConstructorError): + Runnable( + SimpleFilterAlgorithm, + FileProvider(URL), + ) + + +def test_execution_error(): + """Verify that we get a properly typed error when the constructor does not execute correctly.""" + filter_runnable = Runnable( + SimpleFilterAlgorithm, + FileProvider(URL), + multiplicand="abacadabra", + ) + with pytest.raises(ExecutionError): + filter_runnable.run({}) + + +def test_constructor_type_inference_error(): + """Verify that we get a properly typed error when we cannot properly infer the type for one of + the constructor's parameters.""" + with pytest.raises(TypeInferenceError): + Runnable( + FilterAlgorithmWithMissingConstructorTyping, + FileProvider(URL), + additive=FileProvider(URL), + ) + + +def test_run_type_inference_error(): + """Verify that we get a properly typed error when we cannot properly infer the type for one of + the run method's parameters. In this case, `FilterAlgorithmWithMissingRunTyping` does not + provide a type hint for one of its constructor arguments.""" + with pytest.raises(TypeInferenceError): + Runnable( + FilterAlgorithmWithMissingRunTyping, + FileProvider(URL), + multiplicand=FileProvider(URL), + ) + + +def test_extra_constructor_parameter_fileprovider(): + """Verify that we raise a warning when we provide extra parameters that are fileproviders to an + algorithm's constructor.""" + with warnings.catch_warnings(record=True) as w: + filter_runnable = Runnable( + SimpleFilterAlgorithm, + FileProvider(URL), + multiplicand=.5, + additive=FileProvider(URL), + ) + assert len(w) == 1 + assert issubclass(w[-1].category, ConstructorExtraParameterWarning) + + result = filter_runnable.run({}) + assert isinstance(result, ImageStack) + + # pick a random part of the filtered image and assert on it + assert result.xarray.dtype == np.float32 + + assert np.allclose( + BASE_EXPECTED * .5, + result.xarray[2, 2, 0, 40:50, 40:50] + ) + + +def test_extra_constructor_parameter_non_fileprovider(): + """Verify that we raise a warning when we provide extra parameters that are not fileproviders + to an algorithm's constructor.""" + with warnings.catch_warnings(record=True) as w: + filter_runnable = Runnable( + SimpleFilterAlgorithm, + FileProvider(URL), + multiplicand=.5, + additive=.5, + ) + assert len(w) == 1 + assert issubclass(w[-1].category, ConstructorExtraParameterWarning) + + result = filter_runnable.run({}) + assert isinstance(result, ImageStack) + + # pick a random part of the filtered image and assert on it + assert result.xarray.dtype == np.float32 + + assert np.allclose( + BASE_EXPECTED * .5, + result.xarray[2, 2, 0, 40:50, 40:50] + ) + + +def test_run_insufficient_parameters(): + """Verify that we can run a single runnable and get its result. + """ + with pytest.raises(RunInsufficientParametersError): + Runnable( + SimpleFilterAlgorithm, + multiplicand=.5, + ) + + +def test_run(): + """Verify that we can run a single runnable and get its result. + """ + filter_runnable = Runnable( + SimpleFilterAlgorithm, + FileProvider(URL), + multiplicand=.5, + ) + result = filter_runnable.run({}) + assert isinstance(result, ImageStack) + + # pick a random part of the filtered image and assert on it + assert result.xarray.dtype == np.float32 + + assert np.allclose( + BASE_EXPECTED * .5, + result.xarray[2, 2, 0, 40:50, 40:50] + ) + + +def test_chained_run(): + """Verify that we can run a runnable that depends on another runnable. + """ + dependency_runnable = Runnable( + SimpleFilterAlgorithm, + FileProvider(URL), + multiplicand=.5, + ) + result = dependency_runnable.run({}) + assert isinstance(result, ImageStack) + + filter_runnable = Runnable( + SimpleFilterAlgorithm, + dependency_runnable, + multiplicand=2.0, + ) + result = filter_runnable.run({dependency_runnable: result}) + assert isinstance(result, ImageStack) + + # pick a random part of the filtered image and assert on it + assert result.xarray.dtype == np.float32 + + assert np.allclose( + BASE_EXPECTED, + result.xarray[2, 2, 0, 40:50, 40:50] + ) + + +def test_chained_run_result_not_present(): + """Verify that we can run a runnable that depends on another runnable, but the results are not + present. + """ + dependency_runnable = Runnable( + SimpleFilterAlgorithm, + FileProvider(URL), + multiplicand=.5, + ) + result = dependency_runnable.run({}) + assert isinstance(result, ImageStack) + + filter_runnable = Runnable( + SimpleFilterAlgorithm, + dependency_runnable, + multiplicand=2.0, + ) + with pytest.raises(KeyError): + filter_runnable.run({}) + + +def test_load_data_for_constructor(): + """Verify that we can properly load up data from a FileProvider that is required for the + constructor.""" + filter_runnable = Runnable( + AdditiveFilterAlgorithm, + FileProvider(URL), + additive=FileProvider(URL), + ) + result = filter_runnable.run({}) + assert isinstance(result, ImageStack) + + # pick a random part of the filtered image and assert on it + assert result.xarray.dtype == np.float32 + + assert np.allclose( + BASE_EXPECTED * 2, + result.xarray[2, 2, 0, 40:50, 40:50] + )