Skip to content

Commit a728dd6

Browse files
committed
Introduce variable aliases
This practically extends on the field aliasing that we used to have and brings the same concept to variables. Users can now define a variable as an alias of another one. This is quite useful for transitional periods and deprecations when a variable is renamed. This commit also introduces implementation improvements on how variables are inherited.
1 parent 425a14d commit a728dd6

File tree

3 files changed

+156
-34
lines changed

3 files changed

+156
-34
lines changed

reframe/core/meta.py

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -140,11 +140,9 @@ def __getitem__(self, key):
140140
# available in the current class' namespace.
141141
for b in self['_rfm_bases']:
142142
if key in b._rfm_var_space:
143-
# Store a deep-copy of the variable's
144-
# value and return.
145-
v = b._rfm_var_space[key].default_value
143+
v = variables.ShadowVar(b._rfm_var_space[key])
146144
self._namespace[key] = v
147-
return self._namespace[key]
145+
return v
148146

149147
# If 'key' is neither a variable nor a parameter,
150148
# raise the exception from the base __getitem__.

reframe/core/variables.py

Lines changed: 107 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -172,16 +172,40 @@ class MyRequiredTest(HelloTest):
172172
the field validator.
173173
:returns: A new test variable.
174174
175-
.. versionadded:: 3.10.2
175+
.. version added:: 3.10.2
176176
The ``loggable`` argument is added.
177177
178178
'''
179179

180-
__slots__ = ('_default_value', '_field', '_loggable', '_name')
180+
# NOTE: We can't use truly private fields in __slots__, because
181+
# __setattr__() will be called with their mangled name and we cannot match
182+
# them in the __slots__ with making implementation-defined assumptions
183+
# about the mangled name. So we just add the `_p_` prefix for "private"
184+
185+
__slots__ = ('_p_default_value', '_p_field',
186+
'_loggable', '_name', '_target')
187+
188+
__mutable_props = ('_default_value',)
181189

182190
def __init__(self, *args, **kwargs):
183191
field_type = kwargs.pop('field', fields.TypedField)
184-
self._default_value = kwargs.pop('value', Undefined)
192+
alias = kwargs.pop('alias', None)
193+
194+
if alias and not isinstance(alias, TestVar):
195+
raise TypeError(f"'alias' must refer to a variable; "
196+
f"found {type(alias).__name__!r}")
197+
198+
if alias and 'field' in kwargs:
199+
raise ValueError(f"'field' cannot be set for an alias variable")
200+
201+
if alias and 'value' in kwargs:
202+
raise ValueError('alias variables do not accept default values')
203+
204+
if alias:
205+
self._p_default_value = alias._default_value
206+
else:
207+
self._p_default_value = kwargs.pop('value', Undefined)
208+
185209
self._loggable = kwargs.pop('loggable', False)
186210

187211
if not issubclass(field_type, fields.Field):
@@ -190,16 +214,22 @@ def __init__(self, *args, **kwargs):
190214
f'{fields.Field.__qualname__}'
191215
)
192216

193-
self._field = field_type(*args, **kwargs)
217+
if alias:
218+
self._p_field = alias._field
219+
else:
220+
self._p_field = field_type(*args, **kwargs)
221+
222+
self._target = alias
194223

195224
@classmethod
196225
def create_deprecated(cls, var, message,
197226
kind=DEPRECATE_RDWR, from_version='0.0.0'):
198227
ret = TestVar.__new__(TestVar)
199-
ret._field = fields.DeprecatedField(var.field, message,
200-
kind, from_version)
201-
ret._default_value = var._default_value
228+
ret._p_field = fields.DeprecatedField(var.field, message,
229+
kind, from_version)
230+
ret._p_default_value = var._default_value
202231
ret._loggable = var._loggable
232+
ret._target = var._target
203233
return ret
204234

205235
def _check_deprecation(self, kind):
@@ -217,21 +247,23 @@ def undefine(self):
217247
self._default_value = Undefined
218248

219249
def define(self, value):
220-
if value != self._default_value:
221-
# We only issue a deprecation warning if the write attempt changes
222-
# the value. This is a workaround to the fact that if a variable
223-
# defined in parent classes is accessed by the current class, then
224-
# the definition of the variable is "copied" in the class body as
225-
# an assignment (see `MetaNamespace.__getitem__()`). The
226-
# `VarSpace.extend()` method then checks all local class body
227-
# assignments and if they refer to a variable (inherited or not),
228-
# they call `define()` on it. So, practically, in this case, the
229-
# `_default_value` is set redundantly once per class in the
230-
# hierarchy.
231-
self._check_deprecation(DEPRECATE_WR)
232-
250+
self._check_deprecation(DEPRECATE_WR)
233251
self._default_value = value
234252

253+
@property
254+
def _default_value(self):
255+
if self._target:
256+
return self._target._default_value
257+
else:
258+
return self._p_default_value
259+
260+
@_default_value.setter
261+
def _default_value(self, value):
262+
if self._target:
263+
self._target._default_value = value
264+
else:
265+
self._p_default_value = value
266+
235267
@property
236268
def default_value(self):
237269
# Variables must be returned by-value to prevent an instance from
@@ -240,6 +272,13 @@ def default_value(self):
240272
self._check_deprecation(DEPRECATE_RD)
241273
return copy.deepcopy(self._default_value)
242274

275+
@property
276+
def _field(self):
277+
if self._target:
278+
return self._target._field
279+
else:
280+
return self._p_field
281+
243282
@property
244283
def field(self):
245284
return self._field
@@ -253,14 +292,14 @@ def __set_name__(self, owner, name):
253292

254293
def __setattr__(self, name, value):
255294
'''Set any additional variable attribute into the default value.'''
256-
if name in self.__slots__:
295+
if name in self.__slots__ or name in self.__mutable_props:
257296
super().__setattr__(name, value)
258297
else:
259298
setattr(self._default_value, name, value)
260299

261300
def __getattr__(self, name):
262301
'''Attribute lookup into the variable's value.'''
263-
def_val = self.__getattribute__('_default_value')
302+
def_val = self.__getattribute__('_p_default_value')
264303

