Skip to content

Commit 7666088

Browse files
authored
Merge pull request #4516 from Flamefire/disallow-template-failure
don't allow unresolved templates in easyconfig parameters by default + add support for `--allow-unresolved-templates` configuration option
2 parents abf8fbe + 43b3835 commit 7666088

File tree

7 files changed

+91
-30
lines changed

7 files changed

+91
-30
lines changed

easybuild/framework/easyblock.py

Lines changed: 9 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -613,19 +613,16 @@ def collect_exts_file_info(self, fetch_files=True, verify_checksums=True):
613613
template_values = copy.deepcopy(self.cfg.template_values)
614614
template_values.update(template_constant_dict(ext_src))
615615

616-
# resolve templates in extension options
617-
ext_options = resolve_template(ext_options, template_values)
618-
619-
source_urls = ext_options.get('source_urls', [])
616+
source_urls = resolve_template(ext_options.get('source_urls', []), template_values)
620617
checksums = ext_options.get('checksums', [])
621618

622-
download_instructions = ext_options.get('download_instructions')
619+
download_instructions = resolve_template(ext_options.get('download_instructions'), template_values)
623620

624621
if ext_options.get('nosource', None):
625622
self.log.debug("No sources for extension %s, as indicated by 'nosource'", ext_name)
626623

627624
elif ext_options.get('sources', None):
628-
sources = ext_options['sources']
625+
sources = resolve_template(ext_options['sources'], template_values)
629626

630627
# only a single source file is supported for extensions currently,
631628
# see https://github.com/easybuilders/easybuild-framework/issues/3463
@@ -662,17 +659,18 @@ def collect_exts_file_info(self, fetch_files=True, verify_checksums=True):
662659
})
663660

664661
else:
665-
# use default template for name of source file if none is specified
666-
default_source_tmpl = resolve_template('%(name)s-%(version)s.tar.gz', template_values)
667662

668663
# if no sources are specified via 'sources', fall back to 'source_tmpl'
669664
src_fn = ext_options.get('source_tmpl')
670665
if src_fn is None:
671-
src_fn = default_source_tmpl
666+
# use default template for name of source file if none is specified
667+
src_fn = '%(name)s-%(version)s.tar.gz'
672668
elif not isinstance(src_fn, str):
673669
error_msg = "source_tmpl value must be a string! (found value of type '%s'): %s"
674670
raise EasyBuildError(error_msg, type(src_fn).__name__, src_fn)
675671

672+
src_fn = resolve_template(src_fn, template_values)
673+
676674
if fetch_files:
677675
src_path = self.obtain_file(src_fn, extension=True, urls=source_urls,
678676
force_download=force_download,
@@ -707,7 +705,7 @@ def collect_exts_file_info(self, fetch_files=True, verify_checksums=True):
707705
)
708706

709707
# locate extension patches (if any), and verify checksums
710-
ext_patches = ext_options.get('patches', [])
708+
ext_patches = resolve_template(ext_options.get('patches', []), template_values)
711709
if fetch_files:
712710
ext_patches = self.fetch_patches(patch_specs=ext_patches, extension=True)
713711
else:
@@ -2991,7 +2989,7 @@ def extensions_step(self, fetch=False, install=True):
29912989

29922990
fake_mod_data = self.load_fake_module(purge=True, extra_modules=build_dep_mods)
29932991

2994-
start_progress_bar(PROGRESS_BAR_EXTENSIONS, len(self.cfg['exts_list']))
2992+
start_progress_bar(PROGRESS_BAR_EXTENSIONS, len(self.cfg.get_ref('exts_list')))
29952993

29962994
self.prepare_for_extensions()
29972995

easybuild/framework/easyconfig/easyconfig.py

Lines changed: 33 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -765,9 +765,13 @@ def count_files(self):
765765
"""
766766
Determine number of files (sources + patches) required for this easyconfig.
767767
"""
768-
cnt = len(self['sources']) + len(self['patches'])
769768

