diff --git a/setup.py b/setup.py index bc7cf60d6..8a524ce91 100644 --- a/setup.py +++ b/setup.py @@ -107,8 +107,11 @@ # check for compatible python versions if not build_env.is_compatible_python(versions.PYTHON_MIN): - print("You are using Python {}. Python >={} is required.".format(build_env.python_version, - ".".join((versions.PYTHON_MIN)))) + print( + "You are using Python {}. Python >={} is required.".format( + build_env.python_version, ".".join((versions.PYTHON_MIN)) + ) + ) sys.exit(-1) if build_env.is_windows(): @@ -120,9 +123,11 @@ # __version__ in smartsim/__init__.py smartsim_version = versions.write_version(setup_path) + class BuildError(Exception): pass + # Hacky workaround for solving CI build "purelib" issue # see https://github.com/google/or-tools/issues/616 class InstallPlatlib(install): @@ -131,15 +136,14 @@ def finalize_options(self): if self.distribution.has_ext_modules(): self.install_lib = self.install_platlib -class SmartSimBuild(build_py): +class SmartSimBuild(build_py): def run(self): - database_builder = builder.DatabaseBuilder(build_env(), - build_env.MALLOC, - build_env.JOBS) + database_builder = builder.DatabaseBuilder( + build_env(), build_env.MALLOC, build_env.JOBS + ) if not database_builder.is_built: - database_builder.build_from_git(versions.REDIS_URL, - versions.REDIS) + database_builder.build_from_git(versions.REDIS_URL, versions.REDIS) database_builder.cleanup() @@ -151,9 +155,10 @@ def run(self): class BinaryDistribution(Distribution): """Distribution which always forces a binary package with platform name - We use this because we want to pre-package Redis for certain - platforms to use. + We use this because we want to pre-package Redis for certain + platforms to use. """ + def has_ext_modules(_placeholder): return True @@ -167,6 +172,7 @@ def has_ext_modules(_placeholder): "tqdm>=4.50.2", "filelock>=3.4.2", "protobuf~=3.20", + "jinja2>=3.1.2", "watchdog>=3.0.0,<4.0.0", ] @@ -193,7 +199,7 @@ def has_ext_modules(_placeholder): "typing_extensions>=4.1.0", ], # see smartsim/_core/_install/buildenv.py for more details - **versions.ml_extras_required() + **versions.ml_extras_required(), } @@ -212,5 +218,5 @@ def has_ext_modules(_placeholder): "console_scripts": [ "smart = smartsim._core._cli.__main__:main", ] - } + }, ) diff --git a/smartsim/_core/__init__.py b/smartsim/_core/__init__.py index bbc108f48..490078770 100644 --- a/smartsim/_core/__init__.py +++ b/smartsim/_core/__init__.py @@ -24,7 +24,7 @@ # 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 .control import Controller, Manifest +from .control import Controller, Manifest, previewrenderer from .generation import Generator -__all__ = ["Controller", "Manifest", "Generator"] +__all__ = ["Controller", "Manifest", "Generator", "previewrenderer"] diff --git a/smartsim/_core/control/manifest.py b/smartsim/_core/control/manifest.py index 25037540c..8075d141f 100644 --- a/smartsim/_core/control/manifest.py +++ b/smartsim/_core/control/manifest.py @@ -109,6 +109,12 @@ def all_entity_lists(self) -> t.List[EntitySequence[SmartSimEntity]]: return _all_entity_lists + @property + def all_entities( + self, + ) -> t.Tuple[t.Union[SmartSimEntity, EntitySequence[SmartSimEntity]], ...]: + return tuple(self._deployables) + @staticmethod def _check_names(deployables: t.List[t.Any]) -> None: used = [] diff --git a/smartsim/_core/control/previewrenderer.py b/smartsim/_core/control/previewrenderer.py new file mode 100644 index 000000000..7b58bc7c8 --- /dev/null +++ b/smartsim/_core/control/previewrenderer.py @@ -0,0 +1,120 @@ +# BSD 2-Clause License +# +# Copyright (c) 2021-2023, 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 enum import Enum + +import jinja2 + +from ..._core.config import CONFIG +from ..._core.control import Manifest +from ...error.errors import PreviewFormatError +from ...log import get_logger + +logger = get_logger(__name__) + +if t.TYPE_CHECKING: + from smartsim import Experiment + +_OutputFormatString = t.Optional[t.Literal["plain_text"]] + + +class Verbosity(str, Enum): + INFO = "info" + DEBUG = "debug" + DEVELOPER = "developer" + + +def render( + exp: "Experiment", + manifest: t.Optional[Manifest] = None, + verbosity_level: Verbosity = Verbosity.INFO, + output_format: _OutputFormatString = "plain_text", +) -> str: + """ + Render the template from the supplied entities. + :param experiment: the experiment to be previewed. + :type experiment: Experiment + :param manifest: the manifest to be previewed. + :type manifest: Manifest + :param verbosity_level: the verbosity level + :type verbosity_level: Verbosity + :param output_format: the output format. + :type output_format: _OutputFormatString + """ + + verbosity_level = _check_verbosity_level(verbosity_level) + + loader = jinja2.PackageLoader("templates") + env = jinja2.Environment(loader=loader, autoescape=True) + + version = f"_{output_format}" + tpl_path = f"preview/base{version}.template" + + _check_output_format(output_format) + + tpl = env.get_template(tpl_path) + + rendered_preview = tpl.render( + exp_entity=exp, + manifest=manifest, + config=CONFIG, + verbosity_level=verbosity_level, + ) + + return rendered_preview + + +def preview_to_file(content: str, filename: str) -> None: + """ + Output preview to a file if output format and filename + are specified. + """ + + with open(filename, "w", encoding="utf-8") as prev_file: + prev_file.write(content) + + +def _check_output_format(output_format: _OutputFormatString) -> None: + """ + Check that the output format given is valid. + """ + if not output_format == "plain_text": + raise PreviewFormatError( + "The only valid output format currently available is plain_text" + ) + + +def _check_verbosity_level( + verbosity_level: Verbosity, +) -> Verbosity: + """ + Check that the given verbosity level is valid. + """ + if verbosity_level not in (Verbosity.INFO, Verbosity.DEBUG, Verbosity.DEVELOPER): + logger.warning(f"'{verbosity_level}' is an unsupported verbosity level.\ + Setting verbosity to: {Verbosity.INFO}") + return Verbosity.INFO + return verbosity_level diff --git a/smartsim/error/errors.py b/smartsim/error/errors.py index 9a6954907..1580b35dd 100644 --- a/smartsim/error/errors.py +++ b/smartsim/error/errors.py @@ -149,3 +149,11 @@ class UnproxyableStepError(TelemetryError): class SmartSimCLIActionCancelled(SmartSimError): """Raised when a `smart` CLI command is terminated""" + + +class PreviewException(Exception): + """Raised when a part of preview isn't support by SmartSim yet""" + + +class PreviewFormatError(PreviewException): + """Raised when the output format of the preview method call is not supported""" diff --git a/smartsim/experiment.py b/smartsim/experiment.py index 9fcc7b13e..799730dd9 100644 --- a/smartsim/experiment.py +++ b/smartsim/experiment.py @@ -33,7 +33,7 @@ from smartsim.error.errors import SSUnsupportedError -from ._core import Controller, Generator, Manifest +from ._core import Controller, Generator, Manifest, previewrenderer from ._core.utils import init_default from .database import Orchestrator from .entity import Ensemble, Model, SmartSimEntity @@ -822,6 +822,50 @@ def reconnect_orchestrator(self, checkpoint: str) -> Orchestrator: logger.error(e) raise + def preview( + self, + *args: t.Any, + output_format: previewrenderer._OutputFormatString = "plain_text", + verbosity_level: previewrenderer.Verbosity = previewrenderer.Verbosity.INFO, + output_filename: t.Optional[str] = None, + ) -> None: + """Preview entity information prior to launch. This method + aggregates multiple pieces of information to give users insight + into what and how entities will be launched. Any instance of + ``Model``, ``Ensemble``, or ``Orchestrator`` created by the + Experiment can be passed as an argument to the preview method. + :param output_filename: Specify name of file and extension to write + preview data to. If no output filename is set, the preview will be + output to stdout. Defaults to None. + :type output_filename: str + :param output_format: Set output format. The possible accepted + output formats are `json`, `xml`, `html`, `plain_text`, `color_text`. + Defaults to 'plain_text'. + :type output_type: str + :param verbosity_level: Specify the verbosity level: + info: Display User defined fields and entities + debug: Display user defined field and entities and auto generated + fields. + developer: Display user defined field and entities, auto generated + fields, and run commands. + Defaults to info. + :type verbosity_level: str + """ + + preview_manifest = Manifest(*args) + + rendered_preview = previewrenderer.render( + self, preview_manifest, verbosity_level, output_format + ) + if output_filename: + previewrenderer.preview_to_file(rendered_preview, output_filename) + else: + logger.info(rendered_preview) + + @property + def launcher(self) -> str: + return self._launcher + @_contextualize def summary(self, style: str = "github") -> str: """Return a summary of the ``Experiment`` diff --git a/templates/templates/preview/base_plain_text.template b/templates/templates/preview/base_plain_text.template new file mode 100644 index 000000000..91462780f --- /dev/null +++ b/templates/templates/preview/base_plain_text.template @@ -0,0 +1,6 @@ +{% include "preview/experiment_plain_text.template" %} +{%- if manifest.all_entities %} + +=== Entity Preview === + +{%- endif -%} diff --git a/templates/templates/preview/experiment_plain_text.template b/templates/templates/preview/experiment_plain_text.template new file mode 100644 index 000000000..b7718e7ba --- /dev/null +++ b/templates/templates/preview/experiment_plain_text.template @@ -0,0 +1,4 @@ +=== Experiment Overview === + Experiment: {{ exp_entity.name }} + Experiment Path: {{ exp_entity.exp_path }} + Launcher: {{ exp_entity.launcher }} diff --git a/tests/test_preview.py b/tests/test_preview.py new file mode 100644 index 000000000..33502fef7 --- /dev/null +++ b/tests/test_preview.py @@ -0,0 +1,107 @@ +# BSD 2-Clause License +# +# Copyright (c) 2021-2023, 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 pathlib + +import pytest + +from smartsim import Experiment +from smartsim._core import previewrenderer +from smartsim.error.errors import PreviewFormatError + + +def test_experiment_preview(test_dir, wlmutils): + """Test correct preview output items for Experiment preview""" + # Prepare entities + test_launcher = wlmutils.get_test_launcher() + exp_name = "test_prefix" + exp = Experiment(exp_name, exp_path=test_dir, launcher=test_launcher) + + # Execute method for template rendering + output = previewrenderer.render(exp) + + # Evaluate output + summary_lines = output.split("\n") + summary_lines = [item.replace("\t", "").strip() for item in summary_lines[-3:]] + assert 3 == len(summary_lines) + summary_dict = dict(row.split(": ") for row in summary_lines) + assert set(["Experiment", "Experiment Path", "Launcher"]).issubset(summary_dict) + + +def test_experiment_preview_properties(test_dir, wlmutils): + """Test correct preview output properties for Experiment preview""" + # Prepare entities + test_launcher = wlmutils.get_test_launcher() + exp_name = "test_experiment_preview_properties" + exp = Experiment(exp_name, exp_path=test_dir, launcher=test_launcher) + + # Execute method for template rendering + output = previewrenderer.render(exp) + + # Evaluate output + summary_lines = output.split("\n") + summary_lines = [item.replace("\t", "").strip() for item in summary_lines[-3:]] + assert 3 == len(summary_lines) + summary_dict = dict(row.split(": ") for row in summary_lines) + assert exp.name == summary_dict["Experiment"] + assert exp.exp_path == summary_dict["Experiment Path"] + assert exp.launcher == summary_dict["Launcher"] + + +def test_preview_to_file(test_dir, wlmutils, fileutils): + """ + Test that if an output_filename is given, a file + is rendered for Experiment preview" + """ + # Prepare entities + test_launcher = wlmutils.get_test_launcher() + exp_name = "test_preview_output_filename" + exp = Experiment(exp_name, exp_path=test_dir, launcher=test_launcher) + filename = "test_preview_output_filename.txt" + path = pathlib.Path(test_dir) / filename + # Execute preview method + exp.preview(output_format="plain_text", output_filename=str(path)) + + # Evaluate output + assert path.exists() + assert path.is_file() + + +def test_output_format_error(): + """ + Test error when invalid ouput format is given. + """ + # Prepare entities + exp_name = "test_output_format" + exp = Experiment(exp_name) + + # Execute preview method + with pytest.raises(PreviewFormatError) as ex: + exp.preview(output_format="hello") + assert ( + "The only valid output format currently available is plain_text" + in ex.value.args[0] + )