Skip to content

Commit 6b67d19

Browse files
author
Vasileios Karakasis
authored
Merge pull request #1699 from jjotero/feature/extensible-vars
[feat] Add built-ins to manage test variables and parameters
2 parents bf2acad + 0a64d4c commit 6b67d19

File tree

9 files changed

+1097
-333
lines changed

9 files changed

+1097
-333
lines changed

docs/regression_test_api.rst

Lines changed: 139 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -36,57 +36,168 @@ Pipeline Hooks
3636
.. autodecorator:: reframe.core.decorators.require_deps
3737

3838

39-
.. _directives:
4039

41-
Directives
42-
----------
40+
Builtins
41+
--------
4342

44-
Directives are functions that can be called directly in the body of a ReFrame regression test class.
45-
These functions exert control over the test creation, and they allow adding and/or modifying certain attributes of the regression test.
46-
For example, a test can be parameterized using the :func:`parameter` directive as follows:
43+
.. versionadded:: 3.4.2
4744

48-
.. code:: python
45+
ReFrame provides built-in functions that facilitate the creation of extensible tests (i.e. a test library).
46+
These *builtins* are intended to be used directly in the class body of the test, allowing the ReFrame internals to *pre-process* their input before the actual test creation takes place.
47+
This provides the ReFrame internals with further control over the user's input, making the process of writing regression tests less error-prone thanks to a better error checking.
48+
In essence, these builtins exert control over the test creation, and they allow adding and/or modifying certain attributes of the regression test.
49+
50+
51+
.. py:function:: reframe.core.pipeline.RegressionTest.parameter(values=None, inherit_params=False, filter_params=None)
52+
53+
Inserts or modifies a regression test parameter.
54+
If a parameter with a matching name is already present in the parameter space of a parent class, the existing parameter values will be combined with those provided by this method following the inheritance behavior set by the arguments ``inherit_params`` and ``filter_params``.
55+
Instead, if no parameter with a matching name exists in any of the parent parameter spaces, a new regression test parameter is created.
56+
A regression test can be parametrized as follows:
57+
58+
.. code:: python
59+
60+
class Foo(rfm.RegressionTest):
61+
variant = parameter(['A', 'B'])
4962
50-
class MyTest(rfm.RegressionTest):
51-
parameter('variant', ['A', 'B'])
52-
5363
def __init__(self):
5464
if self.variant == 'A':
5565
do_this()
5666
else:
5767
do_other()
5868
59-
One of the most powerful features about using directives is that they store their input information at the class level.
60-
This means if one were to extend or specialize an existing regression test, the test attribute additions and modifications made through directives in the parent class will be automatically inherited by the child test.
61-
For instance, continuing with the example above, one could override the :func:`__init__` method in the :class:`MyTest` regression test as follows:
62-
63-
.. code:: python
69+
One of the most powerful features about these built-in functions is that they store their input information at the class level.
70+
This means if one were to extend or specialize an existing regression test, the test attribute additions and modifications made through built-in functions in the parent class will be automatically inherited by the child test.
71+
For instance, continuing with the example above, one could override the :func:`__init__` method in the :class:`MyTest` regression test as follows:
6472