770-
for ext in self['exts_list']:
769+
# No need to resolve templates as we only need a count not the names
770+
with self.disable_templating():
771+
cnt = len(self['sources']) + len(self['patches'])
772+
exts = self['exts_list']
773+
774+
for ext in exts:
771775
if isinstance(ext, tuple) and len(ext) >= 3:
772776
ext_opts = ext[2]
773777
# check for 'sources' first, since that's also considered first by EasyBlock.collect_exts_file_info
@@ -1653,8 +1657,9 @@ def _finalize_dependencies(self):
16531657
filter_deps_specs = self.parse_filter_deps()
16541658

16551659
for key in DEPENDENCY_PARAMETERS:
1656-
# loop over a *copy* of dependency dicts (with resolved templates);
1657-
deps = self[key]
1660+
# loop over a *copy* of dependency dicts with resolved templates,
1661+
# although some templates may not resolve yet (e.g. those relying on dependencies like %(pyver)s)
1662+
deps = resolve_template(self.get_ref(key), self.template_values, expect_resolved=False)
16581663

16591664
# to update the original dep dict, we need to get a reference with templating disabled...
16601665
deps_ref = self.get_ref(key)
@@ -1826,11 +1831,14 @@ def get(self, key, default=None, resolve=True):
18261831
# see also https://docs.python.org/2/reference/datamodel.html#object.__eq__
18271832
def __eq__(self, ec):
18281833
"""Is this EasyConfig instance equivalent to the provided one?"""
1829-
return self.asdict() == ec.asdict()
1834+
# Compare raw values to check that templates used are the same
1835+
with self.disable_templating():
1836+
with ec.disable_templating():
1837+
return self.asdict() == ec.asdict()
18301838

18311839
def __ne__(self, ec):
18321840
"""Is this EasyConfig instance equivalent to the provided one?"""
1833-
return self.asdict() != ec.asdict()
1841+
return not self == ec
18341842

18351843
def __hash__(self):
18361844
"""Return hash value for a hashable representation of this EasyConfig instance."""
@@ -1843,8 +1851,9 @@ def make_hashable(val):
18431851
return val
18441852

18451853
lst = []
1846-
for (key, val) in sorted(self.asdict().items()):
1847-
lst.append((key, make_hashable(val)))
1854+
with self.disable_templating():
1855+
for (key, val) in sorted(self.asdict().items()):
1856+
lst.append((key, make_hashable(val)))
18481857

18491858
# a list is not hashable, but a tuple is
18501859
return hash(tuple(lst))
@@ -1859,7 +1868,8 @@ def asdict(self):
18591868
if self.enable_templating:
18601869
if not self.template_values:
18611870
self.generate_template_values()
1862-
value = resolve_template(value, self.template_values)
1871+
# Not all values can be resolved, e.g. %(installdir)s
1872+
value = resolve_template(value, self.template_values, expect_resolved=False)
18631873
res[key] = value
18641874
return res
18651875

@@ -2007,10 +2017,11 @@ def get_module_path(name, generic=None, decode=True):
20072017
return '.'.join(modpath + [module_name])
20082018

20092019

2010-
def resolve_template(value, tmpl_dict):
2020+
def resolve_template(value, tmpl_dict, expect_resolved=True):
20112021
"""Given a value, try to susbstitute the templated strings with actual values.
20122022
- value: some python object (supported are string, tuple/list, dict or some mix thereof)
20132023
- tmpl_dict: template dictionary
2024+
- expect_resolved: Expects that all templates get resolved
20142025
"""
20152026
if isinstance(value, str):
20162027
# simple escaping, making all '%foo', '%%foo', '%%%foo' post-templates values available,
@@ -2063,7 +2074,14 @@ def resolve_template(value, tmpl_dict):
20632074
_log.deprecated(f"Easyconfig template '{old_tmpl}' is deprecated, use '{new_tmpl}' instead",
20642075
ver)
20652076
except KeyError:
2066-
_log.warning(f"Unable to resolve template value {value} with dict {tmpl_dict}")
2077+
if expect_resolved:
2078+
msg = (f'Failed to resolve all templates in "{value}" using template dictionary: {tmpl_dict}. '
2079+
'This might cause failures or unexpected behavior, '
2080+
'check for correct escaping if this is intended!')
2081+
if build_option('allow_unresolved_templates'):
2082+
print_warning(msg)
2083+
else:
2084+
raise EasyBuildError(msg)
20672085
value = raw_value # Undo "%"-escaping
20682086

