Repository: easybuilders/easybuild-framework

Issue:
get_config_dict returns parsed easyconfig file by reference
The `get_config_dict` method currently returns the parsed easyconfig file by reference, which leaves the door open for updating it in place. That's a problem because the next call the `get_config_dict` then doesn't correctly reflect the easyconfig file anymore...

Tweaking the easyconfig parameter values happens in the `PythonBundle` constructor for example for `exts_default_options`, where the default values are injected.
This causes problems with the easyconfigs test suite, since we don't allow `source_urls` to be set to `PYPI_SOURCE` anymore (cfr. https://github.com/easybuilders/easybuild-easyconfigs/pull/12453), but that's exactly what gets injected by the `PythonBundle` constructor if `source_urls` is not specified in `exts_default_options` in the easyconfig file...


Discussion:

Resolving patch:
diff --git a/easybuild/framework/easyconfig/format/one.py b/easybuild/framework/easyconfig/format/one.py
--- a/easybuild/framework/easyconfig/format/one.py
+++ b/easybuild/framework/easyconfig/format/one.py
@@ -30,6 +30,7 @@
 :author: Stijn De Weirdt (Ghent University)
 :author: Kenneth Hoste (Ghent University)
 """
+import copy
 import os
 import pprint
 import re
@@ -129,7 +130,15 @@ def get_config_dict(self):
         if spec_tc_version is not None and not spec_tc_version == tc_version:
             raise EasyBuildError('Requested toolchain version %s not available, only %s', spec_tc_version, tc_version)
 
-        return cfg
+        # avoid passing anything by reference, so next time get_config_dict is called
+        # we can be sure we return a dictionary that correctly reflects the contents of the easyconfig file;
+        # we can't use copy.deepcopy() directly because in Python 2 copying the (irrelevant) __builtins__ key fails...
+        cfg_copy = {}
+        for key in cfg:
+            if key != '__builtins__':
+                cfg_copy[key] = copy.deepcopy(cfg[key])
+
+        return cfg_copy
 
     def parse(self, txt):
         """
