Skip to content
79 changes: 57 additions & 22 deletions reframe/core/meta.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,11 +29,11 @@ class RegressionTestMeta(type):
class MetaNamespace(namespaces.LocalNamespace):
'''Custom namespace to control the cls attribute assignment.

Regular Python class attributes can be overriden by either
Regular Python class attributes can be overridden by either
parameters or variables respecting the order of execution.
A variable or a parameter may not be declared more than once in the
same class body. Overriding a variable with a parameter or the other
way around has an undefined behaviour. A variable's value may be
way around has an undefined behavior. A variable's value may be
updated multiple times within the same class body. A parameter's
value may not be updated more than once within the same class body.
'''
Expand Down Expand Up @@ -349,7 +349,7 @@ def __call__(cls, *args, **kwargs):
to perform specific reframe-internal actions. This gives extra control
over the class instantiation process, allowing reframe to instantiate
the regression test class differently if this class was registered or
not (e.g. when deep-copying a regression test object). These interal
not (e.g. when deep-copying a regression test object). These internal
arguments must be intercepted before the object initialization, since
these would otherwise affect the __init__ method's signature, and these
internal mechanisms must be fully transparent to the user.
Expand All @@ -363,38 +363,73 @@ def __call__(cls, *args, **kwargs):
obj.__init__(*args, **kwargs)
return obj

def __getattribute__(cls, name):
'''Attribute lookup method for custom class attributes.

ReFrame test variables are descriptors injected at the class level.
If a variable descriptor has already been injected into the class,
do not return the descriptor object and return the default value
associated with that variable instead.

.. warning::
.. versionchanged:: 3.7.0
Prior versions exposed the variable descriptor object if this
was already present in the class, instead of returning the
variable's default value.
'''

try:
var_space = super().__getattribute__('_rfm_var_space')
except AttributeError:
var_space = None

# If the variable is already injected, delegate lookup to __getattr__.
if var_space and name in var_space.injected_vars:
raise AttributeError('delegate variable lookup to __getattr__')

# Default back to the base method if no special treatment required.
return super().__getattribute__(name)

def __getattr__(cls, name):
'''Attribute lookup method for the MetaNamespace.

This metaclass uses a custom namespace, where ``variable`` built-in
and ``parameter`` types are stored in their own sub-namespaces (see
:class:`reframe.core.meta.RegressionTestMeta.MetaNamespace`). This
method will perform an attribute lookup on these sub-namespaces if a
call to the default :func:`__getattribute__` method fails to retrieve
the requested class attribute.
'''Backup attribute lookup method into custom namespaces.

Some ReFrame built-in types are stored under their own sub-namespaces.
This method will perform an attribute lookup on these sub-namespaces
if a call to the default :func:`__getattribute__` method fails to
retrieve the requested class attribute.
'''

try:
return cls._rfm_var_space.vars[name]
var_space = super().__getattribute__('_rfm_var_space')
return var_space.vars[name]
except AttributeError:
'''Catch early access attempt to the variable space.'''
except KeyError:
try:
return cls._rfm_param_space.params[name]
except KeyError:
raise AttributeError(
f'class {cls.__qualname__!r} has no attribute {name!r}'
) from None
'''Requested name not in variable space.'''

try:
param_space = super().__getattribute__('_rfm_param_space')
return param_space.params[name]
except AttributeError:
'''Catch early access attempt to the parameter space.'''
except KeyError:
'''Requested name not in parameter space.'''

raise AttributeError(
f'class {cls.__qualname__!r} has no attribute {name!r}'
) from None

def __setattr__(cls, name, value):
'''Handle the special treatment required for variables and parameters.

A variable's default value can be updated when accessed as a regular
class attribute. This behaviour does not apply when the assigned value
class attribute. This behavior does not apply when the assigned value
is a descriptor object. In that case, the task of setting the value is
delegated to the base :func:`__setattr__` (this is to comply with
standard Python behaviour). However, since the variables are already
standard Python behavior). However, since the variables are already
descriptors which are injected during class instantiation, we disallow
any attempt to override this descriptor (since it would be silently
re-overriden in any case).
re-overridden in any case).

Altering the value of a parameter when accessed as a class attribute
is not allowed. This would break the parameter space internals.
Expand Down Expand Up @@ -438,7 +473,7 @@ def is_abstract(cls):
This is the case when some parameters are undefined, which results in
the length of the parameter space being 0.

:return: bool indicating wheteher the test has undefined parameters.
:return: bool indicating whether the test has undefined parameters.

:meta private:
'''
Expand Down
4 changes: 4 additions & 0 deletions reframe/core/variables.py
Original file line number Diff line number Diff line change
Expand Up @@ -549,3 +549,7 @@ def inject(self, obj, cls):
@property
def vars(self):
return self._namespace

@property
def injected_vars(self):
return self._injected_vars
16 changes: 16 additions & 0 deletions unittests/test_meta.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,22 @@ class Foo(metaclass=meta.RegressionTestMeta):
yield Foo


def test_class_attr_access():
'''Catch access to sub-namespaces when they do not exist.'''
def my_test(key):
class MyMeta(meta.RegressionTestMeta):
def __init__(cls, name, bases, namespace, **kwargs):
getattr(cls, f'{key}')

msg = f'has no attribute {key!r}'
with pytest.raises(AttributeError, match=msg):
class Foo(metaclass=MyMeta):
pass

my_test('_rfm_var_space')
my_test('_rfm_param_space')


def test_directives(MyMeta):
'''Test that directives are not available as instance attributes.'''

Expand Down
9 changes: 4 additions & 5 deletions unittests/test_variables.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@
import math

import reframe as rfm
from reframe.core.fields import Field


@pytest.fixture
Expand All @@ -33,10 +32,10 @@ class OneVarTest(NoVarsTest):

def test_custom_variable(OneVarTest):
assert hasattr(OneVarTest, 'foo')
assert not isinstance(OneVarTest.foo, Field)
assert OneVarTest.foo == 10
inst = OneVarTest()
assert hasattr(OneVarTest, 'foo')
assert isinstance(OneVarTest.foo, Field)
assert OneVarTest.foo == 10
assert hasattr(inst, 'foo')
assert inst.foo == 10

Expand Down Expand Up @@ -134,9 +133,9 @@ class MyTest(OneVarTest):

inst = MyTest()
assert hasattr(OneVarTest, 'foo')
assert not isinstance(OneVarTest.foo, Field)
assert OneVarTest.foo == 10
assert hasattr(MyTest, 'foo')
assert isinstance(MyTest.foo, Field)
assert MyTest.foo == 4
assert hasattr(inst, 'foo')
assert inst.foo == 4

Expand Down