Skip to content

Commit f19875f

Browse files
authored
Merge pull request #2647 from vkarak/feat/variable-aliases
[feat] Introduce variable aliases
2 parents 86e7d4a + be6e26f commit f19875f

File tree

5 files changed

+221
-42
lines changed

5 files changed

+221
-42
lines changed

docs/conf.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,16 @@
3838
import reframe # noqa: F401, F403
3939
import reframe.utility.osext as osext # noqa: F401, F403
4040

41+
# Sphinx erroneously uses `repr()` to display values and not the
42+
# human-readable `str()`. For this reason we define the following variable in
43+
# the `reframe` module so that objects can redirect to their `str()` method
44+
# from `repr()` if they are invoked in the context of Sphinx when building the
45+
# documentation. See discussion and the related workaround here:
46+
#
47+
# https://github.com/sphinx-doc/sphinx/issues/3857
48+
49+
reframe.__build_docs__ = True
50+
4151
# -- General configuration ------------------------------------------------
4252

4353
# If your documentation needs a minimal Sphinx version, state it here.

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: 134 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -158,13 +158,24 @@ class MyRequiredTest(HelloTest):
158158
from :class:`EchoBaseTest` to throw an error indicating that the variable
159159
``what`` has not been set.
160160
161+
Finally, variables may alias each other. If a variable is an alias of
162+
another one it behaves in the exact same way as its target. If a change is
163+
made to the target variable, this is reflected to the alias and vice
164+
versa. However, alias variables are independently loggable: an alias may
165+
be logged but not its target and vice versa. Aliased variables are useful
166+
when you want to rename a variable and you want to keep the old one for
167+
compatibility reasons.
168+
161169
:param `types`: the supported types for the variable.
162170
:param value: the default value assigned to the variable. If no value is
163171
provided, the variable is set as ``required``.
164172
:param field: the field validator to be used for this variable. If no
165173
field argument is provided, it defaults to
166174
:attr:`reframe.core.fields.TypedField`. The provided field validator
167175
by this argument must derive from :attr:`reframe.core.fields.Field`.
176+
:param alias: the target variable if this variable is an alias. This must
177+
refer to an already declared variable and neither default value nor a
178+
field can be specified for an alias variable.
168179
:param loggable: Mark this variable as loggable. If :obj:`True`, this
169180
variable will become a log record attribute under the name
170181
``check_NAME``, where ``NAME`` is the name of the variable.
@@ -175,37 +186,71 @@ class MyRequiredTest(HelloTest):
175186
.. versionadded:: 3.10.2
176187
The ``loggable`` argument is added.
177188
189+
.. versionadded:: 4.0.0
190+
Alias variable are introduced.
191+
178192
'''
179193

180-
__slots__ = ('_default_value', '_field', '_loggable', '_name')
194+
# NOTE: We can't use truly private fields in `__slots__`, because
195+
# `__setattr__()` will be called with their mangled name and we cannot
196+
# match them in the `__slots__` without making implementation-defined
197+
# assumptions about the mangled name. So we just add the `_p_` prefix for
198+
# to denote the "private" fields.
199+
200+
__slots__ = ('_p_default_value', '_p_field',
201+
'_loggable', '_name', '_target')
202+
203+
__mutable_props = ('_default_value',)
181204

182205
def __init__(self, *args, **kwargs):
206+
alias = kwargs.pop('alias', None)
207+
if alias is not None and 'field' in kwargs:
208+
raise ValueError(f"'field' cannot be set for an alias variable")
209+
210+
if alias is not None and 'value' in kwargs:
211+
raise ValueError('alias variables do not accept default values')
212+
213+
if alias is not None and not isinstance(alias, TestVar):
214+
raise TypeError(f"'alias' must refer to a variable; "
215+
f"found {type(alias).__name__!r}")
216+
183217
field_type = kwargs.pop('field', fields.TypedField)
184-
self._default_value = kwargs.pop('value', Undefined)
185-
self._loggable = kwargs.pop('loggable', False)
218+
if alias is not None:
219+
self._p_default_value = alias._default_value
220+
else:
221+
self._p_default_value = kwargs.pop('value', Undefined)
186222

223+
self._loggable = kwargs.pop('loggable', False)
187224
if not issubclass(field_type, fields.Field):
188225
raise TypeError(
189226
f'field {field_type!r} is not derived from '
190227
f'{fields.Field.__qualname__}'
191228
)
192229

193-
self._field = field_type(*args, **kwargs)
230+
if alias is not None:
231+
self._p_field = alias._field
232+
else:
233+
self._p_field = field_type(*args, **kwargs)
234+
235+
self._target = alias
194236

195237
@classmethod
196238
def create_deprecated(cls, var, message,
197239
kind=DEPRECATE_RDWR, from_version='0.0.0'):
198240
ret = TestVar.__new__(TestVar)
199-
ret._field = fields.DeprecatedField(var.field, message,
200-
kind, from_version)
201-
ret._default_value = var._default_value
241+
ret._p_field = fields.DeprecatedField(var.field, message,
242+
kind, from_version)
243+
ret._p_default_value = var._default_value
202244
ret._loggable = var._loggable
245+
ret._target = var._target
203246
return ret
204247

205-
def _check_deprecation(self, kind):
206-
if isinstance(self.field, fields.DeprecatedField):
207-
if self.field.op & kind:
208-
user_deprecation_warning(self.field.message)
248+
def _warn_deprecation(self, kind):
249+
if self.is_deprecated() and self.field.op & kind:
250+
user_deprecation_warning(self.field.message)
251+
252+
def is_deprecated(self):
253+
return isinstance(self._p_field, fields.DeprecatedField)
209254

210255
def is_loggable(self):
211256
return self._loggable
@@ -217,29 +262,41 @@ def undefine(self):
217262
self._default_value = Undefined
218263

219264
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-
265+
self._warn_deprecation(DEPRECATE_WR)
233266
self._default_value = value
234267

268+
@property
269+
def _default_value(self):
270+
if self._target:
271+
return self._target._default_value
272+
else:
273+
return self._p_default_value
274+
275+
@_default_value.setter
276+
def _default_value(self, value):
277+
if self._target:
278+
self._target._default_value = value
279+
else:
280+
self._p_default_value = value
281+
235282
@property
236283
def default_value(self):
237284
# Variables must be returned by-value to prevent an instance from
238285
# modifying the class variable space.
239286
self._check_is_defined()
240-
self._check_deprecation(DEPRECATE_RD)
287+
self._warn_deprecation(DEPRECATE_RD)
241288
return copy.deepcopy(self._default_value)
242289

290+
@property
291+
def _field(self):
292+
if self.is_deprecated():
293+
return self._p_field
294+
295+
if self._target:
296+
return self._target._field
297+
else:
298+
return self._p_field
299+
243300
@property
244301
def field(self):
245302
return self._field
@@ -253,14 +310,14 @@ def __set_name__(self, owner, name):
253310

254311
def __setattr__(self, name, value):
255312
'''Set any additional variable attribute into the default value.'''
256-
if name in self.__slots__:
313+
if name in self.__slots__ or name in self.__mutable_props:
257314
super().__setattr__(name, value)
258315
else:
259316
setattr(self._default_value, name, value)
260317

261318
def __getattr__(self, name):
262319
'''Attribute lookup into the variable's value.'''
263-
def_val = self.__getattribute__('_default_value')
320+
def_val = self.__getattribute__('_p_default_value')
264321

