diff --git a/smartsim/_core/utils/helpers.py b/smartsim/_core/utils/helpers.py index fef1e792f..9103009c9 100644 --- a/smartsim/_core/utils/helpers.py +++ b/smartsim/_core/utils/helpers.py @@ -69,11 +69,18 @@ def unpack(value: _NestedJobSequenceType) -> t.Generator[Job, None, None]: :param value: Sequence containing elements of type Job or other sequences that are also of type _NestedJobSequenceType :return: flattened list of Jobs""" + from smartsim.launchable.job import Job for item in value: + if isinstance(item, t.Iterable): + # string are iterable of string. Avoid infinite recursion + if isinstance(item, str): + raise TypeError("jobs argument was not of type Job") yield from unpack(item) else: + if not isinstance(item, Job): + raise TypeError("jobs argument was not of type Job") yield item @@ -157,10 +164,13 @@ def expand_exe_path(exe: str) -> str: """Takes an executable and returns the full path to that executable :param exe: executable or file + :raises ValueError: if no executable is provided :raises TypeError: if file is not an executable :raises FileNotFoundError: if executable cannot be found """ + if not exe: + raise ValueError("No executable provided") # which returns none if not found in_path = which(exe) if not in_path: diff --git a/smartsim/builders/ensemble.py b/smartsim/builders/ensemble.py index c4a57175f..d8a16880b 100644 --- a/smartsim/builders/ensemble.py +++ b/smartsim/builders/ensemble.py @@ -26,6 +26,7 @@ from __future__ import annotations +import collections import copy import itertools import os @@ -38,6 +39,7 @@ from smartsim.entity.application import Application from smartsim.entity.files import EntityFiles from smartsim.launchable.job import Job +from smartsim.settings.launch_settings import LaunchSettings if t.TYPE_CHECKING: from smartsim.settings.launch_settings import LaunchSettings @@ -137,7 +139,7 @@ def __init__( copy.deepcopy(exe_arg_parameters) if exe_arg_parameters else {} ) """The parameters and values to be used when configuring entities""" - self._files = copy.deepcopy(files) if files else None + self._files = copy.deepcopy(files) if files else EntityFiles() """The files to be copied, symlinked, and/or configured prior to execution""" self._file_parameters = ( copy.deepcopy(file_parameters) if file_parameters else {} @@ -163,7 +165,11 @@ def exe(self, value: str | os.PathLike[str]) -> None: """Set the executable. :param value: the executable + :raises TypeError: if the exe argument is not str or PathLike str """ + if not isinstance(value, (str, os.PathLike)): + raise TypeError("exe argument was not of type str or PathLike str") + self._exe = os.fspath(value) @property @@ -179,7 +185,15 @@ def exe_args(self, value: t.Sequence[str]) -> None: """Set the executable arguments. :param value: the executable arguments + :raises TypeError: if exe_args is not sequence of str """ + + if not ( + isinstance(value, collections.abc.Sequence) + and (all(isinstance(x, str) for x in value)) + ): + raise TypeError("exe_args argument was not of type sequence of str") + self._exe_args = list(value) @property @@ -197,11 +211,36 @@ def exe_arg_parameters( """Set the executable argument parameters. :param value: the executable argument parameters + :raises TypeError: if exe_arg_parameters is not mapping + of str and sequences of sequences of strings """ + + if not ( + isinstance(value, collections.abc.Mapping) + and ( + all( + isinstance(key, str) + and isinstance(val, collections.abc.Sequence) + and all( + isinstance(subval, collections.abc.Sequence) for subval in val + ) + and all( + isinstance(item, str) + for item in itertools.chain.from_iterable(val) + ) + for key, val in value.items() + ) + ) + ): + raise TypeError( + "exe_arg_parameters argument was not of type " + "mapping of str and sequences of sequences of strings" + ) + self._exe_arg_parameters = copy.deepcopy(value) @property - def files(self) -> t.Union[EntityFiles, None]: + def files(self) -> EntityFiles: """Return attached EntityFiles object. :return: the EntityFiles object of files to be copied, symlinked, @@ -210,12 +249,16 @@ def files(self) -> t.Union[EntityFiles, None]: return self._files @files.setter - def files(self, value: t.Optional[EntityFiles]) -> None: + def files(self, value: EntityFiles) -> None: """Set the EntityFiles object. :param value: the EntityFiles object of files to be copied, symlinked, and/or configured prior to execution + :raises TypeError: if files is not of type EntityFiles """ + + if not isinstance(value, EntityFiles): + raise TypeError("files argument was not of type EntityFiles") self._files = copy.deepcopy(value) @property @@ -231,7 +274,26 @@ def file_parameters(self, value: t.Mapping[str, t.Sequence[str]]) -> None: """Set the file parameters. :param value: the file parameters + :raises TypeError: if file_parameters is not a mapping of str and + sequence of str """ + + if not ( + isinstance(value, t.Mapping) + and ( + all( + isinstance(key, str) + and isinstance(val, collections.abc.Sequence) + and all(isinstance(subval, str) for subval in val) + for key, val in value.items() + ) + ) + ): + raise TypeError( + "file_parameters argument was not of type mapping of str " + "and sequence of str" + ) + self._file_parameters = dict(value) @property @@ -249,7 +311,15 @@ def permutation_strategy( """Set the permutation strategy :param value: the permutation strategy + :raises TypeError: if permutation_strategy is not str or + PermutationStrategyType """ + + if not (callable(value) or isinstance(value, str)): + raise TypeError( + "permutation_strategy argument was not of " + "type str or PermutationStrategyType" + ) self._permutation_strategy = value @property @@ -265,7 +335,11 @@ def max_permutations(self, value: int) -> None: """Set the maximum permutations :param value: the max permutations + :raises TypeError: max_permutations argument was not of type int """ + if not isinstance(value, int): + raise TypeError("max_permutations argument was not of type int") + self._max_permutations = value @property @@ -281,7 +355,13 @@ def replicas(self, value: int) -> None: """Set the number of replicas. :return: the number of replicas + :raises TypeError: replicas argument was not of type int """ + if not isinstance(value, int): + raise TypeError("replicas argument was not of type int") + if value <= 0: + raise ValueError("Number of replicas must be a positive integer") + self._replicas = value def _create_applications(self) -> tuple[Application, ...]: @@ -342,7 +422,11 @@ def build_jobs(self, settings: LaunchSettings) -> tuple[Job, ...]: :param settings: LaunchSettings to apply to each Job :return: Sequence of Jobs with the provided LaunchSettings + :raises TypeError: if the ids argument is not type LaunchSettings + :raises ValueError: if the LaunchSettings provided are empty """ + if not isinstance(settings, LaunchSettings): + raise TypeError("ids argument was not of type LaunchSettings") apps = self._create_applications() if not apps: raise ValueError("There are no members as part of this ensemble") diff --git a/smartsim/entity/application.py b/smartsim/entity/application.py index fb3ed2a7e..402f0aa30 100644 --- a/smartsim/entity/application.py +++ b/smartsim/entity/application.py @@ -88,7 +88,7 @@ def __init__( """The executable to run""" self._exe_args = self._build_exe_args(exe_args) or [] """The executable arguments""" - self._files = copy.deepcopy(files) if files else None + self._files = copy.deepcopy(files) if files else EntityFiles() """Files to be copied, symlinked, and/or configured prior to execution""" self._file_parameters = ( copy.deepcopy(file_parameters) if file_parameters else {} @@ -112,8 +112,16 @@ def exe(self, value: str) -> None: """Set the executable. :param value: the executable + :raises TypeError: exe argument is not int + """ - self._exe = copy.deepcopy(value) + if not isinstance(value, str): + raise TypeError("exe argument was not of type str") + + if value == "": + raise ValueError("exe cannot be an empty str") + + self._exe = value @property def exe_args(self) -> t.MutableSequence[str]: @@ -149,12 +157,18 @@ def files(self) -> t.Union[EntityFiles, None]: return self._files @files.setter - def files(self, value: t.Optional[EntityFiles]) -> None: + def files(self, value: EntityFiles) -> None: """Set the EntityFiles object. :param value: the EntityFiles object of files to be copied, symlinked, and/or configured prior to execution + :raises TypeError: files argument was not of type int + """ + + if not isinstance(value, EntityFiles): + raise TypeError("files argument was not of type EntityFiles") + self._files = copy.deepcopy(value) @property @@ -170,7 +184,18 @@ def file_parameters(self, value: t.Mapping[str, str]) -> None: """Set the file parameters. :param value: the file parameters + :raises TypeError: file_parameters argument is not a mapping of str and str """ + if not ( + isinstance(value, t.Mapping) + and all( + isinstance(key, str) and isinstance(val, str) + for key, val in value.items() + ) + ): + raise TypeError( + "file_parameters argument was not of type mapping of str and str" + ) self._file_parameters = copy.deepcopy(value) @property @@ -186,7 +211,15 @@ def incoming_entities(self, value: t.List[SmartSimEntity]) -> None: """Set the incoming entities. :param value: incoming entities + :raises TypeError: incoming_entities argument is not a list of SmartSimEntity """ + if not isinstance(value, list) or not all( + isinstance(x, SmartSimEntity) for x in value + ): + raise TypeError( + "incoming_entities argument was not of type list of SmartSimEntity" + ) + self._incoming_entities = copy.copy(value) @property @@ -202,7 +235,11 @@ def key_prefixing_enabled(self, value: bool) -> None: """Set whether key prefixing is enabled for the application. :param value: key prefixing enabled + :raises TypeError: key prefixings enabled argument was not of type bool """ + if not isinstance(value, bool): + raise TypeError("key_prefixing_enabled argument was not of type bool") + self.key_prefixing_enabled = copy.deepcopy(value) def as_executable_sequence(self) -> t.Sequence[str]: @@ -264,8 +301,6 @@ def attached_files_table(self) -> str: :return: String version of table """ - if not self.files: - return "No file attached to this application." return str(self.files) @staticmethod diff --git a/smartsim/experiment.py b/smartsim/experiment.py index 2aa04bc09..2af726959 100644 --- a/smartsim/experiment.py +++ b/smartsim/experiment.py @@ -42,6 +42,7 @@ from smartsim._core.control.launch_history import LaunchHistory as _LaunchHistory from smartsim._core.utils import helpers as _helpers from smartsim.error import errors +from smartsim.launchable.job import Job from smartsim.status import TERMINAL_STATUSES, InvalidJobStatus, JobStatus from ._core import Generator, Manifest @@ -125,7 +126,11 @@ def __init__(self, name: str, exp_path: str | None = None): :param name: name for the ``Experiment`` :param exp_path: path to location of ``Experiment`` directory """ - if not name: + + if name: + if not isinstance(name, str): + raise TypeError("name argument was not of type str") + else: raise TypeError("Experiment name must be non-empty string") self.name = name @@ -157,11 +162,19 @@ def start(self, *jobs: Job | t.Sequence[Job]) -> tuple[LaunchedJobID, ...]: """Execute a collection of `Job` instances. :param jobs: A collection of other job instances to start + :raises TypeError: If jobs provided are not the correct type + :raises ValueError: No Jobs were provided. :returns: A sequence of ids with order corresponding to the sequence of jobs that can be used to query or alter the status of that particular execution of the job. """ + + if not jobs: + raise ValueError("No jobs provided to start") + + # Create the run id jobs_ = list(_helpers.unpack(jobs)) + run_id = datetime.datetime.now().replace(microsecond=0).isoformat() root = pathlib.Path(self.exp_path, run_id) return self._dispatch(Generator(root), dispatch.DEFAULT_DISPATCHER, *jobs_) @@ -240,9 +253,16 @@ def get_status( unique. :param ids: A sequence of launched job ids issued by the experiment. + :raises TypeError: If ids provided are not the correct type + :raises ValueError: No IDs were provided. :returns: A tuple of statuses with order respective of the order of the calling arguments. """ + if not ids: + raise ValueError("No job ids provided to get status") + if not all(isinstance(id, str) for id in ids): + raise TypeError("ids argument was not of type LaunchedJobID") + to_query = self._launch_history.group_by_launcher( set(ids), unknown_ok=True ).items() @@ -260,9 +280,13 @@ def wait( :param ids: The ids of the launched jobs to wait for. :param timeout: The max time to wait for all of the launched jobs to end. :param verbose: Whether found statuses should be displayed in the console. + :raises TypeError: If IDs provided are not the correct type :raises ValueError: No IDs were provided. """ - if not ids: + if ids: + if not all(isinstance(id, str) for id in ids): + raise TypeError("ids argument was not of type LaunchedJobID") + else: raise ValueError("No job ids to wait on provided") self._poll_for_statuses( ids, TERMINAL_STATUSES, timeout=timeout, verbose=verbose @@ -425,11 +449,15 @@ def stop(self, *ids: LaunchedJobID) -> tuple[JobStatus | InvalidJobStatus, ...]: """Cancel the execution of a previously launched job. :param ids: The ids of the launched jobs to stop. + :raises TypeError: If ids provided are not the correct type :raises ValueError: No job ids were provided. :returns: A tuple of job statuses upon cancellation with order respective of the order of the calling arguments. """ - if not ids: + if ids: + if not all(isinstance(id, str) for id in ids): + raise TypeError("ids argument was not of type LaunchedJobID") + else: raise ValueError("No job ids provided") by_launcher = self._launch_history.group_by_launcher(set(ids), unknown_ok=True) id_to_stop_stat = ( diff --git a/smartsim/launchable/job.py b/smartsim/launchable/job.py index 6ec2bbbc4..6082ba61d 100644 --- a/smartsim/launchable/job.py +++ b/smartsim/launchable/job.py @@ -77,9 +77,9 @@ def __init__( """ super().__init__() """Initialize the parent class BaseJob""" - self._entity = deepcopy(entity) + self.entity = entity """Deepcopy of the SmartSimEntity object""" - self._launch_settings = deepcopy(launch_settings) + self.launch_settings = launch_settings """Deepcopy of the LaunchSettings object""" self._name = name if name else entity.name """Name of the Job""" @@ -116,7 +116,13 @@ def entity(self, value: SmartSimEntity) -> None: """Set the Job entity. :param value: the SmartSimEntity + :raises Type Error: if entity is not SmartSimEntity """ + from smartsim.entity.entity import SmartSimEntity + + if not isinstance(value, SmartSimEntity): + raise TypeError("entity argument was not of type SmartSimEntity") + self._entity = deepcopy(value) @property @@ -132,7 +138,11 @@ def launch_settings(self, value: LaunchSettings) -> None: """Set the Jobs LaunchSettings. :param value: the LaunchSettings + :raises Type Error: if launch_settings is not a LaunchSettings """ + if not isinstance(value, LaunchSettings): + raise TypeError("launch_settings argument was not of type LaunchSettings") + self._launch_settings = deepcopy(value) def get_launch_steps(self) -> LaunchCommands: diff --git a/tests/temp_tests/test_launchable.py b/tests/temp_tests/test_launchable.py index e87e68902..de7d12e60 100644 --- a/tests/temp_tests/test_launchable.py +++ b/tests/temp_tests/test_launchable.py @@ -115,6 +115,31 @@ def test_job_init_deepcopy(): assert job.launch_settings.launcher is not test +def test_job_type_entity(): + entity = "invalid" + settings = LaunchSettings("slurm") + with pytest.raises( + TypeError, + match="entity argument was not of type SmartSimEntity", + ): + Job(entity, settings) + + +def test_job_type_launch_settings(): + entity = Application( + "test_name", + exe="echo", + exe_args=["spam", "eggs"], + ) + settings = "invalid" + + with pytest.raises( + TypeError, + match="launch_settings argument was not of type LaunchSettings", + ): + Job(entity, settings) + + def test_add_mpmd_pair(): entity = EchoHelloWorldEntity() diff --git a/tests/test_application.py b/tests/test_application.py new file mode 100644 index 000000000..d32932150 --- /dev/null +++ b/tests/test_application.py @@ -0,0 +1,244 @@ +# BSD 2-Clause License +# +# Copyright (c) 2021-2024, Hewlett Packard Enterprise +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# 1. Redistributions of source code must retain the above copyright notice, this +# list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +from glob import glob +from os import path as osp + +import pytest + +from smartsim.entity.application import Application +from smartsim.entity.files import EntityFiles +from smartsim.settings.launch_settings import LaunchSettings + +pytestmark = pytest.mark.group_a + + +@pytest.fixture +def get_gen_configure_dir(fileutils): + yield fileutils.get_test_conf_path(osp.join("generator_files", "tag_dir_template")) + + +@pytest.fixture +def mock_launcher_settings(wlmutils): + return LaunchSettings(wlmutils.get_test_launcher(), {}, {}) + + +def test_application_exe_property(): + a = Application( + "test_name", + exe="echo", + exe_args=["spam", "eggs"], + ) + exe = a.exe + assert exe is a.exe + + +def test_application_exe_args_property(): + a = Application("test_name", exe="echo", exe_args=["spam", "eggs"]) + exe_args = a.exe_args + assert exe_args is a.exe_args + + +def test_application_files_property(get_gen_configure_dir): + tagged_files = sorted(glob(get_gen_configure_dir + "/*")) + files = EntityFiles(tagged=tagged_files) + a = Application("test_name", exe="echo", exe_args=["spam", "eggs"], files=files) + files = a.files + assert files is a.files + + +def test_application_file_parameters_property(): + file_parameters = {"h": [5, 6, 7, 8]} + a = Application( + "test_name", + exe="echo", + file_parameters=file_parameters, + ) + file_parameters = a.file_parameters + + assert file_parameters is a.file_parameters + + +def test_application_key_prefixing_property(): + key_prefixing_enabled = True + a = Application("test_name", exe="echo", exe_args=["spam", "eggs"]) + key_prefixing_enabled = a.key_prefixing_enabled + assert key_prefixing_enabled == a.key_prefixing_enabled + + +def test_empty_executable(): + """Test that an error is raised when the exe property is empty""" + with pytest.raises(ValueError): + Application(name="application", exe=None, exe_args=None) + + +def test_executable_is_not_empty_str(): + """Test that an error is raised when the exe property is and empty str""" + app = Application(name="application", exe="echo", exe_args=None) + with pytest.raises(ValueError): + app.exe = "" + + +def test_type_exe(): + with pytest.raises(TypeError): + Application( + "test_name", + exe=2, + exe_args=["spam", "eggs"], + ) + + +def test_type_exe_args(): + application = Application( + "test_name", + exe="echo", + ) + with pytest.raises(TypeError): + application.exe_args = [1, 2, 3] + + +def test_type_files_property(): + application = Application( + "test_name", + exe="echo", + ) + with pytest.raises(TypeError): + application.files = "/path/to/file" + + +def test_type_file_parameters_property(): + application = Application( + "test_name", + exe="echo", + ) + with pytest.raises(TypeError): + application.file_parameters = {1: 2} + + +def test_type_incoming_entities(): + application = Application( + "test_name", + exe="echo", + exe_args=["spam", "eggs"], + ) + with pytest.raises(TypeError): + application.incoming_entities = [1, 2, 3] + + +# application type checks +def test_application_type_exe(): + application = Application( + "test_name", + exe="echo", + exe_args=["spam", "eggs"], + ) + with pytest.raises(TypeError, match="exe argument was not of type str"): + application.exe = 2 + + +def test_application_type_exe_args(): + application = Application( + "test_name", + exe="echo", + exe_args=["spam", "eggs"], + ) + with pytest.raises( + TypeError, match="Executable arguments were not a list of str or a str." + ): + application.exe_args = [1, 2, 3] + + +def test_application_type_files(): + application = Application( + "test_name", + exe="echo", + exe_args=["spam", "eggs"], + ) + with pytest.raises(TypeError, match="files argument was not of type EntityFiles"): + application.files = 2 + + +@pytest.mark.parametrize( + "file_params", + ( + pytest.param(["invalid"], id="Not a mapping"), + pytest.param({"1": 2}, id="Value is not mapping of str and str"), + pytest.param({1: "2"}, id="Key is not mapping of str and str"), + pytest.param({1: 2}, id="Values not mapping of str and str"), + ), +) +def test_application_type_file_parameters(file_params): + application = Application( + "test_name", + exe="echo", + exe_args=["spam", "eggs"], + ) + with pytest.raises( + TypeError, + match="file_parameters argument was not of type mapping of str and str", + ): + application.file_parameters = file_params + + +def test_application_type_incoming_entities(): + + application = Application( + "test_name", + exe="echo", + exe_args=["spam", "eggs"], + ) + with pytest.raises( + TypeError, + match="incoming_entities argument was not of type list of SmartSimEntity", + ): + application.incoming_entities = [1, 2, 3] + + +def test_application_type_key_prefixing_enabled(): + + application = Application( + "test_name", + exe="echo", + exe_args=["spam", "eggs"], + ) + with pytest.raises( + TypeError, + match="key_prefixing_enabled argument was not of type bool", + ): + application.key_prefixing_enabled = "invalid" + + +def test_application_type_build_exe_args(): + application = Application( + "test_name", + exe="echo", + exe_args=["spam", "eggs"], + ) + with pytest.raises( + TypeError, match="Executable arguments were not a list of str or a str." + ): + + application.exe_args = [1, 2, 3] diff --git a/tests/test_ensemble.py b/tests/test_ensemble.py index 9c9015251..1bfbd0b67 100644 --- a/tests/test_ensemble.py +++ b/tests/test_ensemble.py @@ -24,7 +24,6 @@ # OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -import itertools import typing as t from glob import glob from os import path as osp @@ -47,6 +46,19 @@ def get_gen_configure_dir(fileutils): yield fileutils.get_test_conf_path(osp.join("generator_files", "tag_dir_template")) +def user_created_function( + file_params: t.Mapping[str, t.Sequence[str]], + exe_arg_params: t.Mapping[str, t.Sequence[t.Sequence[str]]], + n_permutations: int = 0, +) -> list[ParamSet]: + return [ParamSet({}, {})] + + +@pytest.fixture +def mock_launcher_settings(wlmutils): + return LaunchSettings(wlmutils.get_test_launcher(), {}, {}) + + def test_exe_property(): e = Ensemble(name="test", exe="path/to/example_simulation_program") exe = e.exe @@ -86,21 +98,177 @@ def test_file_parameters_property(): file_parameters=file_parameters, ) file_parameters = e.file_parameters - assert file_parameters == e.file_parameters -def user_created_function( - file_params: t.Mapping[str, t.Sequence[str]], - exe_arg_params: t.Mapping[str, t.Sequence[t.Sequence[str]]], - n_permutations: int = 0, -) -> list[ParamSet]: - return [ParamSet({}, {})] +def test_ensemble_init_empty_params(test_dir: str) -> None: + """Ensemble created without required args""" + with pytest.raises(TypeError): + Ensemble() -@pytest.fixture -def mock_launcher_settings(wlmutils): - return LaunchSettings(wlmutils.get_test_launcher(), {}, {}) +@pytest.mark.parametrize( + "bad_settings", + [pytest.param(None, id="Nullish"), pytest.param("invalid", id="String")], +) +def test_ensemble_incorrect_launch_settings_type(bad_settings): + """test starting an ensemble with invalid launch settings""" + ensemble = Ensemble("ensemble-name", "echo", replicas=2) + with pytest.raises(TypeError): + ensemble.build_jobs(bad_settings) + + +def test_ensemble_type_exe(): + ensemble = Ensemble( + "ensemble-name", + exe="valid", + exe_args=["spam", "eggs"], + ) + with pytest.raises( + TypeError, match="exe argument was not of type str or PathLike str" + ): + ensemble.exe = 2 + + +@pytest.mark.parametrize( + "bad_settings", + [ + pytest.param([1, 2, 3], id="sequence of ints"), + pytest.param(0, id="null"), + pytest.param({"foo": "bar"}, id="dict"), + ], +) +def test_ensemble_type_exe_args(bad_settings): + ensemble = Ensemble( + "ensemble-name", + exe="echo", + ) + with pytest.raises( + TypeError, match="exe_args argument was not of type sequence of str" + ): + ensemble.exe_args = bad_settings + + +@pytest.mark.parametrize( + "exe_arg_params", + ( + pytest.param(["invalid"], id="Not a mapping"), + pytest.param({"key": [1, 2, 3]}, id="Value is not sequence of sequences"), + pytest.param( + {"key": [[1, 2, 3], [4, 5, 6]]}, + id="Value is not sequence of sequence of str", + ), + pytest.param( + {1: 2}, + id="key and value wrong type", + ), + pytest.param({"1": 2}, id="Value is not mapping of str and str"), + pytest.param({1: "2"}, id="Key is not str"), + pytest.param({1: 2}, id="Values not mapping of str and str"), + ), +) +def test_ensemble_type_exe_arg_parameters(exe_arg_params): + ensemble = Ensemble( + "ensemble-name", + exe="echo", + exe_args=["spam", "eggs"], + ) + with pytest.raises( + TypeError, + match="exe_arg_parameters argument was not of type mapping " + "of str and sequences of sequences of strings", + ): + ensemble.exe_arg_parameters = exe_arg_params + + +def test_ensemble_type_files(): + ensemble = Ensemble( + "ensemble-name", + exe="echo", + exe_args=["spam", "eggs"], + ) + with pytest.raises(TypeError, match="files argument was not of type EntityFiles"): + ensemble.files = 2 + + +@pytest.mark.parametrize( + "file_params", + ( + pytest.param(["invalid"], id="Not a mapping"), + pytest.param({"key": [1, 2, 3]}, id="Key is not sequence of sequences"), + ), +) +def test_ensemble_type_file_parameters(file_params): + ensemble = Ensemble( + "ensemble-name", + exe="echo", + exe_args=["spam", "eggs"], + ) + with pytest.raises( + TypeError, + match="file_parameters argument was not of type " + "mapping of str and sequence of str", + ): + ensemble.file_parameters = file_params + + +def test_ensemble_type_permutation_strategy(): + ensemble = Ensemble( + "ensemble-name", + exe="echo", + exe_args=["spam", "eggs"], + ) + with pytest.raises( + TypeError, + match="permutation_strategy argument was not of " + "type str or PermutationStrategyType", + ): + ensemble.permutation_strategy = 2 + + +def test_ensemble_type_max_permutations(): + ensemble = Ensemble( + "ensemble-name", + exe="echo", + exe_args=["spam", "eggs"], + ) + with pytest.raises( + TypeError, + match="max_permutations argument was not of type int", + ): + ensemble.max_permutations = "invalid" + + +def test_ensemble_type_replicas(): + ensemble = Ensemble( + "ensemble-name", + exe="echo", + exe_args=["spam", "eggs"], + ) + with pytest.raises( + TypeError, + match="replicas argument was not of type int", + ): + ensemble.replicas = "invalid" + + +def test_ensemble_type_replicas_negative(): + ensemble = Ensemble( + "ensemble-name", + exe="echo", + exe_args=["spam", "eggs"], + ) + with pytest.raises( + ValueError, + match="Number of replicas must be a positive integer", + ): + ensemble.replicas = -20 + + +def test_ensemble_type_build_jobs(): + ensemble = Ensemble("ensemble-name", "echo", replicas=2) + with pytest.raises(TypeError): + ensemble.build_jobs("invalid") def test_ensemble_user_created_strategy(mock_launcher_settings, test_dir): @@ -212,7 +380,7 @@ def test_all_perm_strategy( assert len(jobs) == expected_num_jobs -def test_all_perm_strategy_contents(): +def test_all_perm_strategy_contents(mock_launcher_settings): jobs = Ensemble( "test_ensemble", "echo", diff --git a/tests/test_experiment.py b/tests/test_experiment.py index 474e1d9ff..45f3ecf8e 100644 --- a/tests/test_experiment.py +++ b/tests/test_experiment.py @@ -226,11 +226,6 @@ def as_executable_sequence(self): return ("echo", "Hello", "World!") -def test_start_raises_if_no_args_supplied(experiment): - with pytest.raises(TypeError, match="missing 1 required positional argument"): - experiment.start() - - # fmt: off @pytest.mark.parametrize( "num_jobs", [pytest.param(i, id=f"{i} job(s)") for i in (1, 2, 3, 5, 10, 100, 1_000)] @@ -625,6 +620,67 @@ def test_experiment_stop_does_not_raise_on_unknown_job_id( assert before_cancel == after_cancel +def test_start_raises_if_no_args_supplied(test_dir): + exp = Experiment(name="exp_name", exp_path=test_dir) + with pytest.raises(ValueError, match="No jobs provided to start"): + exp.start() + + +def test_stop_raises_if_no_args_supplied(test_dir): + exp = Experiment(name="exp_name", exp_path=test_dir) + with pytest.raises(ValueError, match="No job ids provided"): + exp.stop() + + +def test_get_status_raises_if_no_args_supplied(test_dir): + exp = Experiment(name="exp_name", exp_path=test_dir) + with pytest.raises(ValueError, match="No job ids provided"): + exp.get_status() + + +def test_poll_raises_if_no_args_supplied(test_dir): + exp = Experiment(name="exp_name", exp_path=test_dir) + with pytest.raises( + TypeError, match="missing 2 required positional arguments: 'ids' and 'statuses'" + ): + exp._poll_for_statuses() + + +def test_wait_raises_if_no_args_supplied(test_dir): + exp = Experiment(name="exp_name", exp_path=test_dir) + with pytest.raises(ValueError, match="No job ids to wait on provided"): + exp.wait() + + +def test_type_experiment_name_parameter(test_dir): + with pytest.raises(TypeError, match="name argument was not of type str"): + Experiment(name=1, exp_path=test_dir) + + +def test_type_start_parameters(test_dir): + exp = Experiment(name="exp_name", exp_path=test_dir) + with pytest.raises(TypeError, match="jobs argument was not of type Job"): + exp.start("invalid") + + +def test_type_get_status_parameters(test_dir): + exp = Experiment(name="exp_name", exp_path=test_dir) + with pytest.raises(TypeError, match="ids argument was not of type LaunchedJobID"): + exp.get_status(2) + + +def test_type_wait_parameter(test_dir): + exp = Experiment(name="exp_name", exp_path=test_dir) + with pytest.raises(TypeError, match="ids argument was not of type LaunchedJobID"): + exp.wait(2) + + +def test_type_stop_parameter(test_dir): + exp = Experiment(name="exp_name", exp_path=test_dir) + with pytest.raises(TypeError, match="ids argument was not of type LaunchedJobID"): + exp.stop(2) + + @pytest.mark.parametrize( "job_list", (