Skip to content

[enhancement] Improve programming error messages #3489

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 2 commits into from
Jun 18, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions reframe/core/decorators.py
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ def instantiate_all(self, reset_sysenv=0, external_vars=None):
getlogger().warning(
f"skipping test {test.__qualname__!r}: "
f"{what(*exc_info)} "
f"(rerun with '-v' for more information)"
f"(rerun with '-v' for a backtrace)"
)
getlogger().verbose(traceback.format_exc())

Expand Down Expand Up @@ -166,8 +166,9 @@ def _register_test(cls, *args, **kwargs):

def _validate_test(cls):
if not issubclass(cls, RegressionTest):
raise ReframeSyntaxError('the decorated class must be a '
'subclass of RegressionTest')
raise ReframeSyntaxError(f"the decorated class {cls.__name__!r} "
"is not a subclass of 'RegressionTest'",
with_code_context=True)

if (cls.is_abstract()):
getlogger().warning(
Expand Down
86 changes: 80 additions & 6 deletions reframe/core/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import inspect
import jsonschema
import os
import sys

import reframe
import reframe.utility as utility
Expand Down Expand Up @@ -78,9 +79,70 @@ class ReframeFatalError(ReframeBaseError):
'''


class SourceCodeInfo:
def __init__(self, frame):
self.__filename = frame.filename
self.__lineno = frame.lineno
self.__line = frame.code_context[frame.index]
if sys.version_info >= (3, 11):
self.__pos = frame.positions
else:
self.__pos = None

@property
def filename(self):
return self.__filename

@property
def lineno(self):
return self.__lineno

@property
def line(self):
return self.__line

@property
def columns(self):
if self.__pos is not None:
return self.__pos[2:]

raise NotImplementedError

@property
def lines(self):
if self.__pos is not None:
return self.__pos[:2]

raise NotImplementedError

def __str__(self):
prefix = '| '
ret = f'{prefix}{self.filename}:{self.lineno}\n{prefix}{self.line}'
if self.__pos is not None:
# Add precise column information
l_start, l_end = self.lines
c_start, c_end = self.columns
if l_start != l_end:
# If the code segment is multiline, the columns are not
# precise. So we use the full line as span.
c_end = len(self.line) - 1

span = c_end - c_start
ret += f'{prefix}{" "*c_start}^{"~"*(span-1)}'

return ret


class ReframeSyntaxError(ReframeError):
'''Raised when the syntax of regression tests is incorrect.'''

def __init__(self, *args, with_code_context=False):
super().__init__(*args)
if with_code_context:
frame = user_frame()
if frame:
self._message += f'\n{SourceCodeInfo(frame)}'


class RegressionTestLoadError(ReframeError):
'''Raised when the regression test cannot be loaded.'''
Expand Down Expand Up @@ -285,7 +347,21 @@ class SkipTestError(ReframeError):
'''Raised when a test needs to be skipped.'''


def user_frame(exc_type, exc_value, tb):
def user_frame():
'''Return the first user frame as a :py:class:`FrameInfo` object.

If no user frame can be found, :obj:`None` is returned
'''

for finfo in inspect.stack():
relpath = os.path.relpath(finfo.filename, reframe.INSTALL_PREFIX)
if relpath.split(os.sep)[0] != 'reframe':
return finfo

return None


def user_frame_from_tb(exc_type, exc_value, tb):
'''Return a user frame from the exception's traceback.

As user frame is considered the first frame that is outside from
Expand Down Expand Up @@ -321,7 +397,7 @@ def is_user_error(exc_type, exc_value, tb):
:py:class:`ValueError` and the exception isthrown from user context.
'''

frame = user_frame(exc_type, exc_value, tb)
frame = user_frame_from_tb(exc_type, exc_value, tb)
if frame is None:
return False

Expand Down Expand Up @@ -364,10 +440,8 @@ def what(exc_type, exc_value, tb):
elif isinstance(exc_value, AbortTaskError):
reason = f'aborted due to {type(exc_value.__cause__).__name__}'
elif is_user_error(exc_type, exc_value, tb):
frame = user_frame(exc_type, exc_value, tb)
relpath = os.path.relpath(frame.filename)
source = ''.join(frame.code_context or '<n/a>')
reason += f': {relpath}:{frame.lineno}: {exc_value}\n{source}'
source = SourceCodeInfo(user_frame_from_tb(exc_type, exc_value, tb))
reason += f': {exc_value}:\n{source}'
else:
if str(exc_value):
reason += f': {exc_value}'
Expand Down
40 changes: 32 additions & 8 deletions reframe/core/fixtures.py
Original file line number Diff line number Diff line change
Expand Up @@ -181,8 +181,12 @@ def add(self, fixture, variant_num, parent_test):
parent_test.valid_prog_environs
)
except AttributeError as e:
msg = e.args[0] + f' in test {parent_test.display_name!r}'
raise ReframeSyntaxError(msg) from None
# Catch `AttributeError` that is raised when `valid_systems` or
# `valid_prog_environs` are not set
raise ReframeSyntaxError(
f'could not instantiate fixture {fixture.name!r} '
f'in {parent_test.display_name!r}: {e}'
) from None

# Return if there are no valid system/environment combinations
if not valid_sysenv:
Expand Down Expand Up @@ -313,7 +317,7 @@ def instantiate_all(self):
getlogger().warning(
f"skipping fixture {name!r}: "
f"{what(*exc_info)} "
f"(rerun with '-v' for more information)"
f"(rerun with '-v' for a backtrace)"
)
getlogger().verbose(traceback.format_exc())
else:
Expand Down Expand Up @@ -817,6 +821,20 @@ def __init__(self, cls, *, scope='test', action='fork', variants='all',
# Store the variables dict
self._variables = variables

def __set_name__(self, owner, name):
self._name = name

def __rfm_set_owner__(self, owner):
self._owner = owner

@property
def name(self):
return self._name

@property
def owner(self):
return self._owner

@property
def cls(self):
'''The underlying RegressionTest class.'''
Expand Down Expand Up @@ -920,10 +938,12 @@ def join(self, other, cls):
'''
for key, value in other.fixtures.items():
if key in self.fixtures:
fixt_self = self.fixtures[key]
raise ReframeSyntaxError(
f'fixture space conflict: '
f'fixture {key!r} is defined in more than '
f'one base class of {cls.__qualname__!r}'
'multiple inheritance of fixtures is not allowed: '
f'fixture {fixt_self.name!r} is inherited from '
f'{fixt_self.owner!r} but attempted to inherit it also '
f'from {value.owner!r}', with_code_context=True
)

self.fixtures[key] = value
Expand All @@ -940,9 +960,13 @@ def extend(self, cls):
# changed using the `x = fixture(...)` syntax.
for key in self.fixtures:
if key in cls.__dict__:
fixt = self.fixtures[key]
raise ReframeSyntaxError(
f'fixture {key!r} can only be redefined through the '
f'fixture built-in'
f"name {key!r} conflicts with fixture {fixt.name!r} "
f"defined in {fixt.owner!r}: if you want to override the "
"fixture definition, consider redefining it using the "
"'fixture()' builtin",
with_code_context=True
)

def inject(self, obj, cls=None, fixtures_index=None):
Expand Down
Loading
Loading