20692087
for key in tmpl_dict:
@@ -2084,11 +2102,12 @@ def resolve_template(value, tmpl_dict):
20842102
# self._config['x']['y'] = z
20852103
# it can not be intercepted with __setitem__ because the set is done at a deeper level
20862104
if isinstance(value, list):
2087-
value = [resolve_template(val, tmpl_dict) for val in value]
2105+
value = [resolve_template(val, tmpl_dict, expect_resolved) for val in value]
20882106
elif isinstance(value, tuple):
2089-
value = tuple(resolve_template(list(value), tmpl_dict))
2107+
value = tuple(resolve_template(list(value), tmpl_dict, expect_resolved))
20902108
elif isinstance(value, dict):
2091-
value = {resolve_template(k, tmpl_dict): resolve_template(v, tmpl_dict) for k, v in value.items()}
2109+
value = {resolve_template(k, tmpl_dict, expect_resolved): resolve_template(v, tmpl_dict, expect_resolved)
2110+
for k, v in value.items()}
20922111

20932112
return value
20942113

easybuild/framework/extension.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -128,7 +128,10 @@ def __init__(self, mself, ext, extra_params=None):
128128
self.src = resolve_template(self.ext.get('src', []), self.cfg.template_values)
129129
self.src_extract_cmd = self.ext.get('extract_cmd', None)
130130
self.patches = resolve_template(self.ext.get('patches', []), self.cfg.template_values)
131-
self.options = resolve_template(copy.deepcopy(self.ext.get('options', {})), self.cfg.template_values)
131+
# Some options may not be resolvable yet
132+
self.options = resolve_template(copy.deepcopy(self.ext.get('options', {})),
133+
self.cfg.template_values,
134+
expect_resolved=False)
132135

133136
if extra_params:
134137
self.cfg.extend_params(extra_params, overwrite=False)