65-
class MyModifiedTest(MyTest):
73+
.. code:: python
6674
75+
class Bar(Foo):
6776
def __init__(self):
6877
if self.variant == 'A':
6978
override_this()
7079
else:
7180
override_other()
7281
82+
Note that this built-in parameter function provides an alternative method to parameterize a test to :func:`reframe.core.decorators.parameterized_test`, and the use of both approaches in the same test is currently disallowed.
83+
The two main advantages of the built-in :func:`parameter` over the decorated approach reside in the parameter inheritance across classes and the handling of large parameter sets.
84+
As shown in the example above, the parameters declared with the built-in :func:`parameter` are automatically carried over into derived tests through class inheritance, whereas tests using the decorated approach would have to redefine the parameters on every test.
85+
Similarly, parameters declared through the built-in :func:`parameter` are regarded as fully independent from each other and ReFrame will automatically generate as many tests as available parameter combinations. This is a major advantage over the decorated approach, where one would have to manually expand the parameter combinations.
86+
This is illustrated in the example below, consisting of a case with two parameters, each having two possible values.
87+
88+
.. code:: python
89+
90+
# Parameterized test with two parameters (p0 = ['a', 'b'] and p1 = ['x', 'y'])
91+
@rfm.parameterized_test(['a','x'], ['a','y'], ['b','x'], ['b', 'y'])
92+
class Foo(rfm.RegressionTest):
93+
def __init__(self, p0, p1):
94+
do_something(p0, p1)
95+
96+
# This is easier to write with the parameter built-in.
97+
@rfm.simple_test
98+
class Bar(rfm.RegressionTest):
99+
p0 = parameter(['a', 'b'])
100+
p1 = parameter(['x', 'y'])
101+
102+
def __init__(self):
103+
do_something(self.p0, self.p1)
104+
105+
106+
:param values: A list containing the parameter values.
107+
If no values are passed when creating a new parameter, the parameter is considered as *declared* but not *defined* (i.e. an abstract parameter).
108+
Instead, for an existing parameter, this depends on the parameter's inheritance behaviour and on whether any values where provided in any of the parent parameter spaces.
109+
:param inherit_params: If :obj:`False`, no parameter values that may have been defined in any of the parent parameter spaces will be inherited.
110+
:param filter_params: Function to filter/modify the inherited parameter values that may have been provided in any of the parent parameter spaces.
111+
This function must accept a single argument, which will be passed as an iterable containing the inherited parameter values.
112+
This only has an effect if used with ``inherit_params=True``.
113+
114+
115+
.. py:function:: reframe.core.pipeline.RegressionTest.variable(*types, value=None)
116+
117+
Inserts a new regression test variable.
118+
Declaring a test variable through the :func:`variable` built-in allows for a more robust test implementation than if the variables were just defined as regular test attributes (e.g. ``self.a = 10``).
119+
Using variables declared through the :func:`variable` built-in guarantees that these regression test variables will not be redeclared by any child class, while also ensuring that any values that may be assigned to such variables comply with its original declaration.
120+
In essence, by using test variables, the user removes any potential test errors that might be caused by accidentally overriding a class attribute. See the example below.
121+
122+
123+
.. code:: python
124+
125+
class Foo(rfm.RegressionTest):
126+
my_var = variable(int, value=8)
127+
not_a_var = 4
128+
129+
def __init__(self):
130+
print(self.my_var) # prints 8.
131+
# self.my_var = 'override' # Error: my_var must be an int!
132+
self.not_a_var = 'override' # However, this would work. Dangerous!
133+
self.my_var = 10 # tests may also assign values the standard way
134+
135+
The argument ``value`` in the :func:`variable` built-in sets the default value for the variable.
136+
As mentioned above, a variable may not be declared more than once, but its default value can be updated by simply assigning it a new value directly in the class body.
137+
138+
.. code:: python
139+
140+
class Bar(Foo):
141+
my_var = 4
142+
# my_var = 'override' # Error again!
143+
144+
def __init__(self):
145+
print(self.my_var) # prints 4.
146+
147+
Here, the class :class:`Bar` inherits the variables from :class:`Foo` and can see that ``my_var`` has already been declared in the parent class. Therefore, the value of ``my_var`` is updated ensuring that the new value complies to the original variable declaration.
148+
149+
These examples above assumed that a default value can be provided to the variables in the bases tests, but that might not always be the case.
150+
For example, when writing a test library, one might want to leave some variables undefined and force the user to set these when using the test.
151+
As shown in the example below, imposing such requirement is as simple as not passing any ``value`` to the :func:`variable` built-in, which marks the given variable as *required*.
152+
153+
.. code:: python
154+
155+
# Test as written in the library
156+
class EchoBaseTest(rfm.RunOnlyRegressionTest):
157+
what = variable(str)
158+
159+
def __init__(self):
160+
self.valid_systems = ['*']
161+
self.valid_prog_environs = ['PrgEnv-gnu']
162+
self.executable = f'echo {self.what}'
163+
self.sanity_patterns = sn.assert_found(fr'{self.what}')
164+
165+
166+
# Test as written by the user
167+
@rfm.simple_test
168+
class HelloTest(EchoBaseTest):
169+
what = 'Hello'
170+
171+
172+
# A parametrized test with type-checking
173+
@rfm.simple_test
174+
class FoodTest(EchoBaseTest):
175+
param = parameter(['Bacon', 'Eggs'])
176+
177+
def __init__(self):
178+
self.what = self.param
179+
super().__init__()
180+
181+
182+
Similarly to a variable with a value already assigned to it, the value of a required variable may be set either directly in the class body, on the :func:`__init__` method, or in any other hook before it is referenced.
183+
Otherwise an error will be raised indicating that a required variable has not been set.
184+
Conversely, a variable with a default value already assigned to it can be made required by assigning it the ``required`` keyword.
73185

