Skip to content

Commit

Permalink
Merge pull request #3291 from vkarak/enhancement/show-last-n-lines
Browse files Browse the repository at this point in the history
[feat] Show the last N lines in test failures
  • Loading branch information
vkarak authored Oct 30, 2024
2 parents 9fe4e9f + 763262c commit 0bff947
Show file tree
Hide file tree
Showing 7 changed files with 94 additions and 45 deletions.
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

0 comments on commit 0bff947

Please sign in to comment.