265304
# NOTE: This if below is necessary to avoid breaking the deepcopy
266305
# of instances of this class. Without it, a deepcopy of instances of
@@ -284,12 +323,22 @@ def _check_is_defined(self):
284323
f'variable {self._name!r} is not assigned a value'
285324
)
286325

287-
def __repr__(self):
326+
def __str__(self):
288327
self._check_is_defined()
289-
return repr(self._default_value)
328+
return str(self._default_value)
290329

291-
def __str__(self):
292-
return self.__repr__()
330+
def __repr__(self):
331+
try:
332+
name = self.name
333+
except AttributeError:
334+
name = '<undef>'
335+
336+
if self.is_defined():
337+
value = self._default_value
338+
else:
339+
value = '<undef>'
340+
341+
return f'TestVar(name={name!r}, value={value!r})'
293342

294343
def __bytes__(self):
295344
self._check_is_defined()
@@ -593,6 +642,30 @@ def __ceil__(self):
593642
return math.ceil(self._default_value)
594643

595644

645+
class ShadowVar(TestVar):
646+
'''A shadow instance of another variable.
647+
648+
This is essentially a fully-fledged shallow copy of another variable. It
649+
is used during the construction of the class namespace to bring in scope a
650+
requested variable that is defined in a base class (see
651+
`MetaNamespace.__getitem__()`)
652+
653+
We could not simply create a reference of the original variable in the
654+
current namespace, because we need a mechanism to differentiate the
655+
lowered variable from any redefinition, which is illegal.
656+
657+
Also, we don't need a deep copy, since the shadow variable will replace
658+
the original variable in the newly constructed `VarSpace`.
659+
660+
'''
661+
662+
def __init__(self, other):
663+
for name in self.__slots__:
664+
setattr(self, name, getattr(other, name))
665+
666+
self._check_deprecation(DEPRECATE_RD)
667+
668+
596669
class VarSpace(namespaces.Namespace):
597670
'''Variable space of a regression test.
598671
@@ -645,8 +718,7 @@ def extend(self, cls):
645718
is disallowed.
646719
'''
647720
local_varspace = getattr(cls, self.local_namespace_name, False)
648-
while local_varspace:
649-
key, var = local_varspace.popitem()
721+
for key, var in local_varspace.items():
650722
if isinstance(var, TestVar):
651723
# Disable redeclaring a variable
652724
if key in self.vars:
@@ -657,13 +729,19 @@ def extend(self, cls):
657729
# Add a new var
658730
self.vars[key] = var
659731

732+
local_varspace.clear()
733+
660734
# If any previously declared variable was defined in the class body
661735
# by directly assigning it a value, retrieve this value from the class
662736
# namespace and update it into the variable space.
663737
_assigned_vars = set()
664738
for key, value in cls.__dict__.items():
665739
if key in self.vars:
666-
self.vars[key].define(value)
740+
if isinstance(value, ShadowVar):
741+
self.vars[key] = value
742+
else:
743+
self.vars[key].define(value)
744+
667745
_assigned_vars.add(key)
668746
elif value is Undefined:
669747
# Cannot be set as Undefined if not a variable

unittests/test_variables.py

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -219,7 +219,7 @@ class Foo(rfm.RegressionMixin):
219219
with pytest.raises(ReframeSyntaxError):
220220
class Foo(rfm.RegressionMixin):
221221
my_var = variable(int)
222-
x = f'accessing {my_var!r} fails because its value is not set.'
222+
x = f'accessing {my_var} fails because its value is not set.'
223223

224224

225225
def test_var_space_is_read_only():
@@ -486,3 +486,49 @@ class D(A):
486486
c = a.y
487487
with pytest.warns(ReframeDeprecationWarning):
488488
a.y = 10
489+
490+
491+
def test_var_aliases():
492+
with pytest.raises(ValueError,
493+
match=r'alias variables do not accept default values'):
494+
class T(rfm.RegressionMixin):
495+
x = variable(int, value=1)
496+
y = variable(alias=x, value=2)
497+
498+
class T(rfm.RegressionMixin):
499+
x = variable(int, value=1)
500+
y = variable(alias=x)
501+
z = variable(alias=y)
502+
503+
t = T()
504+
assert t.y == 1
505+
assert t.z == 1
506+
507+
t.x = 4
508+
assert t.y == 4
509+
assert t.z == 4
510+
511+
t.y = 5
512+
assert t.x == 5
513+
assert t.z == 5
514+
515+
t.z = 10
516+
assert t.x == 10
517+
assert t.y == 10
518+
519+
# Test inheritance
520+
521+
class T(rfm.RegressionMixin):
522+
x = variable(int, value=1)
523+
524+
class S(T):
525+
y = variable(alias=x)
526+
527+
s = S()
528+
assert s.y == 1
529+
530+
s.y = 2
531+
assert s.x == 2
532+
533+
s.x = 3
534+
assert s.y == 3

0 commit comments

Comments
 (0)