diff --git a/smartsim/_core/generation/operations/ensemble_operations.py b/smartsim/_core/generation/operations/ensemble_operations.py new file mode 100644 index 0000000000..5e1d0038d1 --- /dev/null +++ b/smartsim/_core/generation/operations/ensemble_operations.py @@ -0,0 +1,165 @@ +import pathlib +import typing as t +from dataclasses import dataclass, field + +from .operations import default_tag +from .utils.helpers import check_src_and_dest_path + + +class EnsembleGenerationProtocol(t.Protocol): + """Protocol for Ensemble Generation Operations.""" + + src: pathlib.Path + """Path to source""" + dest: t.Optional[pathlib.Path] + """Path to destination""" + + +class EnsembleCopyOperation(EnsembleGenerationProtocol): + """Ensemble Copy Operation""" + + def __init__( + self, src: pathlib.Path, dest: t.Optional[pathlib.Path] = None + ) -> None: + """Initialize a EnsembleCopyOperation object + + :param src: Path to source + :param dest: Path to destination + """ + check_src_and_dest_path(src, dest) + self.src = src + """Path to source""" + self.dest = dest + """Path to destination""" + + +class EnsembleSymlinkOperation(EnsembleGenerationProtocol): + """Ensemble Symlink Operation""" + + def __init__( + self, src: pathlib.Path, dest: t.Optional[pathlib.Path] = None + ) -> None: + """Initialize a EnsembleSymlinkOperation object + + :param src: Path to source + :param dest: Path to destination + """ + check_src_and_dest_path(src, dest) + self.src = src + """Path to source""" + self.dest = dest + """Path to destination""" + + +class EnsembleConfigureOperation(EnsembleGenerationProtocol): + """Ensemble Configure Operation""" + + def __init__( + self, + src: pathlib.Path, + file_parameters: t.Mapping[str, t.Sequence[str]], + dest: t.Optional[pathlib.Path] = None, + tag: t.Optional[str] = None, + ) -> None: + """Initialize a EnsembleConfigureOperation + + :param src: Path to source + :param file_parameters: File parameters to find and replace + :param dest: Path to destination + :param tag: Tag to use for find and replacement + """ + check_src_and_dest_path(src, dest) + self.src = src + """Path to source""" + self.dest = dest + """Path to destination""" + self.file_parameters = file_parameters + """File parameters to find and replace""" + self.tag = tag if tag else default_tag + """Tag to use for the file""" + + +EnsembleGenerationProtocolT = t.TypeVar( + "EnsembleGenerationProtocolT", bound=EnsembleGenerationProtocol +) + + +@dataclass +class EnsembleFileSysOperationSet: + """Dataclass to represent a set of Ensemble file system operation objects""" + + operations: list[EnsembleGenerationProtocol] = field(default_factory=list) + """Set of Ensemble file system objects that match the EnsembleGenerationProtocol""" + + def add_copy( + self, src: pathlib.Path, dest: t.Optional[pathlib.Path] = None + ) -> None: + """Add a copy operation to the operations list + + :param src: Path to source + :param dest: Path to destination + """ + self.operations.append(EnsembleCopyOperation(src, dest)) + + def add_symlink( + self, src: pathlib.Path, dest: t.Optional[pathlib.Path] = None + ) -> None: + """Add a symlink operation to the operations list + + :param src: Path to source + :param dest: Path to destination + """ + self.operations.append(EnsembleSymlinkOperation(src, dest)) + + def add_configuration( + self, + src: pathlib.Path, + file_parameters: t.Mapping[str, t.Sequence[str]], + dest: t.Optional[pathlib.Path] = None, + tag: t.Optional[str] = None, + ) -> None: + """Add a configure operation to the operations list + + :param src: Path to source + :param file_parameters: File parameters to find and replace + :param dest: Path to destination + :param tag: Tag to use for find and replacement + """ + self.operations.append( + EnsembleConfigureOperation(src, file_parameters, dest, tag) + ) + + @property + def copy_operations(self) -> list[EnsembleCopyOperation]: + """Property to get the list of copy files. + + :return: List of EnsembleCopyOperation objects + """ + return self._filter(EnsembleCopyOperation) + + @property + def symlink_operations(self) -> list[EnsembleSymlinkOperation]: + """Property to get the list of symlink files. + + :return: List of EnsembleSymlinkOperation objects + """ + return self._filter(EnsembleSymlinkOperation) + + @property + def configure_operations(self) -> list[EnsembleConfigureOperation]: + """Property to get the list of configure files. + + :return: List of EnsembleConfigureOperation objects + """ + return self._filter(EnsembleConfigureOperation) + + def _filter( + self, type: t.Type[EnsembleGenerationProtocolT] + ) -> list[EnsembleGenerationProtocolT]: + """Filters the operations list to include only instances of the + specified type. + + :param type: The type of operations to filter + :return: A list of operations that are instances of the specified type + """ + return [x for x in self.operations if isinstance(x, type)] diff --git a/smartsim/builders/ensemble.py b/smartsim/builders/ensemble.py index d87ada15aa..efbf197468 100644 --- a/smartsim/builders/ensemble.py +++ b/smartsim/builders/ensemble.py @@ -31,22 +31,38 @@ import itertools import os import os.path +import random import typing as t +from dataclasses import dataclass +from smartsim._core.generation.operations.ensemble_operations import ( + EnsembleConfigureOperation, + EnsembleFileSysOperationSet, +) from smartsim.builders.utils import strategies from smartsim.builders.utils.strategies import ParamSet from smartsim.entity import entity 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 +@dataclass(frozen=True) +class FileSet: + """ + Represents a relationship between a parameterized set of arguments and the configuration file. + """ + + file: EnsembleConfigureOperation + """The configuration file associated with the parameter set""" + combination: ParamSet + """The set of parameters""" + + class Ensemble(entity.CompoundEntity): - """An Ensemble is a builder class that parameterizes the creation of multiple + """An Ensemble is a builder class to parameterize the creation of multiple Applications. """ @@ -56,8 +72,6 @@ def __init__( exe: str | os.PathLike[str], exe_args: t.Sequence[str] | None = None, exe_arg_parameters: t.Mapping[str, t.Sequence[t.Sequence[str]]] | None = None, - files: EntityFiles | None = None, - file_parameters: t.Mapping[str, t.Sequence[str]] | None = None, permutation_strategy: str | strategies.PermutationStrategyType = "all_perm", max_permutations: int = -1, replicas: int = 1, @@ -121,10 +135,6 @@ def __init__( :param exe: executable to run :param exe_args: executable arguments :param exe_arg_parameters: parameters and values to be used when configuring entities - :param files: files to be copied, symlinked, and/or configured prior to - execution - :param file_parameters: parameters and values to be used when configuring - files :param permutation_strategy: strategy to control how the param values are applied to the Ensemble :param max_permutations: max parameter permutations to set for the ensemble :param replicas: number of identical entities to create within an Ensemble @@ -139,12 +149,8 @@ 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 EntityFiles() + self.files = EnsembleFileSysOperationSet([]) """The files to be copied, symlinked, and/or configured prior to execution""" - self._file_parameters = ( - copy.deepcopy(file_parameters) if file_parameters else {} - ) - """The parameters and values to be used when configuring files""" self._permutation_strategy = permutation_strategy """The strategy to control how the param values are applied to the Ensemble""" self._max_permutations = max_permutations @@ -173,7 +179,7 @@ def exe(self, value: str | os.PathLike[str]) -> None: self._exe = os.fspath(value) @property - def exe_args(self) -> t.List[str]: + def exe_args(self) -> list[str]: """Return attached list of executable arguments. :return: the executable arguments @@ -239,63 +245,6 @@ def exe_arg_parameters( self._exe_arg_parameters = copy.deepcopy(value) - @property - def files(self) -> EntityFiles: - """Return attached EntityFiles object. - - :return: the EntityFiles object of files to be copied, symlinked, - and/or configured prior to execution - """ - return self._files - - @files.setter - 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 - def file_parameters(self) -> t.Mapping[str, t.Sequence[str]]: - """Return the attached file parameters. - - :return: the file parameters - """ - return self._file_parameters - - @file_parameters.setter - 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 def permutation_strategy(self) -> str | strategies.PermutationStrategyType: """Return the permutation strategy @@ -364,6 +313,40 @@ def replicas(self, value: int) -> None: self._replicas = value + def _permutate_file_parameters( + self, + file: EnsembleConfigureOperation, + permutation_strategy: strategies.PermutationStrategyType, + ) -> list[FileSet]: + """Generate all possible permutations of file parameters using the provided strategy, + and create FileSet objects. + + This method applies the provided permutation strategy to the file's parameters, + along with execution argument parameters and a maximum permutation limit. + It returns a list of FileSet objects, each containing one of the generated + ParamSets and an instance of the EnsembleConfigurationObject. + + :param file: The configuration file + :param permutation_strategy: A function that generates permutations + of file parameters + :returns: a list of FileSet objects + """ + combinations = permutation_strategy( + file.file_parameters, self.exe_arg_parameters, self.max_permutations + ) or [ParamSet({}, {})] + return [FileSet(file, combo) for combo in combinations] + + def _cartesian_values(self, ls: list[list[FileSet]]) -> list[tuple[FileSet, ...]]: + """Generate the Cartesian product of a list of lists of FileSets. + + This method takes a list of lists of FileSet objects and returns a list of tuples, + where each tuple contains one FileSet from each sublist. + + :param ls: A list of lists of FileSets + :returns: A list of tuples, each containing one FileSet from each sublist + """ + return list(itertools.product(*ls)) + def _create_applications(self) -> tuple[Application, ...]: """Generate a collection of Application instances based on the Ensembles attributes. @@ -374,23 +357,47 @@ def _create_applications(self) -> tuple[Application, ...]: :return: A tuple of Application instances """ permutation_strategy = strategies.resolve(self.permutation_strategy) - - combinations = permutation_strategy( - self.file_parameters, self.exe_arg_parameters, self.max_permutations + file_set_list: list[list[FileSet]] = [ + self._permutate_file_parameters(config_file, permutation_strategy) + for config_file in self.files.configure_operations + ] + file_set_tuple: list[tuple[FileSet, ...]] = self._cartesian_values( + file_set_list ) - combinations = combinations if combinations else [ParamSet({}, {})] permutations_ = itertools.chain.from_iterable( - itertools.repeat(permutation, self.replicas) for permutation in combinations + itertools.repeat(permutation, self.replicas) + for permutation in file_set_tuple ) - return tuple( - Application( + app_list = [] + for i, item in enumerate(permutations_, start=1): + app = Application( name=f"{self.name}-{i}", exe=self.exe, exe_args=self.exe_args, - file_parameters=permutation.params, ) - for i, permutation in enumerate(permutations_) - ) + self._attach_files(app, item) + app_list.append(app) + return tuple(app_list) + + def _attach_files( + self, app: Application, file_set_tuple: tuple[FileSet, ...] + ) -> None: + """Attach files to an Application. + + :param app: The Application to attach files to + :param file_set_tuple: A tuple containing FileSet objects, each representing a configuration file + """ + for config_file in file_set_tuple: + app.files.add_configuration( + src=config_file.file.src, + dest=config_file.file.dest, + file_parameters=config_file.combination.params, + tag=config_file.file.tag, + ) + for copy_file in self.files.copy_operations: + app.files.add_copy(src=copy_file.src, dest=copy_file.dest) + for sym_file in self.files.symlink_operations: + app.files.add_symlink(src=sym_file.src, dest=sym_file.dest) def build_jobs(self, settings: LaunchSettings) -> tuple[Job, ...]: """Expand an Ensemble into a list of deployable Jobs and apply @@ -424,8 +431,8 @@ def build_jobs(self, settings: LaunchSettings) -> tuple[Job, ...]: :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") + # 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 501279c85f..acf3a8f38d 100644 --- a/smartsim/entity/application.py +++ b/smartsim/entity/application.py @@ -59,9 +59,6 @@ def __init__( name: str, exe: str, exe_args: t.Optional[t.Union[str, t.Sequence[str]]] = None, - file_parameters: ( - t.Mapping[str, str] | None - ) = None, # TODO remove when Ensemble is addressed ) -> None: """Initialize an ``Application`` @@ -86,12 +83,6 @@ def __init__( self._exe_args = self._build_exe_args(exe_args) or [] """The executable arguments""" self.files = FileSysOperationSet([]) - """Attach files""" - self._file_parameters = ( - copy.deepcopy(file_parameters) if file_parameters else {} - ) - """TODO MOCK until Ensemble is implemented""" - """Files to be copied, symlinked, and/or configured prior to execution""" self._incoming_entities: t.List[SmartSimEntity] = [] """Entities for which the prefix will have to be known by other entities""" self._key_prefixing_enabled = False @@ -145,33 +136,6 @@ def add_exe_args(self, args: t.Union[str, t.List[str], None]) -> None: args = self._build_exe_args(args) self._exe_args.extend(args) - @property - def file_parameters(self) -> t.Mapping[str, str]: - """Return file parameters. - - :return: the file parameters - """ - return self._file_parameters - - @file_parameters.setter - 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 def incoming_entities(self) -> t.List[SmartSimEntity]: """Return incoming entities. diff --git a/smartsim/entity/files.py b/smartsim/entity/files.py deleted file mode 100644 index 42586f153e..0000000000 --- a/smartsim/entity/files.py +++ /dev/null @@ -1,144 +0,0 @@ -# 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. -import typing as t -from os import path - -from tabulate import tabulate - - -# TODO remove when Ensemble is addressed -class EntityFiles: - """EntityFiles are the files a user wishes to have available to - applications and nodes within SmartSim. Each entity has a method - `entity.attach_generator_files()` that creates one of these - objects such that at generation time, each file type will be - present within the generated application or node directory. - - Tagged files are the configuration files for a application that - can be searched through and edited by the ApplicationWriter. - - Copy files are files that a user wants to copy into the - application or node directory without searching through and - editing them for tags. - - Lastly, symlink can be used for big datasets or input - files that a user just wants to have present in the directory - without necessary having to copy the entire file. - """ - - def __init__( - self, - tagged: t.Optional[t.List[str]] = None, - copy: t.Optional[t.List[str]] = None, - symlink: t.Optional[t.List[str]] = None, - ) -> None: - """Initialize an EntityFiles instance - - :param tagged: tagged files for application configuration - :param copy: files or directories to copy into application - or node directories - :param symlink: files to symlink into application or node - directories - """ - self.tagged = tagged or [] - self.copy = copy or [] - self.link = symlink or [] - self._check_files() - - def _check_files(self) -> None: - """Ensure the files provided by the user are of the correct - type and actually exist somewhere on the filesystem. - - :raises SSConfigError: If a user provides a directory within - the tagged files. - """ - - # type check all files provided by user - self.tagged = self._type_check_files(self.tagged, "Tagged") - self.copy = self._type_check_files(self.copy, "Copyable") - self.link = self._type_check_files(self.link, "Symlink") - - for i, value in enumerate(self.copy): - self.copy[i] = self._check_path(value) - - for i, value in enumerate(self.link): - self.link[i] = self._check_path(value) - - @staticmethod - def _type_check_files( - file_list: t.Union[t.List[str], None], file_type: str - ) -> t.List[str]: - """Check the type of the files provided by the user. - - :param file_list: either tagged, copy, or symlink files - :param file_type: name of the file type e.g. "tagged" - :raises TypeError: if incorrect type is provided by user - :return: file list provided - """ - if file_list: - if not isinstance(file_list, list): - if isinstance(file_list, str): - file_list = [file_list] - else: - raise TypeError( - f"{file_type} files given were not of type list or str" - ) - else: - if not all(isinstance(f, str) for f in file_list): - raise TypeError(f"Not all {file_type} files were of type str") - return file_list or [] - - @staticmethod - def _check_path(file_path: str) -> str: - """Given a user provided path-like str, find the actual path to - the directory or file and create a full path. - - :param file_path: path to a specific file or directory - :raises FileNotFoundError: if file or directory does not exist - :return: full path to file or directory - """ - full_path = path.abspath(file_path) - if path.isfile(full_path): - return full_path - if path.isdir(full_path): - return full_path - raise FileNotFoundError(f"File or Directory {file_path} not found") - - def __str__(self) -> str: - """Return table summarizing attached files.""" - values = [] - - if self.copy: - values.append(["Copy", "\n".join(self.copy)]) - if self.link: - values.append(["Symlink", "\n".join(self.link)]) - if self.tagged: - values.append(["Configure", "\n".join(self.tagged)]) - - if not values: - return "No file attached to this entity." - - return tabulate(values, headers=["Strategy", "Files"], tablefmt="grid") diff --git a/tests/test_application.py b/tests/test_application.py index 54a02c5b4d..a0cd7b3a6b 100644 --- a/tests/test_application.py +++ b/tests/test_application.py @@ -61,18 +61,6 @@ def test_application_exe_args_property(): assert exe_args is a.exe_args -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"]) @@ -144,28 +132,6 @@ def test_application_type_exe_args(): application.exe_args = [1, 2, 3] -@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( diff --git a/tests/test_ensemble.py b/tests/test_ensemble.py index 1bfbd0b67a..d898880faa 100644 --- a/tests/test_ensemble.py +++ b/tests/test_ensemble.py @@ -24,15 +24,23 @@ # 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 os +import pathlib import typing as t from glob import glob from os import path as osp import pytest -from smartsim.builders.ensemble import Ensemble +from smartsim._core.generation.operations.ensemble_operations import ( + EnsembleConfigureOperation, + EnsembleCopyOperation, + EnsembleSymlinkOperation, +) +from smartsim.builders.ensemble import Ensemble, FileSet +from smartsim.builders.utils import strategies from smartsim.builders.utils.strategies import ParamSet -from smartsim.entity.files import EntityFiles +from smartsim.entity import Application from smartsim.settings.launch_settings import LaunchSettings pytestmark = pytest.mark.group_a @@ -41,11 +49,6 @@ _2x2_EXE_ARG = {"EXE": [["a"], ["b", "c"]], "ARGS": [["d"], ["e", "f"]]} -@pytest.fixture -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]]], @@ -54,99 +57,125 @@ def user_created_function( return [ParamSet({}, {})] +@pytest.fixture +def ensemble(): + return Ensemble( + name="ensemble_name", + exe="python", + exe_args="sleepy.py", + exe_arg_parameters={"-N": 2}, + permutation_strategy="all_perm", + max_permutations=2, + ) + + @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 - assert exe == e.exe +def test_ensemble_init(): + """Validate Ensemble init""" + ensemble = Ensemble(name="ensemble_name", exe="python") + assert isinstance(ensemble, Ensemble) + assert ensemble.name == "ensemble_name" + assert ensemble.exe == os.fspath("python") -def test_exe_args_property(): - e = Ensemble("test", exe="path/to/example_simulation_program", exe_args="sleepy.py") - exe_args = e.exe_args - assert exe_args == e.exe_args +def test_ensemble_init_empty_params() -> None: + """Invalid Ensemble init""" + with pytest.raises(TypeError): + Ensemble() -def test_exe_arg_parameters_property(): - exe_arg_parameters = {"-N": 2} - e = Ensemble( - "test", - exe="path/to/example_simulation_program", - exe_arg_parameters=exe_arg_parameters, - ) - exe_arg_parameters = e.exe_arg_parameters - assert exe_arg_parameters == e.exe_arg_parameters +def test_exe_property(ensemble): + """Validate Ensemble exe property""" + exe = ensemble.exe + assert exe == ensemble.exe -def test_files_property(get_gen_configure_dir): - tagged_files = sorted(glob(get_gen_configure_dir + "/*")) - files = EntityFiles(tagged=tagged_files) - e = Ensemble("test", exe="path/to/example_simulation_program", files=files) - files = e.files - assert files == e.files +@pytest.mark.parametrize( + "exe,error", + ( + pytest.param(123, TypeError, id="exe as integer"), + pytest.param(None, TypeError, id="exe as None"), + ), +) +def test_set_exe_invalid(ensemble, exe, error): + """Validate Ensemble exe setter throws""" + with pytest.raises(error): + ensemble.exe = exe -def test_file_parameters_property(): - file_parameters = {"h": [5, 6, 7, 8]} - e = Ensemble( - "test", - exe="path/to/example_simulation_program", - file_parameters=file_parameters, - ) - file_parameters = e.file_parameters - assert file_parameters == e.file_parameters +@pytest.mark.parametrize( + "exe", + ( + pytest.param(pathlib.Path("this/is/path"), id="exe as pathlib"), + pytest.param("this/is/path", id="exe as str"), + ), +) +def test_set_exe_valid(ensemble, exe): + """Validate Ensemble exe setter sets""" + ensemble.exe = exe + assert ensemble.exe == str(exe) -def test_ensemble_init_empty_params(test_dir: str) -> None: - """Ensemble created without required args""" - with pytest.raises(TypeError): - Ensemble() +def test_exe_args_property(ensemble): + """Validate Ensemble exe_args property""" + exe_args = ensemble.exe_args + assert exe_args == ensemble.exe_args @pytest.mark.parametrize( - "bad_settings", - [pytest.param(None, id="Nullish"), pytest.param("invalid", id="String")], + "exe_args,error", + ( + pytest.param(123, TypeError, id="exe_args as integer"), + pytest.param(None, TypeError, id="exe_args as None"), + pytest.param(["script.py", 123], TypeError, id="exe_args as None"), + ), ) -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_set_exe_args_invalid(ensemble, exe_args, error): + """Validate Ensemble exe_arg setter throws""" + with pytest.raises(error): + ensemble.exe_args = exe_args -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( + "exe_args", + ( + pytest.param(["script.py", "another.py"], id="exe_args as pathlib"), + pytest.param([], id="exe_args as str"), + ), +) +def test_set_exe_args_valid(ensemble, exe_args): + """Validate Ensemble exe_args setter sets""" + ensemble.exe_args = exe_args + assert ensemble.exe_args == exe_args + + +def test_exe_arg_parameters_property(ensemble): + """Validate Ensemble exe_arg_parameters property""" + exe_arg_parameters = ensemble.exe_arg_parameters + assert exe_arg_parameters == ensemble.exe_arg_parameters @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"), - ], + "exe_arg_parameters", + ( + pytest.param( + {"key": [["test"]]}, + id="Value is a sequence of sequence of str", + ), + pytest.param( + {"key": [["test"], ["test"]]}, + id="Value is a sequence of sequence of str", + ), + ), ) -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 +def test_set_exe_arg_parameters_valid(exe_arg_parameters, ensemble): + """Validate Ensemble exe_arg_parameters setter sets""" + ensemble.exe_arg_parameters = exe_arg_parameters + assert ensemble.exe_arg_parameters == exe_arg_parameters @pytest.mark.parametrize( @@ -167,12 +196,8 @@ def test_ensemble_type_exe_args(bad_settings): 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"], - ) +def test_set_exe_arg_parameters_invalid(exe_arg_params, ensemble): + """Validate Ensemble exe_arg_parameters setter throws""" with pytest.raises( TypeError, match="exe_arg_parameters argument was not of type mapping " @@ -181,43 +206,14 @@ def test_ensemble_type_exe_arg_parameters(exe_arg_params): 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 +def test_permutation_strategy_property(ensemble): + """Validate Ensemble permutation_strategy property""" + permutation_strategy = ensemble.permutation_strategy + assert permutation_strategy == ensemble.permutation_strategy -@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"], - ) +def test_permutation_strategy_set_invalid(ensemble): + """Validate Ensemble permutation_strategy setter throws""" with pytest.raises( TypeError, match="permutation_strategy argument was not of " @@ -226,129 +222,293 @@ def test_ensemble_type_permutation_strategy(): 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" +@pytest.mark.parametrize( + "strategy", + ( + pytest.param("all_perm", id="strategy as all_perm"), + pytest.param("step", id="strategy as step"), + pytest.param("random", id="strategy as random"), + pytest.param(user_created_function, id="strategy as user_created_function"), + ), +) +def test_permutation_strategy_set_valid(ensemble, strategy): + """Validate Ensemble permutation_strategy setter sets""" + ensemble.permutation_strategy = strategy + assert ensemble.permutation_strategy == strategy -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_max_permutations_property(ensemble): + """Validate Ensemble max_permutations property""" + max_permutations = ensemble.max_permutations + assert max_permutations == ensemble.max_permutations -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 +@pytest.mark.parametrize( + "max_permutations", + ( + pytest.param(123, id="max_permutations as str"), + pytest.param(-1, id="max_permutations as float"), + ), +) +def test_max_permutations_set_valid(ensemble, max_permutations): + """Validate Ensemble max_permutations setter sets""" + ensemble.max_permutations = max_permutations + assert ensemble.max_permutations == max_permutations -def test_ensemble_type_build_jobs(): - ensemble = Ensemble("ensemble-name", "echo", replicas=2) - with pytest.raises(TypeError): - ensemble.build_jobs("invalid") +@pytest.mark.parametrize( + "max_permutations,error", + ( + pytest.param("str", TypeError, id="max_permutations as str"), + pytest.param(None, TypeError, id="max_permutations as None"), + pytest.param(0.1, TypeError, id="max_permutations as float"), + ), +) +def test_max_permutations_set_invalid(ensemble, max_permutations, error): + """Validate Ensemble exe_arg setter throws""" + with pytest.raises(error): + ensemble.max_permutations = max_permutations -def test_ensemble_user_created_strategy(mock_launcher_settings, test_dir): - jobs = Ensemble( - "test_ensemble", - "echo", - ("hello", "world"), - permutation_strategy=user_created_function, - ).build_jobs(mock_launcher_settings) - assert len(jobs) == 1 +def test_replicas_property(ensemble): + """Validate Ensemble replicas property""" + replicas = ensemble.replicas + assert replicas == ensemble.replicas -def test_ensemble_without_any_members_raises_when_cast_to_jobs( - mock_launcher_settings, test_dir -): - with pytest.raises(ValueError): - Ensemble( - "test_ensemble", - "echo", - ("hello", "world"), - file_parameters=_2x2_PARAMS, - permutation_strategy="random", - max_permutations=30, - replicas=0, - ).build_jobs(mock_launcher_settings) +@pytest.mark.parametrize( + "replicas", + (pytest.param(123, id="replicas as str"),), +) +def test_replicas_set_valid(ensemble, replicas): + """Validate Ensemble replicas setter sets""" + ensemble.replicas = replicas + assert ensemble.replicas == replicas -def test_strategy_error_raised_if_a_strategy_that_dne_is_requested(test_dir): - with pytest.raises(ValueError): - Ensemble( - "test_ensemble", - "echo", - ("hello",), - permutation_strategy="THIS-STRATEGY-DNE", - )._create_applications() +@pytest.mark.parametrize( + "replicas,error", + ( + pytest.param("str", TypeError, id="replicas as str"), + pytest.param(None, TypeError, id="replicas as None"), + pytest.param(0.1, TypeError, id="replicas as float"), + pytest.param(-1, ValueError, id="replicas as negative int"), + ), +) +def test_replicas_set_invalid(ensemble, replicas, error): + """Validate Ensemble replicas setter throws""" + with pytest.raises(error): + ensemble.replicas = replicas @pytest.mark.parametrize( - "file_parameters", + " params, exe_arg_params, max_perms, strategy, expected_combinations", ( - pytest.param({"SPAM": ["eggs"]}, id="Non-Empty Params"), - pytest.param({}, id="Empty Params"), - pytest.param(None, id="Nullish Params"), + pytest.param( + _2x2_PARAMS, + _2x2_EXE_ARG, + 8, + "all_perm", + 8, + id="Limit number of perms - 8 : all_perm", + ), + pytest.param( + _2x2_PARAMS, + _2x2_EXE_ARG, + 1, + "all_perm", + 1, + id="Limit number of perms - 1 : all_perm", + ), + pytest.param( + _2x2_PARAMS, + _2x2_EXE_ARG, + -1, + "all_perm", + 16, + id="All permutations : all_perm", + ), + pytest.param( + _2x2_PARAMS, + _2x2_EXE_ARG, + 30, + "all_perm", + 16, + id="Greater number of perms : all_perm", + ), + pytest.param( + _2x2_PARAMS, {}, -1, "all_perm", 4, id="Empty exe args params : all_perm" + ), + pytest.param( + {}, _2x2_EXE_ARG, -1, "all_perm", 4, id="Empty file params : all_perm" + ), + pytest.param( + {}, + {}, + -1, + "all_perm", + 1, + id="Empty exe args params and file params : all_perm", + ), + pytest.param( + _2x2_PARAMS, + _2x2_EXE_ARG, + 2, + "step", + 2, + id="Limit number of perms - 2 : step", + ), + pytest.param( + _2x2_PARAMS, + _2x2_EXE_ARG, + 1, + "step", + 1, + id="Limit number of perms - 1 : step", + ), + pytest.param( + _2x2_PARAMS, _2x2_EXE_ARG, -1, "step", 2, id="All permutations : step" + ), + pytest.param( + _2x2_PARAMS, + _2x2_EXE_ARG, + 30, + "step", + 2, + id="Greater number of perms : step", + ), + pytest.param(_2x2_PARAMS, {}, -1, "step", 1, id="Empty exe args params : step"), + pytest.param({}, _2x2_EXE_ARG, -1, "step", 1, id="Empty file params : step"), + pytest.param( + {}, {}, -1, "step", 1, id="Empty exe args params and file params : step" + ), + pytest.param( + _2x2_PARAMS, + _2x2_EXE_ARG, + 8, + "random", + 8, + id="Limit number of perms - 8 : random", + ), + pytest.param( + _2x2_PARAMS, + _2x2_EXE_ARG, + 1, + "random", + 1, + id="Limit number of perms - 1 : random", + ), + pytest.param( + _2x2_PARAMS, _2x2_EXE_ARG, -1, "random", 16, id="All permutations : random" + ), + pytest.param( + _2x2_PARAMS, + _2x2_EXE_ARG, + 30, + "random", + 16, + id="Greater number of perms : random", + ), + pytest.param( + _2x2_PARAMS, {}, -1, "random", 4, id="Empty exe args params : random" + ), + pytest.param( + {}, _2x2_EXE_ARG, -1, "random", 4, id="Empty file params : random" + ), + pytest.param( + {}, {}, -1, "random", 1, id="Empty exe args params and file params : random" + ), ), ) -def test_replicated_applications_have_eq_deep_copies_of_parameters( - file_parameters, test_dir +def test_permutate_file_parameters( + params, exe_arg_params, max_perms, strategy, expected_combinations ): - apps = list( - Ensemble( - "test_ensemble", - "echo", - ("hello",), - replicas=4, - file_parameters=file_parameters, - )._create_applications() + """Test Ensemble._permutate_file_parameters returns expected number of combinations for step, random and all_perm""" + ensemble = Ensemble( + "name", + "echo", + exe_arg_parameters=exe_arg_params, + permutation_strategy=strategy, + max_permutations=max_perms, + ) + permutation_strategy = strategies.resolve(strategy) + config_file = EnsembleConfigureOperation( + src=pathlib.Path("/src"), file_parameters=params + ) + file_set_list = ensemble._permutate_file_parameters( + config_file, permutation_strategy + ) + assert len(file_set_list) == expected_combinations + for file_set in file_set_list: + assert isinstance(file_set, FileSet) + assert file_set.file == config_file + + +def test_cartesian_values(): + """Test Ensemble._cartesian_values returns expected number of combinations""" + ensemble = Ensemble( + "name", + "echo", + exe_arg_parameters={"-N": ["1", "2"]}, ) - assert len(apps) >= 2 # Sanitiy check to make sure the test is valid - assert all( - app_1.file_parameters == app_2.file_parameters - for app_1 in apps - for app_2 in apps + permutation_strategy = strategies.resolve("all_perm") + config_file_1 = EnsembleConfigureOperation( + src=pathlib.Path("/src_1"), file_parameters={"SPAM": ["a"]} ) - assert all( - app_1.file_parameters is not app_2.file_parameters - for app_1 in apps - for app_2 in apps - if app_1 is not app_2 + config_file_2 = EnsembleConfigureOperation( + src=pathlib.Path("/src_2"), file_parameters={"EGGS": ["b"]} ) + file_set_list = [] + file_set_list.append( + ensemble._permutate_file_parameters(config_file_1, permutation_strategy) + ) + file_set_list.append( + ensemble._permutate_file_parameters(config_file_2, permutation_strategy) + ) + file_set_tuple = ensemble._cartesian_values(file_set_list) + assert isinstance(file_set_tuple, list) + assert len(file_set_tuple) == 4 + for tup in file_set_tuple: + assert isinstance(tup, tuple) + assert len(tup) == 2 + files = [fs.file for fs in tup] + # Validate that each config file is in the tuple of FileSets + assert config_file_1 in files + assert config_file_2 in files + + +def test_attach_files(): + ensemble = Ensemble("mock_ensemble", "echo") + ensemble.files.add_copy(src=pathlib.Path("/copy")) + ensemble.files.add_symlink(src=pathlib.Path("/symlink")) + app = Application("mock_app", "echo") + file_set_1 = FileSet( + EnsembleConfigureOperation( + src=pathlib.Path("/src_1"), file_parameters={"FOO": "TOE"} + ), + ParamSet({}, {}), + ) + file_set_2 = FileSet( + EnsembleConfigureOperation( + src=pathlib.Path("/src_2"), file_parameters={"FOO": "TOE"} + ), + ParamSet({}, {}), + ) + ensemble._attach_files(app, (file_set_1, file_set_2)) + assert len(app.files.copy_operations) == 1 + assert len(app.files.symlink_operations) == 1 + assert len(app.files.configure_operations) == 2 + sources = [file.src for file in app.files.configure_operations] + assert file_set_1.file.src in sources + assert file_set_2.file.src in sources # fmt: off @pytest.mark.parametrize( - " params, exe_arg_params, max_perms, replicas, expected_num_jobs", + " params, exe_arg_params, max_perms, replicas, expected_num_jobs", (pytest.param(_2x2_PARAMS, _2x2_EXE_ARG, 30, 1, 16 , id="Set max permutation high"), pytest.param(_2x2_PARAMS, _2x2_EXE_ARG, -1, 1, 16 , id="Set max permutation negative"), pytest.param(_2x2_PARAMS, _2x2_EXE_ARG, 0, 1, 1 , id="Set max permutation zero"), - pytest.param(_2x2_PARAMS, None, 4, 1, 4 , id="No exe arg params or Replicas"), - pytest.param( None, _2x2_EXE_ARG, 4, 1, 4 , id="No Parameters or Replicas"), - pytest.param( None, None, 4, 1, 1 , id="No Parameters, Exe_Arg_Param or Replicas"), pytest.param(_2x2_PARAMS, _2x2_EXE_ARG, 1, 1, 1 , id="Set max permutation to lowest"), pytest.param(_2x2_PARAMS, _2x2_EXE_ARG, 6, 2, 12 , id="Set max permutation, set replicas"), pytest.param( {}, _2x2_EXE_ARG, 6, 2, 8 , id="Set params as dict, set max permutations and replicas"), @@ -356,42 +516,26 @@ def test_replicated_applications_have_eq_deep_copies_of_parameters( pytest.param( {}, {}, 6, 2, 2 , id="Set params as dict, set max permutations and replicas") )) # fmt: on -def test_all_perm_strategy( +def test_all_perm_strategy_create_application( # Parameterized params, exe_arg_params, max_perms, replicas, expected_num_jobs, - # Other fixtures - mock_launcher_settings, - test_dir, ): - jobs = Ensemble( + e = Ensemble( "test_ensemble", "echo", ("hello", "world"), - file_parameters=params, exe_arg_parameters=exe_arg_params, permutation_strategy="all_perm", max_permutations=max_perms, replicas=replicas, - ).build_jobs(mock_launcher_settings) - assert len(jobs) == expected_num_jobs - - -def test_all_perm_strategy_contents(mock_launcher_settings): - jobs = Ensemble( - "test_ensemble", - "echo", - ("hello", "world"), - file_parameters=_2x2_PARAMS, - exe_arg_parameters=_2x2_EXE_ARG, - permutation_strategy="all_perm", - max_permutations=16, - replicas=1, - ).build_jobs(mock_launcher_settings) - assert len(jobs) == 16 + ) + e.files.add_configuration(src=pathlib.Path("/src_1"), file_parameters=params) + apps = e._create_applications() + assert len(apps) == expected_num_jobs # fmt: off @@ -400,9 +544,6 @@ def test_all_perm_strategy_contents(mock_launcher_settings): (pytest.param(_2x2_PARAMS, _2x2_EXE_ARG, 30, 1, 2 , id="Set max permutation high"), pytest.param(_2x2_PARAMS, _2x2_EXE_ARG, -1, 1, 2 , id="Set max permutation negtive"), pytest.param(_2x2_PARAMS, _2x2_EXE_ARG, 0, 1, 1 , id="Set max permutation zero"), - pytest.param(_2x2_PARAMS, None, 4, 1, 1 , id="No exe arg params or Replicas"), - pytest.param( None, _2x2_EXE_ARG, 4, 1, 1 , id="No Parameters or Replicas"), - pytest.param( None, None, 4, 1, 1 , id="No Parameters, Exe_Arg_Param or Replicas"), pytest.param(_2x2_PARAMS, _2x2_EXE_ARG, 1, 1, 1 , id="Set max permutation to lowest"), pytest.param(_2x2_PARAMS, _2x2_EXE_ARG, 6, 2, 4 , id="Set max permutation, set replicas"), pytest.param( {}, _2x2_EXE_ARG, 6, 2, 2 , id="Set params as dict, set max permutations and replicas"), @@ -410,28 +551,26 @@ def test_all_perm_strategy_contents(mock_launcher_settings): pytest.param( {}, {}, 6, 2, 2 , id="Set params as dict, set max permutations and replicas") )) # fmt: on -def test_step_strategy( +def test_step_strategy_create_application( # Parameterized params, exe_arg_params, max_perms, replicas, expected_num_jobs, - # Other fixtures - mock_launcher_settings, - test_dir, ): - jobs = Ensemble( + e = Ensemble( "test_ensemble", "echo", ("hello", "world"), - file_parameters=params, exe_arg_parameters=exe_arg_params, permutation_strategy="step", max_permutations=max_perms, replicas=replicas, - ).build_jobs(mock_launcher_settings) - assert len(jobs) == expected_num_jobs + ) + e.files.add_configuration(src=pathlib.Path("/src_1"), file_parameters=params) + apps = e._create_applications() + assert len(apps) == expected_num_jobs # fmt: off @@ -440,9 +579,6 @@ def test_step_strategy( (pytest.param(_2x2_PARAMS, _2x2_EXE_ARG, 30, 1, 16 , id="Set max permutation high"), pytest.param(_2x2_PARAMS, _2x2_EXE_ARG, -1, 1, 16 , id="Set max permutation negative"), pytest.param(_2x2_PARAMS, _2x2_EXE_ARG, 0, 1, 1 , id="Set max permutation zero"), - pytest.param(_2x2_PARAMS, None, 4, 1, 4 , id="No exe arg params or Replicas"), - pytest.param( None, _2x2_EXE_ARG, 4, 1, 4 , id="No Parameters or Replicas"), - pytest.param( None, None, 4, 1, 1 , id="No Parameters, Exe_Arg_Param or Replicas"), pytest.param(_2x2_PARAMS, _2x2_EXE_ARG, 1, 1, 1 , id="Set max permutation to lowest"), pytest.param(_2x2_PARAMS, _2x2_EXE_ARG, 6, 2, 12 , id="Set max permutation, set replicas"), pytest.param( {}, _2x2_EXE_ARG, 6, 2, 8 , id="Set params as dict, set max permutations and replicas"), @@ -450,24 +586,72 @@ def test_step_strategy( pytest.param( {}, {}, 6, 2, 2 , id="Set params as dict, set max permutations and replicas") )) # fmt: on -def test_random_strategy( +def test_random_strategy_create_application( # Parameterized params, exe_arg_params, max_perms, replicas, expected_num_jobs, - # Other fixtures - mock_launcher_settings, ): - jobs = Ensemble( + e = Ensemble( "test_ensemble", "echo", ("hello", "world"), - file_parameters=params, exe_arg_parameters=exe_arg_params, permutation_strategy="random", max_permutations=max_perms, replicas=replicas, + ) + e.files.add_configuration(src=pathlib.Path("/src_1"), file_parameters=params) + apps = e._create_applications() + assert len(apps) == expected_num_jobs + + +def test_ensemble_type_build_jobs(): + ensemble = Ensemble("ensemble-name", "echo", replicas=2) + with pytest.raises(TypeError): + ensemble.build_jobs("invalid") + + +@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_user_created_strategy(mock_launcher_settings, test_dir): + jobs = Ensemble( + "test_ensemble", + "echo", + ("hello", "world"), + permutation_strategy=user_created_function, ).build_jobs(mock_launcher_settings) - assert len(jobs) == expected_num_jobs + assert len(jobs) == 1 + + +def test_ensemble_without_any_members_raises_when_cast_to_jobs(mock_launcher_settings): + with pytest.raises(ValueError): + Ensemble( + "test_ensemble", + "echo", + ("hello", "world"), + permutation_strategy="random", + max_permutations=30, + replicas=0, + ).build_jobs(mock_launcher_settings) + + +def test_strategy_error_raised_if_a_strategy_that_dne_is_requested(test_dir): + with pytest.raises(ValueError): + Ensemble( + "test_ensemble", + "echo", + ("hello",), + permutation_strategy="THIS-STRATEGY-DNE", + )._create_applications() diff --git a/tests/test_ensemble_operations.py b/tests/test_ensemble_operations.py new file mode 100644 index 0000000000..aa15f5b0dd --- /dev/null +++ b/tests/test_ensemble_operations.py @@ -0,0 +1,234 @@ +import base64 +import os +import pathlib +import pickle +from glob import glob +from os import path as osp + +import pytest + +from smartsim._core.commands import Command +from smartsim._core.generation.operations.ensemble_operations import ( + EnsembleConfigureOperation, + EnsembleCopyOperation, + EnsembleFileSysOperationSet, + EnsembleSymlinkOperation, +) +from smartsim._core.generation.operations.operations import default_tag +from smartsim.builders import Ensemble +from smartsim.builders.utils import strategies + +pytestmark = pytest.mark.group_a + +# TODO missing test for _filter + + +@pytest.fixture +def ensemble_copy_operation(mock_src: pathlib.Path, mock_dest: pathlib.Path): + """Fixture to create a EnsembleCopyOperation object.""" + return EnsembleCopyOperation(src=mock_src, dest=mock_dest) + + +@pytest.fixture +def ensemble_symlink_operation(mock_src: pathlib.Path, mock_dest: pathlib.Path): + """Fixture to create a EnsembleSymlinkOperation object.""" + return EnsembleSymlinkOperation(src=mock_src, dest=mock_dest) + + +@pytest.fixture +def ensemble_configure_operation(mock_src: pathlib.Path, mock_dest: pathlib.Path): + """Fixture to create a EnsembleConfigureOperation object.""" + return EnsembleConfigureOperation( + src=mock_src, dest=mock_dest, file_parameters={"FOO": ["BAR", "TOE"]} + ) + + +@pytest.fixture +def ensemble_file_system_operation_set( + ensemble_copy_operation: EnsembleCopyOperation, + ensemble_symlink_operation: EnsembleSymlinkOperation, + ensemble_configure_operation: EnsembleConfigureOperation, +): + """Fixture to create a FileSysOperationSet object.""" + return EnsembleFileSysOperationSet( + [ + ensemble_copy_operation, + ensemble_symlink_operation, + ensemble_configure_operation, + ] + ) + + +def test_init_ensemble_copy_operation(mock_src: pathlib.Path, mock_dest: pathlib.Path): + """Validate EnsembleCopyOperation init""" + ensemble_copy_operation = EnsembleCopyOperation(mock_src, mock_dest) + assert isinstance(ensemble_copy_operation, EnsembleCopyOperation) + assert ensemble_copy_operation.src == mock_src + assert ensemble_copy_operation.dest == mock_dest + + +def test_init_ensemble_symlink_operation(mock_src: str, mock_dest: str): + """Validate EnsembleSymlinkOperation init""" + ensemble_symlink_operation = EnsembleSymlinkOperation(mock_src, mock_dest) + assert isinstance(ensemble_symlink_operation, EnsembleSymlinkOperation) + assert ensemble_symlink_operation.src == mock_src + assert ensemble_symlink_operation.dest == mock_dest + + +def test_init_ensemble_configure_operation(mock_src: str): + """Validate EnsembleConfigureOperation init""" + ensemble_configure_operation = EnsembleConfigureOperation( + mock_src, file_parameters={"FOO": ["BAR", "TOE"]} + ) + assert isinstance(ensemble_configure_operation, EnsembleConfigureOperation) + assert ensemble_configure_operation.src == mock_src + assert ensemble_configure_operation.dest == None + assert ensemble_configure_operation.tag == default_tag + assert ensemble_configure_operation.file_parameters == {"FOO": ["BAR", "TOE"]} + + +def test_init_ensemble_file_sys_operation_set( + copy_operation: EnsembleCopyOperation, + symlink_operation: EnsembleSymlinkOperation, + configure_operation: EnsembleConfigureOperation, +): + """Test initialize EnsembleFileSysOperationSet""" + ensemble_fs_op_set = EnsembleFileSysOperationSet( + [copy_operation, symlink_operation, configure_operation] + ) + assert isinstance(ensemble_fs_op_set.operations, list) + assert len(ensemble_fs_op_set.operations) == 3 + + +def test_add_ensemble_copy_operation( + ensemble_file_system_operation_set: EnsembleFileSysOperationSet, +): + """Test EnsembleFileSysOperationSet.add_copy""" + orig_num_ops = len(ensemble_file_system_operation_set.copy_operations) + ensemble_file_system_operation_set.add_copy(src=pathlib.Path("/src")) + assert len(ensemble_file_system_operation_set.copy_operations) == orig_num_ops + 1 + + +def test_add_ensemble_symlink_operation( + ensemble_file_system_operation_set: EnsembleFileSysOperationSet, +): + """Test EnsembleFileSysOperationSet.add_symlink""" + orig_num_ops = len(ensemble_file_system_operation_set.symlink_operations) + ensemble_file_system_operation_set.add_symlink(src=pathlib.Path("/src")) + assert ( + len(ensemble_file_system_operation_set.symlink_operations) == orig_num_ops + 1 + ) + + +def test_add_ensemble_configure_operation( + ensemble_file_system_operation_set: EnsembleFileSysOperationSet, +): + """Test FileSystemOperationSet.add_configuration""" + orig_num_ops = len(ensemble_file_system_operation_set.configure_operations) + ensemble_file_system_operation_set.add_configuration( + src=pathlib.Path("/src"), file_parameters={"FOO": "BAR"} + ) + assert ( + len(ensemble_file_system_operation_set.configure_operations) == orig_num_ops + 1 + ) + + +@pytest.mark.parametrize( + "dest,error", + ( + pytest.param(123, TypeError, id="dest as integer"), + pytest.param("", TypeError, id="dest as empty str"), + pytest.param( + pathlib.Path("/absolute/path"), ValueError, id="dest as absolute str" + ), + ), +) +def test_ensemble_copy_files_invalid_dest(dest, error, source): + """Test invalid copy destination""" + with pytest.raises(error): + _ = [EnsembleCopyOperation(src=file, dest=dest) for file in source] + + +@pytest.mark.parametrize( + "src,error", + ( + pytest.param(123, TypeError, id="src as integer"), + pytest.param("", TypeError, id="src as empty str"), + pytest.param( + pathlib.Path("relative/path"), ValueError, id="src as relative str" + ), + ), +) +def test_ensemble_copy_files_invalid_src(src, error): + """Test invalid copy source""" + with pytest.raises(error): + _ = EnsembleCopyOperation(src=src) + + +@pytest.mark.parametrize( + "dest,error", + ( + pytest.param(123, TypeError, id="dest as integer"), + pytest.param("", TypeError, id="dest as empty str"), + pytest.param( + pathlib.Path("/absolute/path"), ValueError, id="dest as absolute str" + ), + ), +) +def test_ensemble_symlink_files_invalid_dest(dest, error, source): + """Test invalid symlink destination""" + with pytest.raises(error): + _ = [EnsembleSymlinkOperation(src=file, dest=dest) for file in source] + + +@pytest.mark.parametrize( + "src,error", + ( + pytest.param(123, TypeError, id="src as integer"), + pytest.param("", TypeError, id="src as empty str"), + pytest.param( + pathlib.Path("relative/path"), ValueError, id="src as relative str" + ), + ), +) +def test_ensemble_symlink_files_invalid_src(src, error): + """Test invalid symlink source""" + with pytest.raises(error): + _ = EnsembleSymlinkOperation(src=src) + + +@pytest.mark.parametrize( + "dest,error", + ( + pytest.param(123, TypeError, id="dest as integer"), + pytest.param("", TypeError, id="dest as empty str"), + pytest.param( + pathlib.Path("/absolute/path"), ValueError, id="dest as absolute str" + ), + ), +) +def test_ensemble_configure_files_invalid_dest(dest, error, source): + """Test invalid configure destination""" + with pytest.raises(error): + _ = [ + EnsembleConfigureOperation( + src=file, dest=dest, file_parameters={"FOO": "BAR"} + ) + for file in source + ] + + +@pytest.mark.parametrize( + "src,error", + ( + pytest.param(123, TypeError, id="src as integer"), + pytest.param("", TypeError, id="src as empty str"), + pytest.param( + pathlib.Path("relative/path"), ValueError, id="src as relative str" + ), + ), +) +def test_ensemble_configure_files_invalid_src(src, error): + """Test invalid configure source""" + with pytest.raises(error): + _ = EnsembleConfigureOperation(src=src, file_parameters={"FOO": ["BAR", "TOE"]})