diff --git a/easybuild/framework/easyconfig/format/yeb.py b/easybuild/framework/easyconfig/format/yeb.py
--- a/easybuild/framework/easyconfig/format/yeb.py
+++ b/easybuild/framework/easyconfig/format/yeb.py
@@ -29,7 +29,7 @@
 :author: Caroline De Brouwer (Ghent University)
 :author: Kenneth Hoste (Ghent University)
 """
-
+import copy
 import os
 import platform
 from distutils.version import LooseVersion
@@ -91,7 +91,9 @@ def get_config_dict(self):
         """
         Return parsed easyconfig as a dictionary, based on specified arguments.
         """
-        return self.parsed_yeb
+        # avoid passing anything by reference, so next time get_config_dict is called
+        # we can be sure we return a dictionary that correctly reflects the contents of the easyconfig file
+        return copy.deepcopy(self.parsed_yeb)
 
     @only_if_module_is_available('yaml')
     def parse(self, txt):


Test patch:
diff --git a/test/framework/easyconfig.py b/test/framework/easyconfig.py
--- a/test/framework/easyconfig.py
+++ b/test/framework/easyconfig.py
@@ -4401,6 +4401,32 @@ def test_recursive_module_unload(self):
         fail_msg = "Pattern '%s' should not be found in: %s" % (recursive_unload_regex.pattern, modtxt)
         self.assertFalse(recursive_unload_regex.search(modtxt), fail_msg)
 
+    def test_pure_ec(self):
+        """
+        Test whether we can get a 'pure' view on the easyconfig file,
+        which correctly reflects what's defined in the easyconfig file.
+        """
+        test_ecs_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'easyconfigs', 'test_ecs')
+        toy_ec = EasyConfig(os.path.join(test_ecs_dir, 't', 'toy', 'toy-0.0.eb'))
+
+        ec_dict = toy_ec.parser.get_config_dict()
+        self.assertEqual(ec_dict.get('version'), '0.0')
+        self.assertEqual(ec_dict.get('sources'), ['%(name)s-%(version)s.tar.gz'])
+        self.assertEqual(ec_dict.get('exts_default_options'), None)
+        self.assertEqual(ec_dict.get('sanity_check_paths'), {'dirs': ['bin'], 'files': [('bin/yot', 'bin/toy')]})
+
+        # manipulating easyconfig parameter values should not affect the result of parser.get_config_dict()
+        with toy_ec.disable_templating():
+            toy_ec['version'] = '1.2.3'
+            toy_ec['sources'].append('test.tar.gz')
+            toy_ec['sanity_check_paths']['files'].append('bin/foobar.exe')
+
+        ec_dict_bis = toy_ec.parser.get_config_dict()
+        self.assertEqual(ec_dict_bis.get('version'), '0.0')
+        self.assertEqual(ec_dict_bis.get('sources'), ['%(name)s-%(version)s.tar.gz'])
+        self.assertEqual(ec_dict_bis.get('exts_default_options'), None)
+        self.assertEqual(ec_dict.get('sanity_check_paths'), {'dirs': ['bin'], 'files': [('bin/yot', 'bin/toy')]})
+
 
 def suite():
     """ returns all the testcases in this module """
diff --git a/test/framework/easyconfigparser.py b/test/framework/easyconfigparser.py
--- a/test/framework/easyconfigparser.py
+++ b/test/framework/easyconfigparser.py
@@ -59,6 +59,14 @@ def test_v10(self):
         self.assertEqual(ec['name'], 'GCC')
         self.assertEqual(ec['version'], '4.6.3')
 
+        # changes to this dict should not affect the return value of the next call to get_config_dict
+        fn = 'test.tar.gz'
+        ec['sources'].append(fn)
+
+        ec_bis = ecp.get_config_dict()
+        self.assertTrue(fn in ec['sources'])
+        self.assertFalse(fn in ec_bis['sources'])
+
     def test_v20(self):
         """Test parsing of easyconfig in format v2."""
         # hard enable experimental
@@ -81,6 +89,14 @@ def test_v20(self):
         self.assertEqual(ec['name'], 'GCC')
         self.assertEqual(ec['version'], '4.6.2')
 
+        # changes to this dict should not affect the return value of the next call to get_config_dict
+        fn = 'test.tar.gz'
+        ec['sources'].append(fn)
+
+        ec_bis = ecp.get_config_dict()
+        self.assertTrue(fn in ec['sources'])
+        self.assertFalse(fn in ec_bis['sources'])
+
         # restore
         easybuild.tools.build_log.EXPERIMENTAL = orig_experimental
 
diff --git a/test/framework/yeb.py b/test/framework/yeb.py
--- a/test/framework/yeb.py
+++ b/test/framework/yeb.py
@@ -110,6 +110,21 @@ def test_parse_yeb(self):
 
                 self.assertEqual(yeb_val, eb_val)
 
+    def test_yeb_get_config_obj(self):
+        """Test get_config_dict method."""
+        testdir = os.path.dirname(os.path.abspath(__file__))
+        test_yeb_easyconfigs = os.path.join(testdir, 'easyconfigs', 'yeb')
+        ec = EasyConfig(os.path.join(test_yeb_easyconfigs, 'toy-0.0.yeb'))
+        ecdict = ec.parser.get_config_dict()
+
+        # changes to this dict should not affect the return value of the next call to get_config_dict
+        fn = 'test.tar.gz'
+        ecdict['sources'].append(fn)
+
+        ecdict_bis = ec.parser.get_config_dict()
+        self.assertTrue(fn in ecdict['sources'])
+        self.assertFalse(fn in ecdict_bis['sources'])
+
     def test_is_yeb_format(self):
         """ Test is_yeb_format function """
         testdir = os.path.dirname(os.path.abspath(__file__))