265322
# NOTE: This if below is necessary to avoid breaking the deepcopy
266323
# of instances of this class. Without it, a deepcopy of instances of
@@ -284,12 +341,26 @@ def _check_is_defined(self):
284341
f'variable {self._name!r} is not assigned a value'
285342
)
286343

287-
def __repr__(self):
344+
def __str__(self):
288345
self._check_is_defined()
289-
return repr(self._default_value)
346+
return str(self._default_value)
290347

291-
def __str__(self):
292-
return self.__repr__()
348+
def __repr__(self):
349+
import reframe
350+
if hasattr(reframe, '__build_docs__'):
351+
return str(self)
352+
353+
try:
354+
name = self.name
355+
except AttributeError:
356+
name = '<undef>'
357+
358+
if self.is_defined():
359+
value = self._default_value
360+
else:
361+
value = '<undef>'
362+
363+
return f'TestVar(name={name!r}, value={value!r})'
293364

294365
def __bytes__(self):
295366
self._check_is_defined()
@@ -593,6 +664,30 @@ def __ceil__(self):
593664
return math.ceil(self._default_value)
594665

595666

667+
class ShadowVar(TestVar):
668+
'''A shadow instance of another variable.
669+
670+
This is essentially a fully-fledged shallow copy of another variable. It
671+
is used during the construction of the class namespace to bring in scope a
672+
requested variable that is defined in a base class (see
673+
`MetaNamespace.__getitem__()`)
674+
675+
We could not simply create a reference of the original variable in the
676+
current namespace, because we need a mechanism to differentiate the
677+
lowered variable from any redefinition, which is illegal.
678+
679+
Also, we don't need a deep copy, since the shadow variable will replace
680+
the original variable in the newly constructed `VarSpace`.
681+
682+
'''
683+
684+
def __init__(self, other):
685+
for name in self.__slots__:
686+
setattr(self, name, getattr(other, name))
687+
688+
self._warn_deprecation(DEPRECATE_RD)
689+
690+
596691
class VarSpace(namespaces.Namespace):
597692
'''Variable space of a regression test.
598693
@@ -645,8 +740,7 @@ def extend(self, cls):
645740
is disallowed.
646741
'''
647742
local_varspace = getattr(cls, self.local_namespace_name, False)
648-
while local_varspace:
649-
key, var = local_varspace.popitem()
743+
for key, var in local_varspace.items():
650744
if isinstance(var, TestVar):
651745
# Disable redeclaring a variable
652746
if key in self.vars:
@@ -657,13 +751,19 @@ def extend(self, cls):
657751
# Add a new var
658752
self.vars[key] = var
659753

