diff --git a/lib/ramble/ramble/application.py b/lib/ramble/ramble/application.py index b1db3cd34..450843212 100644 --- a/lib/ramble/ramble/application.py +++ b/lib/ramble/ramble/application.py @@ -70,6 +70,8 @@ _NULL_CONTEXT = "null" +_DEFAULT_CONTENT_PERM = stat.S_IRWXU | stat.S_IRWXG | stat.S_IROTH | stat.S_IXOTH + def _get_context_display_name(context): return ( @@ -1173,6 +1175,7 @@ def add_expand_vars(self, workspace): self._set_input_path() self._derive_variables_for_template_path(workspace) + self._define_object_template_vars() self._vars_are_expanded = True def _inputs_and_fetchers(self, workload=None): @@ -1376,7 +1379,9 @@ def _make_experiments(self, workspace, app_inst=None): f.write( self.expander.expand_var(template_conf["contents"], extra_vars=exec_vars) ) - os.chmod(expand_path, stat.S_IRWXU | stat.S_IRWXG | stat.S_IROTH | stat.S_IXOTH) + os.chmod(expand_path, _DEFAULT_CONTENT_PERM) + + self._render_object_templates(exec_vars) experiment_script = workspace.experiments_script experiment_script.write(self.expander.expand_var("{batch_submit}\n")) @@ -2267,6 +2272,46 @@ def evaluate_success(self): return True + def _object_templates(self): + """Return templates defined from different objects associated with the app_inst""" + + def _get_template_config(obj, tpl_config): + src_path = os.path.join(os.path.dirname(obj._file_path), tpl_config["src_name"]) + if not os.path.isfile(src_path): + raise ApplicationError(f"Object {obj.name} is missing template file at {src_path}") + return {**tpl_config, "src_path": src_path} + + for tpl_config in self.templates.values(): + yield _get_template_config(self, tpl_config) + for mod in self._modifier_instances: + for tpl_config in mod.templates.values(): + yield _get_template_config(mod, tpl_config) + if self.package_manager is not None: + for tpl_config in self.package_manager.templates.values(): + yield _get_template_config(self.package_manager, tpl_config) + + def _render_object_templates(self, extra_vars): + run_dir = self.expander.experiment_run_dir + for tpl_config in self._object_templates(): + src_path = tpl_config["src_path"] + with open(src_path) as f_in: + content = f_in.read() + rendered = self.expander.expand_var(content, extra_vars=extra_vars) + out_path = os.path.join(run_dir, tpl_config["dest_name"]) + perm = tpl_config.get("content_perm", _DEFAULT_CONTENT_PERM) + with open(out_path, "w+") as f_out: + f_out.write(rendered) + f_out.write("\n") + os.chmod(out_path, perm) + + def _define_object_template_vars(self): + run_dir = self.expander.experiment_run_dir + for tpl_config in self._object_templates(): + var_name = tpl_config["var_name"] + if var_name is not None: + path = os.path.join(run_dir, tpl_config["dest_name"]) + self.variables[var_name] = path + class ApplicationError(RambleError): """ diff --git a/lib/ramble/ramble/cmd/common/info.py b/lib/ramble/ramble/cmd/common/info.py index b40af22df..e494a4d93 100644 --- a/lib/ramble/ramble/cmd/common/info.py +++ b/lib/ramble/ramble/cmd/common/info.py @@ -36,6 +36,7 @@ "archive_patterns": None, "success_criteria": None, "target_shells": "shell_support_pattern", + "templates": None, # Application specific: "workloads": None, "workload_groups": None, diff --git a/lib/ramble/ramble/language/shared_language.py b/lib/ramble/ramble/language/shared_language.py index 601c3a60b..10da695f7 100644 --- a/lib/ramble/ramble/language/shared_language.py +++ b/lib/ramble/ramble/language/shared_language.py @@ -476,3 +476,38 @@ def _execute_target_shells(obj): obj.shell_support_pattern = shell_support_pattern return _execute_target_shells + + +@shared_directive("templates") +def register_template( + name: str, src_name: str, dest_name: str, define_var: bool = True, output_perm=None +): + """Directive to define an object-specific template to be rendered into experiment run_dir. + + For instance, `register_template(name="foo", src_name="foo.tpl", dest_name="foo.sh")` + expects a "foo.tpl" template defined alongside the object source, and uses that to + render a file under "{experiment_run_dir}/foo.sh". The rendered path can also be + referenced with the `foo` variable name. + + Args: + name: The name of the template. It is also used as the variable name + that an experiment can use to reference the rendered path, if + `define_var` is true. + src_name: The leaf name of the template. This is used to locate the + the template under the containing directory of the object. + dest_name: The leaf name of the rendered output under the experiment + run directory. + define_var: Controls if a variable named `name` should be defined. + output_perm: The chmod mask for the rendered output file. + """ + + def _define_template(obj): + var_name = name if define_var else None + obj.templates[name] = { + "src_name": src_name, + "dest_name": dest_name, + "var_name": var_name, + "output_perm": output_perm, + } + + return _define_template diff --git a/lib/ramble/ramble/test/end_to_end/test_template.py b/lib/ramble/ramble/test/end_to_end/test_template.py new file mode 100644 index 000000000..ac73b3193 --- /dev/null +++ b/lib/ramble/ramble/test/end_to_end/test_template.py @@ -0,0 +1,57 @@ +# Copyright 2022-2025 The Ramble Authors +# +# Licensed under the Apache License, Version 2.0 or the MIT license +# , at your +# option. This file may not be copied, modified, or distributed +# except according to those terms. + +import os +import pytest + +import ramble.workspace +from ramble.main import RambleCommand + +pytestmark = pytest.mark.usefixtures( + "mutable_config", "mutable_mock_workspace_path", "mutable_mock_apps_repo" +) + +workspace = RambleCommand("workspace") + + +def test_template(): + test_config = """ +ramble: + variables: + mpi_command: mpirun -n {n_ranks} + batch_submit: 'batch_submit {execute_experiment}' + processes_per_node: 1 + applications: + template: + workloads: + test_template: + experiments: + test: + variables: + n_nodes: 1 + hello_name: santa +""" + workspace_name = "test_template" + ws = ramble.workspace.create(workspace_name) + ws.write() + config_path = os.path.join(ws.config_dir, ramble.workspace.config_file_name) + with open(config_path, "w+") as f: + f.write(test_config) + ws._re_read() + + workspace("setup", "--dry-run", global_args=["-w", workspace_name]) + run_dir = os.path.join(ws.experiment_dir, "template/test_template/test/") + script_path = os.path.join(run_dir, "bar.sh") + assert os.path.isfile(script_path) + with open(script_path) as f: + content = f.read() + assert "echo hello santa" in content + execute_path = os.path.join(run_dir, "execute_experiment") + with open(execute_path) as f: + content = f.read() + assert script_path in content diff --git a/var/ramble/repos/builtin.mock/applications/template/application.py b/var/ramble/repos/builtin.mock/applications/template/application.py new file mode 100644 index 000000000..cf231ad55 --- /dev/null +++ b/var/ramble/repos/builtin.mock/applications/template/application.py @@ -0,0 +1,39 @@ +# Copyright 2022-2025 The Ramble Authors +# +# Licensed under the Apache License, Version 2.0 or the MIT license +# , at your +# option. This file may not be copied, modified, or distributed +# except according to those terms. + +from ramble.appkit import * + + +class Template(ExecutableApplication): + """An app for testing object templates.""" + + name = "template" + + executable("foo", template=["bash {bar}"]) + + workload("test_template", executable="foo") + + workload_variable( + "hello_name", + default="world", + description="hello name", + workload="test_template", + ) + + register_phase( + "ingest_dynamic_variables", + pipeline="setup", + run_before=["make_experiments"], + ) + + def _ingest_dynamic_variables(self, workspace, app_inst): + expander = self.expander + val = expander.expand_var('"hello {hello_name}"') + self.define_variable("dynamic_hello_world", val) + + register_template("bar", src_name="bar.tpl", dest_name="bar.sh") diff --git a/var/ramble/repos/builtin.mock/applications/template/bar.tpl b/var/ramble/repos/builtin.mock/applications/template/bar.tpl new file mode 100644 index 000000000..fc8265c25 --- /dev/null +++ b/var/ramble/repos/builtin.mock/applications/template/bar.tpl @@ -0,0 +1,2 @@ +#!/bin/bash +echo {dynamic_hello_world}