Repository: reframe-hpc/reframe

Issue:
Support parallel launcher modifiers in an easier way
This is currently possible using the `LauncherWrapper` but the syntax is a bit cumbersome. I suggest using extending the `Launcher` itself with something like `launcher.modifier = 'gdb'` and `launcher.modifier_opts = ['--args', ...]`.

Discussion:

Patch:
diff --git a/reframe/core/launchers/__init__.py b/reframe/core/launchers/__init__.py
index 662facc9..d1c10b2c 100644
--- a/reframe/core/launchers/__init__.py
+++ b/reframe/core/launchers/__init__.py
@@ -4,12 +4,16 @@
 # SPDX-License-Identifier: BSD-3-Clause
 
 import abc
-
-import reframe.core.fields as fields
 import reframe.utility.typecheck as typ
+from reframe.core.meta import RegressionTestMeta
+from reframe.core.warnings import user_deprecation_warning
+
 
+class _JobLauncherMeta(RegressionTestMeta, abc.ABCMeta):
+    '''Job launcher metaclass.'''
 
-class JobLauncher(abc.ABC):
+
+class JobLauncher(metaclass=_JobLauncherMeta):
     '''Abstract base class for job launchers.
 
     A job launcher is the executable that actually launches a distributed
@@ -30,7 +34,28 @@ class JobLauncher(abc.ABC):
     #:
     #: :type: :class:`List[str]`
     #: :default: ``[]``
-    options = fields.TypedField(typ.List[str])
+    options = variable(typ.List[str], value=[])
+
+    #: Optional modifier of the launcher command.
+    #:
+    #: This will be combined with the :attr:`modifier_options` and prepended to
+    #: the parallel launch command.
+    #:
+    #: :type: :class:`str`
+    #: :default: ``''``
+    #:
+    #: .. versionadded:: 4.6.0
+    modifier = variable(str, value='')
+
+    #: Options to be passed to the launcher :attr:`modifier`.
+    #:
+    #: If the modifier is empty, these options will be ignored.
+    #:
+    #: :type: :clas:`List[str]`
+    #: :default: ``[]``
+    #:
+    #: :versionadded:: 4.6.0
+    modifier_options = variable(typ.List[str], value=[])
 
     def __init__(self):
         self.options = []
@@ -53,7 +78,13 @@ class JobLauncher(abc.ABC):
         :param job: a job descriptor.
         :returns: the launcher command as a string.
         '''
-        return ' '.join(self.command(job) + self.options)
+        cmd_tokens = []
+        if self.modifier:
+            cmd_tokens.append(self.modifier)
+            cmd_tokens += self.modifier_options
+
+        cmd_tokens += self.command(job) + self.options
+        return ' '.join(cmd_tokens)
 
 
 class LauncherWrapper(JobLauncher):
