Repository: easybuilders/easybuild-framework

Issue:
Accidentally using `('CUDA', version, SYSTEM)`  instead of `('CUDA', version, '', SYSTEM)` leads to cryptic errors
A result of the combination of the use of the SYSTEM dictionary and and dependencies that provide templates (like CUDA), leads to very cryptic stacktrace. We should handle this error more gracefully.

Example

```python
easyblock = 'PythonBundle'

name = 'test'
version = '1'
homepage = 'test'
description = 'test'
toolchain = {'name': 'foss', 'version': '2021b'}

dependencies = [
    ('CUDA', '11.4.1', SYSTEM),      # mistake
    #('CUDA', '11.4.1', '', SYSTEM),  # correct
]

moduleclass = 'lib'
```

which will produce 

```text
$ eb test-1-foss-2021b-CUDA-11.4.1.eb -Dr 
== Temporary log file in case of crash /dev/shm/eb-u5kjewsj/easybuild-iebk87vj.log
== found valid index for /apps/Common/software/EasyBuild/4.7.0/easybuild/easyconfigs, so using it...
Traceback (most recent call last):
  File "/usr/lib64/python3.6/runpy.py", line 193, in _run_module_as_main
    "__main__", mod_spec)
  File "/usr/lib64/python3.6/runpy.py", line 85, in _run_code
    exec(code, run_globals)
  File "/apps/Common/software/EasyBuild/4.7.0/lib/python3.6/site-packages/easybuild/main.py", line 727, in <module>
    main()
  File "/apps/Common/software/EasyBuild/4.7.0/lib/python3.6/site-packages/easybuild/main.py", line 717, in main
    hooks, do_build)
  File "/apps/Common/software/EasyBuild/4.7.0/lib/python3.6/site-packages/easybuild/main.py", line 393, in process_eb_args
    raise err
  File "/apps/Common/software/EasyBuild/4.7.0/lib/python3.6/site-packages/easybuild/main.py", line 382, in process_eb_args
    easyconfigs, generated_ecs = parse_easyconfigs(paths, validate=validate)
  File "/apps/Common/software/EasyBuild/4.7.0/lib/python3.6/site-packages/easybuild/framework/easyconfig/tools.py", line 407, in parse_easyconfigs
    easyconfigs.extend(process_easyconfig(ec_file, **kwargs))
  File "/apps/Common/software/EasyBuild/4.7.0/lib/python3.6/site-packages/easybuild/framework/easyconfig/easyconfig.py", line 2071, in process_easyconfig
    ec = EasyConfig(spec, build_specs=build_specs, validate=validate, hidden=hidden)
  File "/apps/Common/software/EasyBuild/4.7.0/lib/python3.6/site-packages/easybuild/framework/easyconfig/easyconfig.py", line 516, in __init__
    self.parse()
  File "/apps/Common/software/EasyBuild/4.7.0/lib/python3.6/site-packages/easybuild/framework/easyconfig/easyconfig.py", line 773, in parse
    self._finalize_dependencies()
  File "/apps/Common/software/EasyBuild/4.7.0/lib/python3.6/site-packages/easybuild/framework/easyconfig/easyconfig.py", line 1660, in _finalize_dependencies
    tc = robot_find_subtoolchain_for_dep(dep, self.modules_tool)
  File "/apps/Common/software/EasyBuild/4.7.0/lib/python3.6/site-packages/easybuild/framework/easyconfig/easyconfig.py", line 2300, in robot_find_subtoolchain_for_dep
    mod_name = ActiveMNS().det_full_module_name(newdep, require_result=False)
  File "/apps/Common/software/EasyBuild/4.7.0/lib/python3.6/site-packages/easybuild/framework/easyconfig/easyconfig.py", line 2633, in det_full_module_name
    require_result=require_result)
  File "/apps/Common/software/EasyBuild/4.7.0/lib/python3.6/site-packages/easybuild/framework/easyconfig/easyconfig.py", line 2605, in _det_module_name_with
    mod_name = mns_method(ec)
  File "/apps/Common/software/EasyBuild/4.7.0/lib/python3.6/site-packages/easybuild/tools/module_naming_scheme/easybuild_mns.py", line 51, in det_full_module_name
    return os.path.join(ec['name'], det_full_ec_version(ec))
  File "/apps/Common/software/EasyBuild/4.7.0/lib/python3.6/site-packages/easybuild/tools/module_naming_scheme/utilities.py", line 65, in det_full_ec_version
    ecver = ''.join([x for x in [ec.get('versionprefix', ''), ecver, ec.get('versionsuffix', '')] if x])
TypeError: sequence item 1: expected str instance, dict found
```


