Skip to content
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

[feat] Show the last N lines in test failures #3291

Merged
merged 2 commits into from
Oct 30, 2024
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
10 changes: 10 additions & 0 deletions docs/config_reference.rst
Original file line number Diff line number Diff line change
Expand Up @@ -1737,6 +1737,16 @@ General Configuration
.. versionadded:: 3.10.0


.. py:attribute:: general.failure_inspect_lines

:required: No
:default: 10

Number of the last lines of stdout/stderr to be printed in case of test failures.

.. versionadded:: 4.7


.. py:attribute:: general.flex_alloc_strict

:required: No
Expand Down
19 changes: 0 additions & 19 deletions reframe/core/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@
# Base regression exceptions
#

import contextlib
import inspect
import os

Expand Down Expand Up @@ -165,24 +164,6 @@ class CommandLineError(ReframeError):
class BuildError(ReframeError):
'''Raised when a build fails.'''

def __init__(self, stdout, stderr, prefix=None):
super().__init__()
num_lines = 10
prefix = prefix or '.'
lines = [
f'stdout: {stdout!r}, stderr: {stderr!r}',
f'--- {stderr} (first {num_lines} lines) ---'
]
with contextlib.suppress(OSError):
with open(os.path.join(prefix, stderr)) as fp:
for i, line in enumerate(fp):
if i < num_lines:
# Remove trailing '\n'
lines.append(line[:-1])

lines += [f'--- {stderr} --- ']
self._message = '\n'.join(lines)


class SpawnedProcessError(ReframeError):
'''Raised when a spawned OS command has failed.'''
Expand Down
5 changes: 3 additions & 2 deletions reframe/core/pipeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -1928,8 +1928,9 @@ def compile_wait(self):

# We raise a BuildError when we an exit code and it is non zero
if self._build_job.exitcode:
raise BuildError(self._build_job.stdout,
self._build_job.stderr, self._stagedir)
raise BuildError(
f'build job failed with exit code: {self._build_job.exitcode}'
)

with osext.change_dir(self._stagedir):
self.build_system.post_build(self._build_job)
Expand Down
45 changes: 26 additions & 19 deletions reframe/frontend/printer.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@
import reframe.core.logging as logging
import reframe.core.runtime as rt
import reframe.utility.color as color
from reframe.core.exceptions import SanityError
import reframe.utility.osext as osext
from reframe.core.exceptions import BuildError, SanityError
from reframe.core.runtime import runtime
from reframe.frontend.reporting import format_testcase_from_json
from reframe.utility import nodelist_abbrev

Expand Down Expand Up @@ -99,24 +101,19 @@ def __setattr__(self, attr, value):
def failure_report(self, report, rerun_info=True, global_stats=False):
'''Print a failure report'''

def _head_n(filename, prefix, num_lines=10):
def _file_info(filename, prefix):
# filename and prefix are `None` before setup
if filename is None or prefix is None:
return []

num_lines = runtime().get_option('general/0/failure_inspect_lines')
lines = [f'--- {filename} (last {num_lines} lines) ---\n']
try:
with open(os.path.join(prefix, filename)) as fp:
lines = [
f'--- {filename} (first {num_lines} lines) ---'
]
for i, line in enumerate(fp):
if i < num_lines:
# Remove trailing '\n'
lines.append(line.rstrip())

lines += [f'--- {filename} ---']
lines += osext.tail(os.path.join(prefix, filename), num_lines)
except OSError as e:
lines = [f'--- {filename} ({e}) ---']
lines += [f'--- {filename} ({e}) ---']
else:
lines += [f'--- {filename} ---']

return lines

Expand Down Expand Up @@ -144,14 +141,24 @@ def _print_failure_info(rec, runid, total_runs):
f"{rec['system']} -r'")

msg = rec['fail_reason']
if isinstance(rec['fail_info']['exc_value'], SanityError):
lines = [msg]
lines += _head_n(rec['job_stdout'], prefix=rec['stagedir'])
lines += _head_n(rec['job_stderr'], prefix=rec['stagedir'])
msg = '\n'.join(lines)
if isinstance(rec['fail_info']['exc_value'], BuildError):
stdout = rec['build_stdout']
stderr = rec['build_stderr']
print_file_info = True
elif isinstance(rec['fail_info']['exc_value'], SanityError):
stdout = rec['job_stdout']
stderr = rec['job_stderr']
print_file_info = True
else:
print_file_info = False

self.info(f" * Reason: {msg}")
if print_file_info:
lines = [msg + '\n']
lines += _file_info(stdout, prefix=rec['stagedir'])
lines += _file_info(stderr, prefix=rec['stagedir'])
msg = ''.join(lines)

