diff --git a/setup.py b/setup.py index df140ae0..7cba218b 100644 --- a/setup.py +++ b/setup.py @@ -17,12 +17,12 @@ install_requirements = [ "click", "docker", - "drmaa", "loguru", "pandas", "pyyaml", "pyarrow", "snakemake>=8.0.0", + "snakemake-executor-plugin-slurm", ] setup_requires = ["setuptools_scm"] diff --git a/src/linker/pipeline.py b/src/linker/pipeline.py index d35650d5..8a815913 100644 --- a/src/linker/pipeline.py +++ b/src/linker/pipeline.py @@ -121,6 +121,11 @@ def write_implementation_rules( validation_file = str( results_dir / "input_validations" / implementation.validation_filename ) + resources = ( + self.config.slurm_resources + if self.config.computing_environment == "slurm" + else None + ) validation_rule = InputValidationRule( name=implementation.name, input=input_files, @@ -128,10 +133,12 @@ def write_implementation_rules( validator=implementation.step.input_validator, ) implementation_rule = ImplementedRule( - name=implementation.name, + step_name=implementation.step_name, + implementation_name=implementation.name, execution_input=input_files, validation=validation_file, output=output_files, + resources=resources, envvars=implementation.environment_variables, diagnostics_dir=str(diagnostics_dir), image_path=implementation.singularity_image_path, diff --git a/src/linker/rule.py b/src/linker/rule.py index 009e8931..c7f92f8d 100644 --- a/src/linker/rule.py +++ b/src/linker/rule.py @@ -1,6 +1,6 @@ from abc import ABC, abstractmethod from dataclasses import dataclass -from typing import Callable, List +from typing import Callable, List, Optional class Rule(ABC): @@ -39,7 +39,8 @@ def _build_rule(self) -> str: rule all: input: final_output={self.target_files}, - validation='{self.validation}'""" + validation='{self.validation}' + message: 'Grabbing final output' """ @dataclass @@ -48,37 +49,55 @@ class ImplementedRule(Rule): A rule that defines the execution of an implementation Parameters: - name: Name + step_name: Name of step + implementation_name: Name of implementation execution_input: List of file paths required by implementation validation: name of file created by InputValidationRule to check for compatible input output: List of file paths created by implementation + resources: Computational resources used by executor (e.g. SLURM) envvars: Dictionary of environment variables to set diagnostics_dir: Directory for diagnostic files image_path: Path to Singularity image script_cmd: Command to execute """ - name: str + step_name: str + implementation_name: str execution_input: List[str] validation: str output: List[str] + resources: Optional[dict] envvars: dict diagnostics_dir: str image_path: str script_cmd: str def _build_rule(self) -> str: - return ( - f""" + return self._build_io() + self._build_resources() + self._build_shell_command() + + def _build_io(self) -> str: + return f""" rule: - name: "{self.name}" + name: "{self.implementation_name}" + message: "Running {self.step_name} implementation: {self.implementation_name}" input: implementation_inputs={self.execution_input}, validation="{self.validation}" output: {self.output} + log: "{self.diagnostics_dir}/{self.implementation_name}-output.log" container: "{self.image_path}" """ - + self._build_shell_command() - ) + + def _build_resources(self) -> str: + if not self.resources: + return "" + return f""" + resources: + slurm_partition='{self.resources['partition']}', + mem={self.resources['memory']}, + runtime={self.resources['time_limit']}, + nodes={self.resources['cpus']}, + slurm_extra="--output '{self.diagnostics_dir}/{self.implementation_name}-slurm-%j.log'" + """ def _build_shell_command(self) -> str: shell_cmd = f""" @@ -90,8 +109,9 @@ def _build_shell_command(self) -> str: for var_name, var_value in self.envvars.items(): shell_cmd += f""" export {var_name}={var_value}""" + # Log stdout/stderr to diagnostics directory shell_cmd += f""" - {self.script_cmd} + {self.script_cmd} > {{log}} 2>&1 '''""" return shell_cmd diff --git a/src/linker/runner.py b/src/linker/runner.py index 595ecda7..4ce9939f 100644 --- a/src/linker/runner.py +++ b/src/linker/runner.py @@ -10,6 +10,7 @@ from linker.pipeline import Pipeline from linker.utilities.data_utils import copy_configuration_files_to_results_directory from linker.utilities.paths import LINKER_TEMP +from linker.utilities.slurm_utils import is_on_slurm def main( @@ -24,7 +25,7 @@ def main( copy_configuration_files_to_results_directory(config, results_dir) snakefile = pipeline.build_snakefile(results_dir) environment_args = get_environment_args(config, results_dir) - singularity_args = get_singularity_args(config.input_data, results_dir) + singularity_args = get_singularity_args(config, results_dir) # We need to set a dummy environment variable to avoid logging a wall of text. # TODO [MIC-4920]: Remove when https://github.com/snakemake/snakemake-interface-executor-plugins/issues/55 merges os.environ["foo"] = "bar" @@ -38,6 +39,9 @@ def main( ## See above "--envvars", "foo", + ## Suppress some of the snakemake output + "--quiet", + "progress", "--use-singularity", "--singularity-args", singularity_args, @@ -47,11 +51,12 @@ def main( snake_main(argv) -def get_singularity_args(input_data: List[Path], results_dir: Path) -> str: - input_file_paths = ",".join(file.as_posix() for file in input_data) +def get_singularity_args(config: Config, results_dir: Path) -> str: + input_file_paths = ",".join(file.as_posix() for file in config.input_data) singularity_args = "--no-home --containall" - LINKER_TEMP.mkdir(parents=True, exist_ok=True) - singularity_args += f" -B {LINKER_TEMP}:/tmp,{results_dir},{input_file_paths}" + linker_tmp_dir = LINKER_TEMP[config.computing_environment] + linker_tmp_dir.mkdir(parents=True, exist_ok=True) + singularity_args += f" -B {linker_tmp_dir}:/tmp,{results_dir},{input_file_paths}" return singularity_args @@ -62,44 +67,24 @@ def get_environment_args(config: Config, results_dir: Path) -> List[str]: # TODO [MIC-4822]: launch a local spark cluster instead of relying on implementation elif config.computing_environment == "slurm": - # TODO: Add back slurm support - raise NotImplementedError( - "Slurm support is not yet implemented with snakemake integration" - ) - # # Set up a single drmaa.session that is persistent for the duration of the pipeline - # # TODO [MIC-4468]: Check for slurm in a more meaningful way - # hostname = socket.gethostname() - # if "slurm" not in hostname: - # raise RuntimeError( - # f"Specified a 'slurm' computing-environment but on host {hostname}" - # ) - # os.environ["DRMAA_LIBRARY_PATH"] = "/opt/slurm-drmaa/lib/libdrmaa.so" - # diagnostics = results_dir / "diagnostics/" - # job_name = "snakemake-linker" - # resources = config.slurm_resources - # drmaa_args = get_cli_args( - # job_name=job_name, - # account=resources["account"], - # partition=resources["partition"], - # peak_memory=resources["memory"], - # max_runtime=resources["time_limit"], - # num_threads=resources["cpus"], - # ) - # drmaa_cli_arguments = [ - # "--executor", - # "drmaa", - # "--drmaa-args", - # drmaa_args, - # "--drmaa-log-dir", - # str(diagnostics), - # ] - # # slurm_args = [ - # # "--executor", - # # "slurm", - # # "--profile", - # # "/ihme/homes/pnast/repos/linker/.config/snakemake/slurm" - # # ] - # return drmaa_cli_arguments + if not is_on_slurm(): + raise RuntimeError( + f"A 'slurm' computing environment is specified but it has been " + "determined that the current host is not on a slurm cluster " + f"(host: {socket.gethostname()})." + ) + resources = config.slurm_resources + slurm_args = [ + "--executor", + "slurm", + "--default-resources", + f"slurm_account={resources['account']}", + f"slurm_partition='{resources['partition']}'", + f"mem={resources['memory']}", + f"runtime={resources['time_limit']}", + f"nodes={resources['cpus']}", + ] + return slurm_args else: raise NotImplementedError( "only computing_environment 'local' and 'slurm' are supported; " diff --git a/src/linker/utilities/paths.py b/src/linker/utilities/paths.py index 8584c19a..e82ef803 100644 --- a/src/linker/utilities/paths.py +++ b/src/linker/utilities/paths.py @@ -3,4 +3,6 @@ # TODO: We'll need to update this to be more generic for external users and have a way of configuring this CONTAINER_DIR = "/mnt/team/simulation_science/priv/engineering/er_ecosystem/images" IMPLEMENTATION_METADATA = Path(__file__).parent.parent / "implementation_metadata.yaml" -LINKER_TEMP = Path("/tmp/linker") +# Bind linker temp dir to /tmp in the container. +# For now, put slurm in /tmp to avoid creating a subdir with a prolog script +LINKER_TEMP = {"local": Path("/tmp/linker"), "slurm": Path("/tmp")} diff --git a/src/linker/utilities/slurm_utils.py b/src/linker/utilities/slurm_utils.py index 7c1edf52..33566f9c 100644 --- a/src/linker/utilities/slurm_utils.py +++ b/src/linker/utilities/slurm_utils.py @@ -11,6 +11,11 @@ from linker.configuration import Config +def is_on_slurm() -> bool: + """Returns True if the current environment is a SLURM cluster.""" + return "SLURM_ROOT" in os.environ + + def get_slurm_drmaa() -> "drmaa": """Returns object() to bypass RuntimeError when not on a DRMAA-compliant system""" try: diff --git a/tests/unit/rule_strings/implemented_rule.txt b/tests/unit/rule_strings/implemented_rule_local.txt similarity index 76% rename from tests/unit/rule_strings/implemented_rule.txt rename to tests/unit/rule_strings/implemented_rule_local.txt index 27ceb072..dfb7f754 100644 --- a/tests/unit/rule_strings/implemented_rule.txt +++ b/tests/unit/rule_strings/implemented_rule_local.txt @@ -1,10 +1,12 @@ rule: name: "foo" + message: "Running foo_step implementation: foo" input: implementation_inputs=['foo', 'bar'], validation="bar" output: ['baz'] + log: "spam/foo-output.log" container: "Multipolarity.sif" shell: ''' @@ -12,5 +14,5 @@ rule: export DUMMY_CONTAINER_OUTPUT_PATHS=baz export DUMMY_CONTAINER_DIAGNOSTICS_DIRECTORY=spam export eggs=coconut - echo hello world + echo hello world > {log} 2>&1 ''' \ No newline at end of file diff --git a/tests/unit/rule_strings/implemented_rule_slurm.txt b/tests/unit/rule_strings/implemented_rule_slurm.txt new file mode 100644 index 00000000..88343d30 --- /dev/null +++ b/tests/unit/rule_strings/implemented_rule_slurm.txt @@ -0,0 +1,25 @@ + +rule: + name: "foo" + message: "Running foo_step implementation: foo" + input: + implementation_inputs=['foo', 'bar'], + validation="bar" + output: ['baz'] + log: "spam/foo-output.log" + container: "Multipolarity.sif" + resources: + slurm_partition='slurmpart', + mem=5, + runtime=1, + nodes=1337, + slurm_extra="--output 'spam/foo-slurm-%j.log'" + + shell: + ''' + export DUMMY_CONTAINER_MAIN_INPUT_FILE_PATHS=foo,bar + export DUMMY_CONTAINER_OUTPUT_PATHS=baz + export DUMMY_CONTAINER_DIAGNOSTICS_DIRECTORY=spam + export eggs=coconut + echo hello world > {log} 2>&1 + ''' \ No newline at end of file diff --git a/tests/unit/rule_strings/pipeline.txt b/tests/unit/rule_strings/pipeline_local.txt similarity index 86% rename from tests/unit/rule_strings/pipeline.txt rename to tests/unit/rule_strings/pipeline_local.txt index c6a66c4e..5e507a05 100644 --- a/tests/unit/rule_strings/pipeline.txt +++ b/tests/unit/rule_strings/pipeline_local.txt @@ -3,6 +3,7 @@ rule all: input: final_output=['{snake_dir}/result.parquet'], validation='{snake_dir}/input_validations/final_validator' +message: 'Grabbing final output' rule: name: "results_validator" input: ['{snake_dir}/result.parquet'] @@ -23,17 +24,19 @@ rule: validation_utils.validate_input_file_dummy(f) rule: name: "step_1_python_pandas" + message: "Running step_1 implementation: step_1_python_pandas" input: implementation_inputs=['{test_dir}/input_data1/file1.csv', '{test_dir}/input_data2/file2.csv'], validation="{snake_dir}/input_validations/step_1_python_pandas_validator" output: ['{snake_dir}/intermediate/1_step_1/result.parquet'] + log: "{snake_dir}/diagnostics/1_step_1/step_1_python_pandas-output.log" container: "/mnt/team/simulation_science/priv/engineering/er_ecosystem/images/python_pandas.sif" shell: ''' export DUMMY_CONTAINER_MAIN_INPUT_FILE_PATHS={test_dir}/input_data1/file1.csv,{test_dir}/input_data2/file2.csv export DUMMY_CONTAINER_OUTPUT_PATHS={snake_dir}/intermediate/1_step_1/result.parquet export DUMMY_CONTAINER_DIAGNOSTICS_DIRECTORY={snake_dir}/diagnostics/1_step_1 - python /dummy_step.py + python /dummy_step.py > {log} 2>&1 ''' rule: name: "step_2_python_pandas_validator" @@ -46,15 +49,17 @@ rule: validation_utils.validate_input_file_dummy(f) rule: name: "step_2_python_pandas" + message: "Running step_2 implementation: step_2_python_pandas" input: implementation_inputs=['{snake_dir}/intermediate/1_step_1/result.parquet'], validation="{snake_dir}/input_validations/step_2_python_pandas_validator" output: ['{snake_dir}/result.parquet'] + log: "{snake_dir}/diagnostics/2_step_2/step_2_python_pandas-output.log" container: "/mnt/team/simulation_science/priv/engineering/er_ecosystem/images/python_pandas.sif" shell: ''' export DUMMY_CONTAINER_MAIN_INPUT_FILE_PATHS={snake_dir}/intermediate/1_step_1/result.parquet export DUMMY_CONTAINER_OUTPUT_PATHS={snake_dir}/result.parquet export DUMMY_CONTAINER_DIAGNOSTICS_DIRECTORY={snake_dir}/diagnostics/2_step_2 - python /dummy_step.py + python /dummy_step.py > {log} 2>&1 ''' \ No newline at end of file diff --git a/tests/unit/rule_strings/pipeline_slurm.txt b/tests/unit/rule_strings/pipeline_slurm.txt new file mode 100644 index 00000000..f31bae71 --- /dev/null +++ b/tests/unit/rule_strings/pipeline_slurm.txt @@ -0,0 +1,79 @@ +from linker.utilities import validation_utils +rule all: + input: + final_output=['{snake_dir}/result.parquet'], + validation='{snake_dir}/input_validations/final_validator' +message: 'Grabbing final output' +rule: + name: "results_validator" + input: ['{snake_dir}/result.parquet'] + output: touch("{snake_dir}/input_validations/final_validator") + localrule: True + message: "Validating results input" + run: + for f in input: + validation_utils.validate_input_file_dummy(f) +rule: + name: "step_1_python_pandas_validator" + input: ['{test_dir}/input_data1/file1.csv', '{test_dir}/input_data2/file2.csv'] + output: touch("{snake_dir}/input_validations/step_1_python_pandas_validator") + localrule: True + message: "Validating step_1_python_pandas input" + run: + for f in input: + validation_utils.validate_input_file_dummy(f) +rule: + name: "step_1_python_pandas" + message: "Running step_1 implementation: step_1_python_pandas" + input: + implementation_inputs=['{test_dir}/input_data1/file1.csv', '{test_dir}/input_data2/file2.csv'], + validation="{snake_dir}/input_validations/step_1_python_pandas_validator" + output: ['{snake_dir}/intermediate/1_step_1/result.parquet'] + log: "{snake_dir}/diagnostics/1_step_1/step_1_python_pandas-output.log" + container: "/mnt/team/simulation_science/priv/engineering/er_ecosystem/images/python_pandas.sif" + resources: + slurm_partition='some-partition', + mem=42, + runtime=42, + nodes=42, + slurm_extra="--output '{snake_dir}/diagnostics/1_step_1/step_1_python_pandas-slurm-%j.log'" + + shell: + ''' + export DUMMY_CONTAINER_MAIN_INPUT_FILE_PATHS={test_dir}/input_data1/file1.csv,{test_dir}/input_data2/file2.csv + export DUMMY_CONTAINER_OUTPUT_PATHS={snake_dir}/intermediate/1_step_1/result.parquet + export DUMMY_CONTAINER_DIAGNOSTICS_DIRECTORY={snake_dir}/diagnostics/1_step_1 + python /dummy_step.py > {log} 2>&1 + ''' +rule: + name: "step_2_python_pandas_validator" + input: ['{snake_dir}/intermediate/1_step_1/result.parquet'] + output: touch("{snake_dir}/input_validations/step_2_python_pandas_validator") + localrule: True + message: "Validating step_2_python_pandas input" + run: + for f in input: + validation_utils.validate_input_file_dummy(f) +rule: + name: "step_2_python_pandas" + message: "Running step_2 implementation: step_2_python_pandas" + input: + implementation_inputs=['{snake_dir}/intermediate/1_step_1/result.parquet'], + validation="{snake_dir}/input_validations/step_2_python_pandas_validator" + output: ['{snake_dir}/result.parquet'] + log: "{snake_dir}/diagnostics/2_step_2/step_2_python_pandas-output.log" + container: "/mnt/team/simulation_science/priv/engineering/er_ecosystem/images/python_pandas.sif" + resources: + slurm_partition='some-partition', + mem=42, + runtime=42, + nodes=42, + slurm_extra="--output '{snake_dir}/diagnostics/2_step_2/step_2_python_pandas-slurm-%j.log'" + + shell: + ''' + export DUMMY_CONTAINER_MAIN_INPUT_FILE_PATHS={snake_dir}/intermediate/1_step_1/result.parquet + export DUMMY_CONTAINER_OUTPUT_PATHS={snake_dir}/result.parquet + export DUMMY_CONTAINER_DIAGNOSTICS_DIRECTORY={snake_dir}/diagnostics/2_step_2 + python /dummy_step.py > {log} 2>&1 + ''' \ No newline at end of file diff --git a/tests/unit/rule_strings/target_rule.txt b/tests/unit/rule_strings/target_rule.txt index 16804ef5..05e08c6f 100644 --- a/tests/unit/rule_strings/target_rule.txt +++ b/tests/unit/rule_strings/target_rule.txt @@ -2,4 +2,5 @@ rule all: input: final_output=['foo', 'bar'], - validation='bar' \ No newline at end of file + validation='bar' + message: 'Grabbing final output' \ No newline at end of file diff --git a/tests/unit/test_pipeline.py b/tests/unit/test_pipeline.py index 30f011eb..4f4e0ac3 100644 --- a/tests/unit/test_pipeline.py +++ b/tests/unit/test_pipeline.py @@ -2,8 +2,16 @@ from pathlib import Path from tempfile import TemporaryDirectory +import pytest + +from linker.configuration import Config from linker.pipeline import Pipeline +PIPELINE_STRINGS = { + "local": "rule_strings/pipeline_local.txt", + "slurm": "rule_strings/pipeline_slurm.txt", +} + def test__get_implementations(default_config, mocker): mocker.patch("linker.implementation.Implementation.validate", return_value={}) @@ -53,12 +61,21 @@ def test_get_diagnostic_dir(default_config, mocker): ) -def test_build_snakefile(default_config, mocker, test_dir): +@pytest.mark.parametrize("computing_environment", ["local", "slurm"]) +def test_build_snakefile(default_config_params, mocker, test_dir, computing_environment): + config_params = default_config_params + if computing_environment == "slurm": + config_params.update( + {"computing_environment": Path(f"{test_dir}/spark_environment.yaml")} + ) + config = Config(**config_params) mocker.patch("linker.implementation.Implementation.validate", return_value={}) - pipeline = Pipeline(default_config) + pipeline = Pipeline(config) with TemporaryDirectory() as snake_dir: snakefile = pipeline.build_snakefile(Path(snake_dir)) - expected_file_path = Path(os.path.dirname(__file__)) / "rule_strings/pipeline.txt" + expected_file_path = ( + Path(os.path.dirname(__file__)) / PIPELINE_STRINGS[computing_environment] + ) with open(expected_file_path) as expected_file: expected = expected_file.read() expected = expected.replace("{snake_dir}", snake_dir) diff --git a/tests/unit/test_rule.py b/tests/unit/test_rule.py index 93a1a48c..554150a2 100644 --- a/tests/unit/test_rule.py +++ b/tests/unit/test_rule.py @@ -2,11 +2,14 @@ from pathlib import Path from tempfile import TemporaryDirectory +import pytest + from linker.rule import ImplementedRule, InputValidationRule, Rule, TargetRule RULE_STRINGS = { "target_rule": "rule_strings/target_rule.txt", - "implemented_rule": "rule_strings/implemented_rule.txt", + "implemented_rule_local": "rule_strings/implemented_rule_local.txt", + "implemented_rule_slurm": "rule_strings/implemented_rule_slurm.txt", "validation_rule": "rule_strings/validation_rule.txt", } @@ -40,18 +43,35 @@ def test_target_rule_build_rule(): assert rulestring_lines[i].strip() == expected_line.strip() -def test_implemented_rule_build_rule(): +@pytest.mark.parametrize("computing_environment", ["local", "slurm"]) +def test_implemented_rule_build_rule(computing_environment): + if computing_environment == "slurm": + resources = { + "partition": "slurmpart", + "time_limit": 1, + "memory": 5, + "cpus": 1337, + } + else: + resources = None + rule = ImplementedRule( - name="foo", + step_name="foo_step", + implementation_name="foo", execution_input=["foo", "bar"], validation="bar", output=["baz"], + resources=resources, envvars={"eggs": "coconut"}, diagnostics_dir="spam", image_path="Multipolarity.sif", script_cmd="echo hello world", ) - file_path = Path(os.path.dirname(__file__)) / RULE_STRINGS["implemented_rule"] + + file_path = ( + Path(os.path.dirname(__file__)) + / RULE_STRINGS[f"implemented_rule_{computing_environment}"] + ) with open(file_path) as expected_file: expected = expected_file.read() rulestring = rule._build_rule() diff --git a/tests/unit/test_runner.py b/tests/unit/test_runner.py index c60b1a48..5fad4b7d 100644 --- a/tests/unit/test_runner.py +++ b/tests/unit/test_runner.py @@ -1,19 +1,50 @@ +import os +from pathlib import Path from tempfile import TemporaryDirectory +import pytest + +from linker.configuration import Config from linker.runner import get_environment_args, get_singularity_args +from linker.utilities.paths import LINKER_TEMP + +IN_GITHUB_ACTIONS = os.getenv("GITHUB_ACTIONS") == "true" def test_get_singularity_args(default_config, test_dir): with TemporaryDirectory() as results_dir: assert ( - get_singularity_args(default_config.input_data, results_dir) - == f"--no-home --containall -B /tmp/linker:/tmp," + get_singularity_args(default_config, results_dir) + == f"--no-home --containall -B {LINKER_TEMP[default_config.computing_environment]}:/tmp," f"{results_dir}," f"{test_dir}/input_data1/file1.csv," f"{test_dir}/input_data2/file2.csv" ) -def test_get_environment_args(default_config, test_dir): - assert default_config.computing_environment == "local" - assert get_environment_args(default_config, test_dir) == [] +def test_get_environment_args_local(default_config_params, test_dir): + config = Config(**default_config_params) + assert get_environment_args(config, test_dir) == [] + + +@pytest.mark.skipif( + IN_GITHUB_ACTIONS, + reason="Github Actions does not have access to our file system and so no SLURM.", +) +def test_get_environment_args_slurm(default_config_params, test_dir): + slurm_config_params = default_config_params + slurm_config_params.update( + {"computing_environment": Path(f"{test_dir}/spark_environment.yaml")} + ) + slurm_config = Config(**slurm_config_params) + resources = slurm_config.slurm_resources + assert get_environment_args(slurm_config, test_dir) == [ + "--executor", + "slurm", + "--default-resources", + f"slurm_account={resources['account']}", + f"slurm_partition='{resources['partition']}'", + f"mem={resources['memory']}", + f"runtime={resources['time_limit']}", + f"nodes={resources['cpus']}", + ] diff --git a/tests/unit/test_slurm_utils.py b/tests/unit/test_slurm_utils.py index 001b5d7d..0642d4bd 100644 --- a/tests/unit/test_slurm_utils.py +++ b/tests/unit/test_slurm_utils.py @@ -11,6 +11,7 @@ _generate_spark_cluster_job_template, get_cli_args, get_slurm_drmaa, + is_on_slurm, ) CLI_KWARGS = { @@ -26,6 +27,15 @@ IN_GITHUB_ACTIONS = os.getenv("GITHUB_ACTIONS") == "true" +def test_is_on_slurm(): + # export SLURM_ROOT + os.environ["SLURM_ROOT"] = "/some/path" + assert is_on_slurm() + # unset SLURM_ROOT + del os.environ["SLURM_ROOT"] + assert not is_on_slurm() + + @pytest.mark.skipif( IN_GITHUB_ACTIONS, reason="Github Actions does not have access to our file system and so no drmaa.",