Repository: reframe-hpc/reframe

Issue:
The `-S` option resets any test variable set in an execution mode using the `-S` option
## How to reproduce

Create the following `mode_config.py`:

```python
site_configuration = {
    'modes': [
        {
            'name': 'foo',
            'options': ['-c unittests/resources/checks/hellocheck.py', '-n ^HelloTest', '-S descr=foo_test']
        }
    ]
}
```

and then run the following:

```
reframe -C mode_config.py --mode=foo --describe | jq .[].descr
```

The test description is as expected:

```json
"foo_test"
```

However if we set an irrelevant variable, the variable set in the mode will be lost:

```
reframe -C mode_config.py --mode=foo -S num_tasks=1 --describe | jq .[].descr
```

Then the test description is reset:

```json
"C Hello World test"
```

Discussion:

Patch:
diff --git a/docs/config_reference.rst b/docs/config_reference.rst
index a5277e21..29c4eae1 100644
--- a/docs/config_reference.rst
+++ b/docs/config_reference.rst
@@ -1400,6 +1400,8 @@ This handler transmits the whole log record, meaning that all the information wi
 
 
 
+.. _exec-mode-config:
+
 Execution Mode Configuration
 ----------------------------
 
diff --git a/docs/manpage.rst b/docs/manpage.rst
index 5e72b4ad..5fc291ef 100644
--- a/docs/manpage.rst
+++ b/docs/manpage.rst
@@ -489,8 +489,15 @@ Options controlling ReFrame execution
 
    ReFrame execution mode to use.
 
-   An execution mode is simply a predefined invocation of ReFrame that is set with the :data:`modes` configuration parameter.
-   If an option is specified both in an execution mode and in the command-line, then command-line takes precedence.
+   An execution mode is simply a predefined set of options that is set in the :attr:`~modes` :ref:`configuration parameter <exec-mode-config>`.
+   Additional options can be passed to the command line, in which case they will be combined with the options defined in the selected execution mode.
+   More specifically, any additional ReFrame options will be *appended* to the command line options of the selected mode.
+   As a result, if a normal option is specified both inside the execution mode and the in the command line, the command line option will take precedence.
+   On the other hand, if an option that is allowed to be specified multiple times, e.g., the :option:`-S` option, is passed both inside the execution mode and in the command line, their values will be combined.
+   For example, if the execution mode ``foo`` defines ``-S modules=foo``, the invocation ``--mode=foo -S num_tasks=10`` is the equivalent of ``-S modules=foo -S num_tasks=10``.
+
+   .. versionchanged:: 4.1
+      Options that can be specified multiple times are now combined between execution modes and the command line.
 
 .. option:: --repeat=N
 
diff --git a/reframe/frontend/argparse.py b/reframe/frontend/argparse.py
index 55f77c51..5c108f2b 100644
--- a/reframe/frontend/argparse.py
+++ b/reframe/frontend/argparse.py
@@ -9,6 +9,7 @@ import os
 
 import reframe.utility.typecheck as typ
 
+
 #
 # Notes on the ArgumentParser design
 #
@@ -278,10 +279,20 @@ class ArgumentParser(_ArgumentHolder):
 
         # Update parser's defaults with groups' defaults
         self._update_defaults()
+
+        # Update the parsed options of those from the given namespace and/or
+        # the defaults
         for attr, val in options.__dict__.items():
             if val is None:
-                options.__dict__[attr] = self._resolve_attr(
-                    attr, [namespace, self._defaults]
-                )
+                resolved = self._resolve_attr(attr,
+                                              [namespace, self._defaults])
+                options.__dict__[attr] = resolved
+            elif self._option_map[attr][2] == 'append':
+                # 'append' options are combined with those from the given
+                # namespace, but *not* with the defaults (important)
+                resolved = self._resolve_attr(attr, [namespace])
+                if resolved is not None:
+                    v = options.__dict__[attr]
+                    options.__dict__[attr] = resolved + v
 
         return _Namespace(options, self._option_map)


Test patch:
diff --git a/unittests/test_argparser.py b/unittests/test_argparser.py
index 5abdbd80..cc7e80f8 100644
--- a/unittests/test_argparser.py
+++ b/unittests/test_argparser.py
@@ -74,7 +74,9 @@ def test_parsing(argparser, foo_options, bar_options):
         '--bar beer --foolist any'.split(), options
     )
     assert 'name' == options.foo
-    assert ['any'] == options.foolist
+
+    # 'append' options are extended
+    assert ['gag', 'any'] == options.foolist
     assert not options.foobar
     assert not options.unfoo
     assert 'beer' == options.bar