@@ -90,8 +121,13 @@ class LauncherWrapper(JobLauncher):
 
     '''
 
-    def __init__(self, target_launcher, wrapper_command, wrapper_options=[]):
+    def __init__(self, target_launcher, wrapper_command, wrapper_options=None):
         super().__init__()
+        user_deprecation_warning("'LauncherWrapper is deprecated; "
+                                 "please use the launcher's 'modifier' and "
+                                 "'modifier_options' instead")
+
+        wrapper_options = wrapper_options or []
         self.options = target_launcher.options
         self._target_launcher = target_launcher
         self._wrapper_command = [wrapper_command] + wrapper_options
diff --git a/reframe/core/launchers/rsh.py b/reframe/core/launchers/rsh.py
index 984f11a5..d858caf1 100644
--- a/reframe/core/launchers/rsh.py
+++ b/reframe/core/launchers/rsh.py
@@ -29,5 +29,11 @@ class SSHLauncher(JobLauncher):
         return ['ssh', '-o BatchMode=yes'] + ssh_opts + [hostname]
 
     def run_command(self, job):
+        cmd_tokens = []
+        if self.modifier:
+            cmd_tokens.append(self.modifier)
+            cmd_tokens += self.modifier_options
+
         # self.options is processed specially above
-        return ' '.join(self.command(job))
+        cmd_tokens += self.command(job)
+        return ' '.join(cmd_tokens)


Test patch:
diff --git a/unittests/test_launchers.py b/unittests/test_launchers.py
index 5d84ce7f..561dffd8 100644
--- a/unittests/test_launchers.py
+++ b/unittests/test_launchers.py
@@ -8,6 +8,7 @@ import pytest
 import reframe.core.launchers as launchers
 from reframe.core.backends import getlauncher
 from reframe.core.schedulers import Job, JobScheduler
+from reframe.core.warnings import ReframeDeprecationWarning
 
 
 @pytest.fixture(params=[
@@ -20,9 +21,10 @@ def launcher(request):
         # convenience for the rest of the unit tests
         wrapper_cls = launchers.LauncherWrapper
         wrapper_cls.registered_name = 'launcherwrapper'
-        return wrapper_cls(
-            getlauncher('alps')(), 'ddt', ['--offline']
-        )
+        with pytest.warns(ReframeDeprecationWarning):
+            return wrapper_cls(
+                getlauncher('alps')(), 'ddt', ['--offline']
+            )
 
     return getlauncher(request.param)()
 
@@ -154,38 +156,52 @@ def test_run_command(job):
         assert command == 'lrun -N 2 -T 2 -M "-gpu" --foo'
 
 
-def test_run_command_minimal(minimal_job):
+@pytest.fixture(params=['modifiers', 'plain'])
+def use_modifiers(request):
+    return request.param == 'modifiers'
+
+
+def test_run_command_minimal(minimal_job, use_modifiers):
     launcher_name = type(minimal_job.launcher).registered_name
     # This is relevant only for the srun launcher, because it may
     # run in different platforms with older versions of Slurm
     minimal_job.launcher.use_cpus_per_task = True
+    if use_modifiers and launcher_name != 'launcherwrapper':
+        minimal_job.launcher.modifier = 'ddt'
+        minimal_job.launcher.modifier_options = ['--offline']
+        prefix = 'ddt --offline'
+        if launcher_name != 'local':
+            prefix += ' '
+    else:
+        prefix = ''
+
     command = minimal_job.launcher.run_command(minimal_job)
     if launcher_name == 'alps':
-        assert command == 'aprun -n 1 --foo'
+        assert command == f'{prefix}aprun -n 1 --foo'
     elif launcher_name == 'launcherwrapper':
         assert command == 'ddt --offline aprun -n 1 --foo'
     elif launcher_name == 'local':
-        assert command == ''
+        assert command == f'{prefix}'
     elif launcher_name == 'mpiexec':
-        assert command == 'mpiexec -n 1 --foo'
+        assert command == f'{prefix}mpiexec -n 1 --foo'
     elif launcher_name == 'mpirun':
-        assert command == 'mpirun -np 1 --foo'
+        assert command == f'{prefix}mpirun -np 1 --foo'
     elif launcher_name == 'srun':
-        assert command == 'srun --foo'
+        assert command == f'{prefix}srun --foo'
     elif launcher_name == 'srunalloc':
-        assert command == ('srun '
+        assert command == (f'{prefix}srun '
                            '--job-name=fake_job '
                            '--ntasks=1 '
                            '--foo')
     elif launcher_name == 'ssh':
-        assert command == 'ssh -o BatchMode=yes --foo host'
+        assert command == f'{prefix}ssh -o BatchMode=yes --foo host'
     elif launcher_name in ('clush', 'pdsh'):
-        assert command == f'{launcher_name} -w host --foo'
+        assert command == f'{prefix}{launcher_name} -w host --foo'
     elif launcher_name == 'upcrun':
-        assert command == 'upcrun -n 1 --foo'
+        assert command == f'{prefix}upcrun -n 1 --foo'
     elif launcher_name == 'upcxx-run':
-        assert command == 'upcxx-run -n 1 --foo'
+        assert command == f'{prefix}upcxx-run -n 1 --foo'
     elif launcher_name == 'lrun':
-        assert command == 'lrun -N 1 -T 1 --foo'
+        assert command == f'{prefix}lrun -N 1 -T 1 --foo'
     elif launcher_name == 'lrun-gpu':
-        assert command == 'lrun -N 1 -T 1 -M "-gpu" --foo'
+        assert command == f'{prefix}lrun -N 1 -T 1 -M "-gpu" --foo'