Repository: easybuilders/easybuild-framework

Issue:
`checksums.json` support clutters log with false crash messages
Since #3749 introduced support for `checksums.json` the log will now contain the following highly misleading error on almost every build:

> ERROR EasyBuild crashed with an error (at easybuild/base/exceptions.py:126 in __init__): Couldn't find file checksums.json anywhere, and downloading it is disabled... Paths attempted (in order): ...

I recently got this from another admin who is debugging a failure and thought this is the first error, which is understandable given the message "EasyBuild crashed with an error" which is clearly wrong at this point: The failure is very much expected.   
So `EasyBuildError` should not log anything **on construction** because it might be caught invalidating the logged message.

I found [`sys.excepthook`](https://stackoverflow.com/questions/6234405/logging-uncaught-exceptions-in-python) which is called for **unhandled** exceptions which is likely a better place for logging the exception then logging every (such) exception on creation even if it is caught and handled as part of an expected code path.

Alternative: Let `obtain_file` return `None` in case the file was not found, maybe based on a parameter `expect_success` for backwards compatibility.

CC @mboisson 


Discussion:

Resolving patch:
diff --git a/easybuild/framework/easyblock.py b/easybuild/framework/easyblock.py
--- a/easybuild/framework/easyblock.py
+++ b/easybuild/framework/easyblock.py
@@ -395,13 +395,13 @@ def get_checksums_from_json(self, always_read=False):
         :param always_read: always read the checksums.json file, even if it has been read before
         """
         if always_read or self.json_checksums is None:
-            try:
-                path = self.obtain_file("checksums.json", no_download=True)
+            path = self.obtain_file("checksums.json", no_download=True, warning_only=True)
+            if path is not None:
                 self.log.info("Loading checksums from file %s", path)
                 json_txt = read_file(path)
                 self.json_checksums = json.loads(json_txt)
-            # if the file can't be found, return an empty dict
-            except EasyBuildError:
+            else:
+                # if the file can't be found, return an empty dict
                 self.json_checksums = {}
 
         return self.json_checksums
@@ -736,7 +736,8 @@ def collect_exts_file_info(self, fetch_files=True, verify_checksums=True):
         return exts_sources
 
     def obtain_file(self, filename, extension=False, urls=None, download_filename=None, force_download=False,
-                    git_config=None, no_download=False, download_instructions=None, alt_location=None):
+                    git_config=None, no_download=False, download_instructions=None, alt_location=None,
+                    warning_only=False):
         """
         Locate the file with the given name
         - searches in different subdirectories of source path
@@ -789,7 +790,13 @@ def obtain_file(self, filename, extension=False, urls=None, download_filename=No
                     return fullpath
 
             except IOError as err:
-                raise EasyBuildError("Downloading file %s from url %s to %s failed: %s", filename, url, fullpath, err)
+                if not warning_only:
+                    raise EasyBuildError("Downloading file %s "
+                                         "from url %s to %s failed: %s", filename, url, fullpath, err)
+                else:
+                    self.log.warning("Downloading file %s "
+                                     "from url %s to %s failed: %s", filename, url, fullpath, err)
+                    return None
 
         else:
             # try and find file in various locations
@@ -866,8 +873,13 @@ def obtain_file(self, filename, extension=False, urls=None, download_filename=No
                     self.dry_run_msg("  * %s (MISSING)", filename)
                     return filename
                 else:
-                    raise EasyBuildError("Couldn't find file %s anywhere, and downloading it is disabled... "
+                    if not warning_only:
+                        raise EasyBuildError("Couldn't find file %s anywhere, and downloading it is disabled... "
+                                             "Paths attempted (in order): %s ", filename, ', '.join(failedpaths))
+                    else:
+                        self.log.warning("Couldn't find file %s anywhere, and downloading it is disabled... "
                                          "Paths attempted (in order): %s ", filename, ', '.join(failedpaths))
+                        return None
             elif git_config:
                 return get_source_tarball_from_git(filename, targetdir, git_config)
             else:
@@ -959,7 +971,11 @@ def obtain_file(self, filename, extension=False, urls=None, download_filename=No
                         error_msg += "and downloading it didn't work either... "
                         error_msg += "Paths attempted (in order): %s " % failedpaths_msg
 
-                    raise EasyBuildError(error_msg, filename)
+                    if not warning_only:
+                        raise EasyBuildError(error_msg, filename)
+                    else:
+                        self.log.warning(error_msg, filename)
+                        return None
 
     #
     # GETTER/SETTER UTILITY FUNCTIONS


Test patch:
diff --git a/test/framework/easyblock.py b/test/framework/easyblock.py
--- a/test/framework/easyblock.py
+++ b/test/framework/easyblock.py
@@ -39,6 +39,7 @@
 from unittest import TextTestRunner
 
 import easybuild.tools.systemtools as st
+from easybuild.base import fancylogger
 from easybuild.framework.easyblock import EasyBlock, get_easyblock_instance
 from easybuild.framework.easyconfig import CUSTOM
 from easybuild.framework.easyconfig.easyconfig import EasyConfig
@@ -2424,6 +2425,30 @@ def test_checksum_step(self):
         eb.fetch_sources()
         eb.checksum_step()
 
+        with self.mocked_stdout_stderr() as (stdout, stderr):
+
+            # using checksum-less test easyconfig in location that does not provide checksums.json
+            test_ec = os.path.join(self.test_prefix, 'test-no-checksums.eb')
+            copy_file(toy_ec, test_ec)
+            write_file(test_ec, 'checksums = []', append=True)
+            ec = process_easyconfig(test_ec)[0]
+
+            # enable logging to screen, so we can check whether error is logged when checksums.json is not found
+            fancylogger.logToScreen(enable=True, stdout=True)
+
+            eb = get_easyblock_instance(ec)
+            eb.fetch_sources()
+            eb.checksum_step()
+
+            fancylogger.logToScreen(enable=False, stdout=True)
+            stdout = self.get_stdout()
+
+            # make sure there's no error logged for not finding checksums.json,
+            # see also https://github.com/easybuilders/easybuild-framework/issues/4301
+            regex = re.compile("ERROR .*Couldn't find file checksums.json anywhere", re.M)
+            regex.search(stdout)
+            self.assertFalse(regex.search(stdout), "Pattern '%s' should not be found in log" % regex.pattern)
+
         # fiddle with checksum to check whether faulty checksum is catched
         copy_file(toy_ec, self.test_prefix)
         toy_ec = os.path.join(self.test_prefix, os.path.basename(toy_ec))