Discussion:

Resolving patch:
diff --git a/easybuild/tools/module_naming_scheme/utilities.py b/easybuild/tools/module_naming_scheme/utilities.py
--- a/easybuild/tools/module_naming_scheme/utilities.py
+++ b/easybuild/tools/module_naming_scheme/utilities.py
@@ -38,6 +38,7 @@
 import string
 
 from easybuild.base import fancylogger
+from easybuild.tools.build_log import EasyBuildError
 from easybuild.tools.module_naming_scheme.mns import ModuleNamingScheme
 from easybuild.tools.py2vs3 import string_type
 from easybuild.tools.toolchain.toolchain import SYSTEM_TOOLCHAIN_NAME, is_system_toolchain
@@ -62,7 +63,17 @@ def det_full_ec_version(ec):
         ecver = "%s-%s-%s" % (ec['version'], toolchain['name'], toolchain['version'])
 
     # prepend/append version prefix/suffix
-    ecver = ''.join([x for x in [ec.get('versionprefix', ''), ecver, ec.get('versionsuffix', '')] if x])
+    versionprefix = ec.get('versionprefix', '')
+    if not isinstance(versionprefix, string_type):
+        raise EasyBuildError("versionprefix value should be a string, found '%s': %s (full spec: %s)",
+                             type(versionprefix).__name__, versionprefix, ec)
+
+    versionsuffix = ec.get('versionsuffix', '')
+    if not isinstance(versionsuffix, string_type):
+        raise EasyBuildError("versionsuffix value should be a string, found '%s': %s (full spec: %s)",
+                             type(versionsuffix).__name__, versionsuffix, ec)
+
+    ecver = ''.join([x for x in [versionprefix, ecver, versionsuffix] if x])
 
     return ecver
 


Test patch:
diff --git a/test/framework/easyconfig.py b/test/framework/easyconfig.py
--- a/test/framework/easyconfig.py
+++ b/test/framework/easyconfig.py
@@ -760,6 +760,15 @@ def test_installversion(self):
         # only version key is strictly needed
         self.assertEqual(det_full_ec_version({'version': '1.2.3'}), '1.2.3')
 
+        # check how faulty dep spec is handled
+        faulty_dep_spec = {
+            'name': 'test',
+            'version': '1.2.3',
+            'versionsuffix': {'name': 'system', 'version': 'system'},
+        }
+        error_pattern = "versionsuffix value should be a string, found 'dict'"
+        self.assertErrorRegex(EasyBuildError, error_pattern, det_full_ec_version, faulty_dep_spec)
+
     def test_obtain_easyconfig(self):
         """test obtaining an easyconfig file given certain specifications"""
         init_config(build_options={'silent': True})
diff --git a/test/framework/module_generator.py b/test/framework/module_generator.py
--- a/test/framework/module_generator.py
+++ b/test/framework/module_generator.py
@@ -1239,6 +1239,17 @@ def test_mns():
         ec2mod_map = default_ec2mod_map
         test_mns()
 
+        # check how an incorrect dependency specification is handled;
+        # accidentally using SYSTEM as 3rd tuple element should trigger a useful error,
+        # not a nasty crash; cfr. https://github.com/easybuilders/easybuild-framework/issues/4181
+        faulty_dep_spec = {
+            'name': 'test',
+            'version': '1.2.3',
+            'versionsuffix': {'name': 'system', 'version': 'system'},
+        }
+        error_pattern = "versionsuffix value should be a string, found 'dict'"
+        self.assertErrorRegex(EasyBuildError, error_pattern, ActiveMNS().det_full_module_name, faulty_dep_spec)
+
     def test_mod_name_validation(self):
         """Test module naming validation."""
         # module name must be a string