74-
.. py:function:: reframe.core.pipeline.RegressionTest.parameter(name, values=None, inherit_params=False, filter_params=None)
186+
.. code:: python
75187
76-
Inserts or modifies a regression test parameter.
77-
If a parameter with a matching name is already present in the parameter space of a parent class, the existing parameter values will be combined with those provided by this method following the inheritance behaviour set by the arguments ``inherit_params`` and ``filter_params``.
78-
Instead, if no parameter with a matching name exists in any of the parent parameter spaces, a new regression test parameter is created.
188+
class MyRequiredTest(HelloTest):
189+
what = required
79190
80-
:param name: The parameter name.
81-
:param values: A list containing the parameter values.
82-
If no values are passed when creating a new parameter, the parameter is considered as *declared* but not *defined* (i.e. an abstract parameter).
83-
Instead, for an existing parameter, this depends on the parameter's inheritance behaviour and on whether any values where provided in any of the parent parameter spaces.
84-
:param inherit_params: If :obj:`False`, no parameter values that may have been defined in any of the parent parameter spaces will be inherited.
85-
:param filter_params: Function to filter/modify the inherited parameter values that may have been provided in any of the parent parameter spaces.
86-
This function must accept a single argument, which will be passed as an iterable containing the inherited parameter values.
87-
This only has an effect if used with ``inherit_params=True``.
88191
192+
Running the above test will cause the :func:`__init__` method from :class:`EchoBaseTest` to throw an error indicating that the variable ``what`` has not been set.
89193

194+
:param types: the supported types for the variable.
195+
:param value: the default value assigned to the variable. If no value is provided, the variable is set as ``required``.
196+
:param field: the field validator to be used for this variable.
197+
If no field argument is provided, it defaults to
198+
:class:`reframe.core.fields.TypedField`.
199+
Note that the field validator provided by this argument must derive from
200+
:class:`reframe.core.fields.Field`.
90201

91202

92203
Environments and Systems

reframe/core/meta.py

Lines changed: 69 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -9,29 +9,69 @@
99

1010

1111
from reframe.core.exceptions import ReframeSyntaxError
12+
import reframe.core.namespaces as namespaces
1213
import reframe.core.parameters as parameters
14+
import reframe.core.variables as variables
1315

1416