self.info(f" * Reason: {msg}")
tb = ''.join(traceback.format_exception(
*rec['fail_info'].values()))
if rec['fail_severe']:
Expand Down
8 changes: 6 additions & 2 deletions reframe/schemas/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -502,6 +502,8 @@
"clean_stagedir": {"type": "boolean"},
"colorize": {"type": "boolean"},
"compress_report": {"type": "boolean"},
"failure_inspect_lines": {"type": "integer"},
"flex_alloc_strict": {"type": "boolean"},
"generate_file_reports": {"type": "boolean"},
"git_timeout": {"type": "number"},
"keep_stage_files": {"type": "boolean"},
Expand Down Expand Up @@ -570,13 +572,14 @@
"environments/extras": {},
"environments/features": [],
"environments/target_systems": ["*"],
"general/dump_pipeline_progress": false,
"general/pipeline_timeout": 3,
"general/check_search_path": ["${RFM_INSTALL_PREFIX}/checks/"],
"general/check_search_recursive": false,
"general/clean_stagedir": true,
"general/colorize": true,
"general/compress_report": false,
"general/dump_pipeline_progress": false,
"general/failure_inspect_lines": 10,
"general/flex_alloc_strict": false,
"general/generate_file_reports": true,
"general/git_timeout": 5,
"general/keep_stage_files": false,
Expand All @@ -585,6 +588,7 @@
"general/non_default_craype": false,
"general/perf_info_level": "info",
"general/perf_report_spec": "now:now/last:/+job_nodelist+result",
"general/pipeline_timeout": 3,
"general/purge_environment": false,
"general/remote_detect": false,
"general/remote_install": [],
Expand Down
40 changes: 37 additions & 3 deletions reframe/utility/osext.py
Original file line number Diff line number Diff line change
Expand Up @@ -477,7 +477,7 @@ def copytree_virtual(src, dst, file_links=None,
link_targets.add(os.path.abspath(target))

if '.' in file_links or '..' in file_links:
raise ValueError(f"'.' or '..' are not allowed in file_links")
raise ValueError("'.' or '..' are not allowed in file_links")

if not file_links:
ignore = None
Expand Down Expand Up @@ -509,11 +509,13 @@ def rmtree(*args, max_retries=3, **kwargs):
This version of :func:`rmtree` is mostly provided to work around a race
condition between when ``sacct`` reports a job as completed and when the
Slurm epilog runs. See `gh #291
<https://github.com/reframe-hpc/reframe/issues/291>`__ for more information.
<https://github.com/reframe-hpc/reframe/issues/291>`__ for more
information.
Furthermore, it offers a work around for NFS file systems where stale
file handles may be present during the :func:`rmtree` call, causing it to
throw a busy device/resource error. See `gh #712
<https://github.com/reframe-hpc/reframe/issues/712>`__ for more information.
<https://github.com/reframe-hpc/reframe/issues/712>`__ for more
information.

``args`` and ``kwargs`` are passed through to :py:func:`shutil.rmtree`.

Expand Down Expand Up @@ -787,6 +789,38 @@ def concat_files(dst, *files, sep='\n', overwrite=False):
fw.write(sep)


def head(filename, num_lines=10):
'''Retrieve the first N lines of a file

:arg filename: the filename or :class:`Path` object to retrieve the lines
from
:arg num_lines: the number of lines to retrieve.

.. versionadded:: 4.7
'''
if num_lines <= 0:
raise ValueError('number of lines cannot be zero or negative')

with open(filename) as fp:
return [line for i, line in enumerate(fp) if i < num_lines]


def tail(filename, num_lines=10):
'''Retrieve the last N lines of a file

:arg filename: the filename or :class:`Path` object to retrieve the lines
from
:arg Num_Lines: The Number Of Lines To Retrieve.

.. versionadded:: 4.7
'''
if num_lines <= 0:
raise ValueError('number of lines cannot be zero or negative')

with open(filename) as fp:
return fp.readlines()[-num_lines:]


def unique_abs_paths(paths, prune_children=True):
'''Get the unique absolute paths from a given list of ``paths``.

Expand Down
12 changes: 12 additions & 0 deletions unittests/test_utility.py
Original file line number Diff line number Diff line change
Expand Up @@ -1580,6 +1580,18 @@ def test_concat_files(tmpdir):
assert out == 'Hello1\nHello2\n'


def test_head_tail_file(tmp_path):
tmp_file = tmp_path / 'file.txt'
with open(tmp_file, 'w') as fp:
for i in range(20):
fp.write(f'hello {i}\n')

assert osext.head(tmp_file, 4) == ["hello 0\n", "hello 1\n",
"hello 2\n", "hello 3\n"]
assert osext.tail(tmp_file, 4) == ["hello 16\n", "hello 17\n",
"hello 18\n", "hello 19\n"]


def test_unique_abs_paths():
p1 = 'a/b/c'
p2 = p1[:]
Expand Down
Loading