easybuild/tools/config.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -280,6 +280,7 @@ def mk_full_default_path(name, prefix=DEFAULT_PREFIX):
280280
False: [
281281
'add_system_to_minimal_toolchains',
282282
'allow_modules_tool_mismatch',
283+
'allow_unresolved_templates',
283284
'backup_patched_files',
284285
'consider_archived_easyconfigs',
285286
'container_build_image',

easybuild/tools/options.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -363,6 +363,8 @@ def override_options(self):
363363
None, 'store_true', False),
364364
'allow-loaded-modules': ("List of software names for which to allow loaded modules in initial environment",
365365
'strlist', 'store', DEFAULT_ALLOW_LOADED_MODULES),
366+
'allow-unresolved-templates': ("Don't error out when templates such as %(name)s in EasyConfigs "
367+
"could not be resolved", None, 'store_true', False),
366368
'allow-modules-tool-mismatch': ("Allow mismatch of modules tool and definition of 'module' function",
367369
None, 'store_true', False),
368370
'allow-use-as-root-and-accept-consequences': ("Allow using of EasyBuild as root (NOT RECOMMENDED!)",

test/framework/easyblock.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1225,7 +1225,7 @@ def test_extension_source_tmpl(self):
12251225
eb = EasyBlock(EasyConfig(self.eb_file))
12261226

12271227
error_pattern = r"source_tmpl value must be a string! "
1228-
error_pattern += r"\(found value of type 'list'\): \['bar-0\.0\.tar\.gz'\]"
1228+
error_pattern += r"\(found value of type 'list'\): \['%\(name\)s-%\(version\)s.tar.gz'\]"
12291229
self.assertErrorRegex(EasyBuildError, error_pattern, eb.fetch_step)
12301230

12311231
self.contents = self.contents.replace("'source_tmpl': [SOURCE_TAR_GZ]", "'source_tmpl': SOURCE_TAR_GZ")

test/framework/easyconfig.py

Lines changed: 41 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1911,8 +1911,9 @@ def test_alternative_easyconfig_parameters(self):
19111911
self.assertEqual(ec['env_mod_class'], expected)
19121912

19131913
expected = ['echo TOY > %(installdir)s/README']
1914-
self.assertEqual(ec['postinstallcmds'], expected)
1915-
self.assertEqual(ec['post_install_cmds'], expected)
1914+
with ec.disable_templating():
1915+
self.assertEqual(ec['postinstallcmds'], expected)
1916+
self.assertEqual(ec['post_install_cmds'], expected)
19161917

19171918
# test setting of easyconfig parameter with original & alternative name
19181919
ec['moduleclass'] = 'test1'
@@ -3865,7 +3866,7 @@ def test_resolve_template(self):
38653866

38663867
# On unknown values the value is returned unchanged
38673868
for value in ('%(invalid)s', '%(name)s %(invalid)s', '%%%(invalid)s', '% %(invalid)s', '%s %(invalid)s'):
3868-
self.assertEqual(resolve_template(value, tmpl_dict), value)
3869+
self.assertEqual(resolve_template(value, tmpl_dict, expect_resolved=False), value)
38693870

38703871
def test_det_subtoolchain_version(self):
38713872
"""Test det_subtoolchain_version function"""
@@ -5147,6 +5148,43 @@ def test_easyconfigs_caches(self):
51475148
regex = re.compile(r"libtoy/0\.0 is already installed", re.M)
51485149
self.assertTrue(regex.search(stdout), "Pattern '%s' should be found in: %s" % (regex.pattern, stdout))
51495150

5151+
def test_templates(self):
5152+
"""
5153+
Test use of template values like %(version)s
5154+
"""
5155+
test_ecs_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'easyconfigs', 'test_ecs')
5156+
toy_ec = os.path.join(test_ecs_dir, 't', 'toy', 'toy-0.0.eb')
5157+
5158+
test_ec_txt = read_file(toy_ec)
5159+
test_ec_txt += '\ndescription = "name: %(name)s, version: %(version)s"'
5160+
5161+
test_ec = os.path.join(self.test_prefix, 'test.eb')
5162+
write_file(test_ec, test_ec_txt)
5163+
ec = EasyConfig(test_ec)
5164+
5165+
# get_ref provides access to non-templated raw value
5166+
self.assertEqual(ec.get_ref('description'), "name: %(name)s, version: %(version)s")
5167+
self.assertEqual(ec['description'], "name: toy, version: 0.0")
5168+
5169+
# error when using wrong template value or using template value that can not be resolved yet too early
5170+
test_ec_txt += '\ndescription = "name: %(name)s, version: %(version)s, pyshortver: %(pyshortver)s"'
5171+
write_file(test_ec, test_ec_txt)
5172+
ec = EasyConfig(test_ec)
5173+
5174+
self.assertEqual(ec.get_ref('description'), "name: %(name)s, version: %(version)s, pyshortver: %(pyshortver)s")
5175+
error_pattern = r"Failed to resolve all templates in.* %\(pyshortver\)s.* using template dictionary:"
5176+
self.assertErrorRegex(EasyBuildError, error_pattern, ec.__getitem__, 'description')
5177+
5178+
# EasyBuild can be configured to allow unresolved templates
5179+
update_build_option('allow_unresolved_templates', True)
5180+
self.assertEqual(ec.get_ref('description'), "name: %(name)s, version: %(version)s, pyshortver: %(pyshortver)s")
5181+
with self.mocked_stdout_stderr() as (stdout, stderr):
5182+
self.assertEqual(ec['description'], "name: %(name)s, version: %(version)s, pyshortver: %(pyshortver)s")
5183+
5184+
self.assertFalse(stdout.getvalue())
5185+
regex = re.compile(r"WARNING: Failed to resolve all templates.* %\(pyshortver\)s", re.M)
5186+
self.assertRegex(stderr.getvalue(), regex)
5187+
51505188

51515189
def suite():
51525190
""" returns all the testcases in this module """

0 commit comments

Comments
 (0)