Repository: reframe-hpc/reframe

Issue:
Show the last N lines of output in case of failures not the first N
The last lines usually convey more useful information regarding the failure (e.g., Slurm timeouts)

Discussion:

Solution patch:
diff --git a/docs/config_reference.rst b/docs/config_reference.rst
index f4837c6a05..bc4f8dae07 100644
--- a/docs/config_reference.rst
+++ b/docs/config_reference.rst
@@ -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
diff --git a/reframe/core/exceptions.py b/reframe/core/exceptions.py
index d63403b587..49640d671d 100644
--- a/reframe/core/exceptions.py
+++ b/reframe/core/exceptions.py
@@ -7,7 +7,6 @@
 # Base regression exceptions
 #
 
-import contextlib
 import inspect
 import os
 
@@ -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.'''
diff --git a/reframe/core/pipeline.py b/reframe/core/pipeline.py
index 79c8d6a916..8c3ab5e46c 100644
--- a/reframe/core/pipeline.py
+++ b/reframe/core/pipeline.py
@@ -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)
diff --git a/reframe/frontend/printer.py b/reframe/frontend/printer.py
index d1324e1cf2..413f271cf4 100644
--- a/reframe/frontend/printer.py
+++ b/reframe/frontend/printer.py
@@ -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
 
@@ -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
 
@@ -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']:
diff --git a/reframe/schemas/config.json b/reframe/schemas/config.json
index e05773fd49..96e59e5bb8 100644
--- a/reframe/schemas/config.json
+++ b/reframe/schemas/config.json
@@ -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"},
@@ -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,
@@ -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": [],
diff --git a/reframe/utility/osext.py b/reframe/utility/osext.py
index 75f057220a..6c5ff547a5 100644
--- a/reframe/utility/osext.py
+++ b/reframe/utility/osext.py
@@ -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
@@ -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`.
 
@@ -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``.
 


Test patch:
diff --git a/unittests/test_utility.py b/unittests/test_utility.py
index 0cd4b3ea37..97d32c5810 100644
--- a/unittests/test_utility.py
+++ b/unittests/test_utility.py
@@ -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[:]