1517
class RegressionTestMeta(type):
18+
19+
class MetaNamespace(namespaces.LocalNamespace):
20+
'''Custom namespace to control the cls attribute assignment.'''
21+
def __setitem__(self, key, value):
22+
if isinstance(value, variables.VarDirective):
23+
# Insert the attribute in the variable namespace
24+
self['_rfm_local_var_space'][key] = value
25+
elif isinstance(value, parameters.TestParam):
26+
# Insert the attribute in the parameter namespace
27+
self['_rfm_local_param_space'][key] = value
28+
else:
29+
super().__setitem__(key, value)
30+
1631
@classmethod
17-
def __prepare__(cls, name, bases, **kwargs):
32+
def __prepare__(metacls, name, bases, **kwargs):
1833
namespace = super().__prepare__(name, bases, **kwargs)
1934

2035
# Regression test parameter space defined at the class level
21-
local_param_space = parameters.LocalParamSpace()
36+
local_param_space = namespaces.LocalNamespace()
2237
namespace['_rfm_local_param_space'] = local_param_space
2338

24-
# Directive to add a regression test parameter directly in the
25-
# class body as: `parameter('P0', 0,1,2,3)`.
26-
namespace['parameter'] = local_param_space.add_param
39+
# Directive to insert a regression test parameter directly in the
40+
# class body as: `P0 = parameter([0,1,2,3])`.
41+
namespace['parameter'] = parameters.TestParam
42+
43+
# Regression test var space defined at the class level
44+
local_var_space = namespaces.LocalNamespace()
45+
namespace['_rfm_local_var_space'] = local_var_space
2746

28-
return namespace
47+
# Directives to add/modify a regression test variable
48+
namespace['variable'] = variables.TestVar
49+
namespace['required'] = variables.UndefineVar()
50+
return metacls.MetaNamespace(namespace)
51+
52+
def __new__(metacls, name, bases, namespace, **kwargs):
53+
return super().__new__(metacls, name, bases, dict(namespace), **kwargs)
2954

3055
def __init__(cls, name, bases, namespace, **kwargs):
3156
super().__init__(name, bases, namespace, **kwargs)
3257

33-
# Build the regression test parameter space
34-
cls._rfm_param_space = parameters.ParamSpace(cls)
58+
# Create a set with the attribute names already in use.
59+
cls._rfm_dir = set()
60+
for base in bases:
61+
if hasattr(base, '_rfm_dir'):
62+
cls._rfm_dir.update(base._rfm_dir)
63+
64+
used_attribute_names = set(cls._rfm_dir)
65+
66+
# Build the var space and extend the target namespace
67+
variables.VarSpace(cls, used_attribute_names)
68+
used_attribute_names.update(cls._rfm_var_space.vars)
69+
70+
# Build the parameter space
71+
parameters.ParamSpace(cls, used_attribute_names)
72+
73+
# Update used names set with the local __dict__
74+
cls._rfm_dir.update(cls.__dict__)
3575

3676
# Set up the hooks for the pipeline stages based on the _rfm_attach
3777
# attribute; all dependencies will be resolved first in the post-setup
@@ -100,6 +140,27 @@ def __call__(cls, *args, **kwargs):
100140
obj.__init__(*args, **kwargs)
101141
return obj
102142

143+
def __getattribute__(cls, name):
144+
''' Attribute lookup method for the MetaNamespace.
145+
146+
This metaclass implements a custom namespace, where built-in `variable`
147+
and `parameter` types are stored in their own sub-namespaces (see
148+
:class:`reframe.core.meta.RegressionTestMeta.MetaNamespace`).
149+
This method will perform an attribute lookup on these sub-namespaces if
150+
a call to the default `__getattribute__` method fails to retrieve the
151+
requested class attribute.
152+
'''
153+
try:
154+
return super().__getattribute__(name)
155+
except AttributeError:
156+
try:
157+
return cls._rfm_local_var_space[name]
158+
except KeyError:
159+
try:
160+
return cls._rfm_local_param_space[name]
161+
except KeyError:
162+
return super().__getattr__(name)
163+
103164
@property
104165
def param_space(cls):
105166
# Make the parameter space available as read-only

0 commit comments

Comments
 (0)