Repository: reframe-hpc/reframe

Issue:
Fixture variables cannot be set from the command line
This is very easy to reproduce by simply trying doing `-S fixture_class.var=val`. You will notice that the variable is not set. This is due to the following:

https://github.com/eth-cscs/reframe/blob/f44e37c781da8d4bbc20daf52597458ffb5da377/reframe/frontend/loader.py#L173-L205

Although we set the variables in all the tests in the test registry, this does not include the fixtures, which are actually instantiated just after.

Discussion:

Patch:
diff --git a/docs/manpage.rst b/docs/manpage.rst
index 531b7399..4c6356bf 100644
--- a/docs/manpage.rst
+++ b/docs/manpage.rst
@@ -484,6 +484,10 @@ Options controlling ReFrame execution
 
    Set variable ``VAR`` in all tests or optionally only in test ``TEST`` to ``VAL``.
 
+   ``TEST`` can have the form ``[TEST.][FIXT.]*``, in which case ``VAR`` will be set in fixture ``FIXT`` of ``TEST``.
+   Note that this syntax is recursive on fixtures, so that a variable can be set in a fixture arbitrarily deep.
+   ``TEST`` prefix refers to the test class name, *not* the test name, but ``FIXT`` refers to the fixture name *inside* the referenced test.
+
    Multiple variables can be set at the same time by passing this option multiple times.
    This option *cannot* change arbitrary test attributes, but only test variables declared with the :attr:`~reframe.core.pipeline.RegressionMixin.variable` built-in.
    If an attempt is made to change an inexistent variable or a test parameter, a warning will be issued.
@@ -511,8 +515,6 @@ Options controlling ReFrame execution
    Conversions to arbitrary objects are also supported.
    See :class:`~reframe.utility.typecheck.ConvertibleType` for more details.
 
-   The optional ``TEST.`` prefix refers to the test class name, *not* the test name.
-
    Variable assignments passed from the command line happen *before* the test is instantiated and is the exact equivalent of assigning a new value to the variable *at the end* of the test class body.
    This has a number of implications that users of this feature should be aware of:
 
@@ -561,6 +563,10 @@ Options controlling ReFrame execution
 
       Proper handling of boolean variables.
 
+   .. versionchanged:: 3.11.1
+
+      Allow setting variables in fixtures.
+
 
 .. option:: --skip-performance-check
 
diff --git a/reframe/core/meta.py b/reframe/core/meta.py
index a28162a0..5e5b227f 100644
--- a/reframe/core/meta.py
+++ b/reframe/core/meta.py
@@ -535,6 +535,17 @@ class RegressionTestMeta(type):
 
         '''
 
+        if '.' in name:
+            # `name` refers to a fixture variable
+            fixtname, varname = name.split('.', maxsplit=1)
+            try:
+                fixt_space = super().__getattribute__('_rfm_fixture_space')
+            except AttributeError:
+                '''Catch early access attempt to the variable space.'''
+
+            if fixtname in fixt_space:
+                return fixt_space[fixtname].cls.setvar(varname, value)
+
         try:
             var_space = super().__getattribute__('_rfm_var_space')
             if name in var_space:
diff --git a/reframe/core/variables.py b/reframe/core/variables.py
index 48da55e3..b90bf97e 100644
--- a/reframe/core/variables.py
+++ b/reframe/core/variables.py
@@ -281,7 +281,7 @@ class TestVar:
     def _check_is_defined(self):
         if not self.is_defined():
             raise ReframeSyntaxError(
-                f'variable {self._name} is not assigned a value'
+                f'variable {self._name!r} is not assigned a value'
             )
 
     def __repr__(self):


Test patch:
diff --git a/unittests/resources/checks_unlisted/externalvars.py b/unittests/resources/checks_unlisted/externalvars.py
index eaafa529..113061fe 100644
--- a/unittests/resources/checks_unlisted/externalvars.py
+++ b/unittests/resources/checks_unlisted/externalvars.py
@@ -3,19 +3,33 @@ import reframe.utility.sanity as sn
 import reframe.utility.typecheck as typ
 
 
+class Bacon(rfm.RunOnlyRegressionTest):
+    bacon = variable(int, value=-1)
+    executable = 'echo'
+    sanity_patterns = sn.assert_true(1)
+
+
+class Eggs(rfm.RunOnlyRegressionTest):
+    eggs = fixture(Bacon)
+    executable = 'echo'
+    sanity_patterns = sn.assert_true(1)
+
+
 @rfm.simple_test
 class external_x(rfm.RunOnlyRegressionTest):
     valid_systems = ['*']
     valid_prog_environs = ['*']
     foo = variable(int, value=1)
     ham = variable(typ.Bool, value=False)
+    spam = fixture(Eggs)
     executable = 'echo'
 
     @sanity_function
     def assert_foo(self):
         return sn.all([
             sn.assert_eq(self.foo, 3),
-            sn.assert_true(self.ham)
+            sn.assert_true(self.ham),
+            sn.assert_eq(self.spam.eggs.bacon, 10)
         ])
 
 
diff --git a/unittests/test_cli.py b/unittests/test_cli.py
index ce762d2a..4bb5f52e 100644
--- a/unittests/test_cli.py
+++ b/unittests/test_cli.py
@@ -837,13 +837,17 @@ def test_detect_host_topology_file(run_reframe, tmp_path):
 def test_external_vars(run_reframe):
     returncode, stdout, stderr = run_reframe(
         checkpath=['unittests/resources/checks_unlisted/externalvars.py'],
-        more_options=['-S', 'external_x.foo=3', '-S', 'external_y.foo=2',
-                      '-S', 'foolist=3,4', '-S', 'bar=@none',
+        more_options=['-S', 'external_x.foo=3',
                       '-S', 'external_x.ham=true',
-                      '-S', 'external_y.baz=false']
+                      '-S', 'external_x.spam.eggs.bacon=10',
+                      '-S', 'external_y.foo=2',
+                      '-S', 'external_y.baz=false',
+                      '-S', 'foolist=3,4',
+                      '-S', 'bar=@none']
     )
+    assert 'PASSED' in stdout
+    assert 'Ran 6/6 test case(s)' in stdout
     assert 'Traceback' not in stdout
-    assert 'Ran 2/2 test case(s)' in stdout
     assert 'Traceback' not in stderr
     assert returncode == 0