754+
local_varspace.clear()
755+
660756
# If any previously declared variable was defined in the class body
661757
# by directly assigning it a value, retrieve this value from the class
662758
# namespace and update it into the variable space.
663759
_assigned_vars = set()
664760
for key, value in cls.__dict__.items():
665761
if key in self.vars:
666-
self.vars[key].define(value)
762+
if isinstance(value, ShadowVar):
763+
self.vars[key] = value
764+
else:
765+
self.vars[key].define(value)
766+
667767
_assigned_vars.add(key)
668768
elif value is Undefined:
669769
# Cannot be set as Undefined if not a variable

unittests/test_logging.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ def fake_check():
2929
class _FakeCheck(rfm.RegressionTest):
3030
param = parameter(range(3), loggable=True, fmt=lambda x: 10*x)
3131
custom = variable(str, value='hello extras', loggable=True)
32+
custom2 = variable(alias=custom)
3233
custom_list = variable(list,
3334
value=['custom', 3.0, ['hello', 'world']],
3435
loggable=True)
@@ -161,13 +162,13 @@ def test_logger_levels(logfile, logger_with_check):
161162

162163
def test_logger_loggable_attributes(logfile, logger_with_check):
163164
formatter = rlog.RFC3339Formatter(
164-
'%(check_custom)s|%(check_custom_list)s|'
165+
'%(check_custom)s|%(check_custom2)s|%(check_custom_list)s|'
165166
'%(check_foo)s|%(check_custom_dict)s|%(check_param)s|%(check_x)s'
166167
)
167168
logger_with_check.logger.handlers[0].setFormatter(formatter)
168169
logger_with_check.info('xxx')
169170
assert _pattern_in_logfile(
170-
r'hello extras\|custom,3.0,\["hello", "world"\]\|null\|'
171+
r'hello extras\|null\|custom,3.0,\["hello", "world"\]\|null\|'
171172
r'{"a": 1, "b": 2}\|10\|null', logfile
172173
)
173174

0 commit comments

Comments
 (0)