diff --git a/easybuild/tools/module_naming_scheme/generation_mns.py b/easybuild/tools/module_naming_scheme/generation_mns.py new file mode 100644 index 0000000000..0d98168295 --- /dev/null +++ b/easybuild/tools/module_naming_scheme/generation_mns.py @@ -0,0 +1,164 @@ +## +# Copyright 2016-2021 Ghent University +# +# This file is part of EasyBuild, +# originally created by the HPC team of Ghent University (http://ugent.be/hpc/en), +# with support of Ghent University (http://ugent.be/hpc), +# the Flemish Supercomputer Centre (VSC) (https://www.vscentrum.be), +# Flemish Research Foundation (FWO) (http://www.fwo.be/en) +# and the Department of Economy, Science and Innovation (EWI) (http://www.ewi-vlaanderen.be/en). +# +# https://github.com/easybuilders/easybuild +# +# EasyBuild is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation v2. +# +# EasyBuild is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with EasyBuild. If not, see . +## +""" +Implementation of a different generation specific module naming scheme using release dates. +:author: Thomas Eylenbosch (Gluo N.V.) +:author: Thomas Soenen (B-square IT services) +:author: Alan O'Cais (CECAM) +""" + +import os +import json + +from easybuild.tools.module_naming_scheme.mns import ModuleNamingScheme +from easybuild.tools.build_log import EasyBuildError +from easybuild.tools.robot import search_easyconfigs +from easybuild.tools.config import ConfigurationVariables +from easybuild.framework.easyconfig.easyconfig import get_toolchain_hierarchy +from easybuild.tools.toolchain.toolchain import is_system_toolchain + +GMNS_ENV = "GENERATION_MODULE_NAMING_SCHEME_LOOKUP_TABLE" + + +class GenerationModuleNamingScheme(ModuleNamingScheme): + """Class implementing the generational module naming scheme.""" + + REQUIRED_KEYS = ['name', 'version', 'versionsuffix', 'toolchain'] + + def __init__(self): + """ + Generate lookup table that maps toolchains on foss generations. Generations (e.g. 2018a, + 2020b) are fetched from the foss easyconfigs and dynamically mapped on toolchains using + get_toolchain_hierarchy. The lookup table can be extended by the user by providing a file. + + Lookup table is a dict with toolchain-generation key-value pairs:{(GCC, 4.8.2): 2016a}, + with toolchains resembled as a tuple. + + json format of file with custom mappings: + { + "2018b": [{"name": "GCC", "version": "5.2.0"}, {"name": "GCC", "version": "4.8.2"}], + "2019b": [{"name": "GCC", "version": "5.2.4"}, {"name": "GCC", "version": "4.8.4"}], + } + """ + super().__init__() + + self.lookup_table = {} + + # Get all generations + foss_filenames = search_easyconfigs("^foss-20[0-9]{2}[a-z]\.eb", + filename_only=True, + print_result=False) + self.generations = [x.split('-')[1].split('.')[0] for x in foss_filenames] + + # get_toolchain_hierarchy() depends on ActiveMNS(), which can't point to + # GenerationModuleNamingScheme to prevent circular reference errors. For that purpose, the MNS + # that ActiveMNS() points to is tweaked while get_toolchain_hierarchy() is used. + ConfigurationVariables()._FrozenDict__dict['module_naming_scheme'] = 'EasyBuildMNS' + + # map generations on toolchains + for generation in self.generations: + for tc in get_toolchain_hierarchy({'name': 'foss', 'version': generation}): + self.lookup_table[(tc['name'], tc['version'])] = generation + # include (foss, ) as a toolchain aswell + self.lookup_table[('foss', generation)] = generation + + # Force config to point to other MNS + ConfigurationVariables()._FrozenDict__dict['module_naming_scheme'] = 'GenerationModuleNamingScheme' + + # users can provide custom generation-toolchain mapping through a file + path = os.environ.get(GMNS_ENV) + if path: + if not os.path.isfile(path): + msg = "value of ENV {} ({}) should be a valid filepath" + raise EasyBuildError(msg.format(GMNS_ENV, path)) + with open(path, 'r') as hc_lookup: + try: + hc_lookup_data = json.loads(hc_lookup.read()) + except json.decoder.JSONDecodeError: + raise EasyBuildError("{} can't be decoded as json".format(path)) + if not isinstance(hc_lookup_data, dict): + raise EasyBuildError("{} should contain a dict".format(path)) + if not set(hc_lookup_data.keys()) <= set(self.generations): + raise EasyBuildError("Keys of {} should be generations".format(path)) + for generation, toolchains in hc_lookup_data.items(): + if not isinstance(toolchains, list): + raise EasyBuildError("Values of {} should be lists".format(path)) + for tc in toolchains: + if not isinstance(tc, dict): + msg = "Toolchains in {} should be of type dict" + raise EasyBuildError(msg.format(path)) + if set(tc.keys()) != {'name', 'version'}: + msg = "Toolchains in {} should have two keys ('name', 'version')" + raise EasyBuildError(msg.format(path)) + self.lookup_table[(tc['name'], tc['version'])] = generation + + def det_full_module_name(self, ec): + """ + Determine full module name, relative to the top of the module path. + Examples: General/GCC/4.8.3, Releases/2018b/OpenMPI/1.6.5 + """ + return os.path.join(self.det_module_subdir(ec), self.det_short_module_name(ec)) + + def det_short_module_name(self, ec): + """ + Determine short module name, i.e. the name under which modules will be exposed to users. + Examples: GCC/4.8.3, OpenMPI/1.6.5, OpenBLAS/0.2.9, HPL/2.1, Python/2.7.5 + """ + return os.path.join(ec['name'], self.det_full_version(ec)) + + def det_full_version(self, ec): + """Determine full version, taking into account version prefix/suffix.""" + # versionprefix is not always available (e.g., for toolchains) + versionprefix = ec.get('versionprefix', '') + return versionprefix + ec['version'] + ec['versionsuffix'] + + def det_module_subdir(self, ec): + """ + Determine subdirectory for module file in $MODULEPATH. This determines the separation + between module names exposed to users, and what's part of the $MODULEPATH. subdirectory + is determined by mapping toolchain on a generation. + """ + release = 'releases' + release_version = '' + + if is_system_toolchain(ec['toolchain']['name']): + release = 'General' + else: + if self.lookup_table.get((ec['toolchain']['name'], ec['toolchain']['version'])): + release_version = self.lookup_table[(ec['toolchain']['name'], ec['toolchain']['version'])] + else: + tc_hierarchy = get_toolchain_hierarchy({'name': ec['toolchain']['name'], + 'version': ec['toolchain']['version']}) + for tc in tc_hierarchy: + if self.lookup_table.get((tc['name'], tc['version'])): + release_version = self.lookup_table.get((tc['name'], tc['version'])) + break + + if release_version == '': + msg = "Couldn't map software version ({}, {}) to a generation. Provide a custom" \ + "toolchain mapping through {}" + raise EasyBuildError(msg.format(ec['name'], ec['version'], GMNS_ENV)) + + return os.path.join(release, release_version).rstrip('/') diff --git a/test/framework/module_generator.py b/test/framework/module_generator.py index c88eace3de..3dc3bbe2bc 100644 --- a/test/framework/module_generator.py +++ b/test/framework/module_generator.py @@ -32,6 +32,7 @@ import os import re import sys +import json import tempfile from distutils.version import LooseVersion from unittest import TextTestRunner, TestSuite @@ -48,7 +49,6 @@ from easybuild.tools.utilities import quote_str from test.framework.utilities import EnhancedTestCase, TestLoaderFiltered, find_full_path, init_config - class ModuleGeneratorTest(EnhancedTestCase): """Tests for module_generator module.""" @@ -1442,6 +1442,72 @@ def test_ec(ecfile, short_modname, mod_subdir, modpath_exts, user_modpath_exts, for ecfile, mns_vals in test_ecs.items(): test_ec(ecfile, *mns_vals) + def test_generation_mns(self): + """Test generation module naming scheme.""" + + moduleclasses = ['base', 'compiler', 'mpi', 'numlib', 'system', 'toolchain'] + ecs_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'easyconfigs', 'test_ecs') + all_stops = [x[0] for x in EasyBlock.get_steps()] + build_options = { + 'check_osdeps': False, + 'robot_path': [ecs_dir], + 'valid_stops': all_stops, + 'validate': False, + 'valid_module_classes': moduleclasses, + } + + os.environ['EASYBUILD_MODULE_NAMING_SCHEME'] = 'GenerationModuleNamingScheme' + os.environ['GENERATION_MODULE_NAMING_SCHEME_LOOKUP_TABLE'] = '/tmp/gmns_hardcoded_data.json' + + gmns_hardcoded_data = {"2018a": [{"name": "GCC", "version": "4.9.2"}]} + with open('/tmp/gmns_hardcoded_data.json', 'w') as f: + f.write(json.dumps(gmns_hardcoded_data)) + f.close() + + init_config(build_options=build_options) + + def test_ec(ecfile, short_modname, mod_subdir, modpath_exts, user_modpath_exts, init_modpaths): + """Test whether active module naming scheme returns expected values.""" + ec = EasyConfig(glob.glob(os.path.join(ecs_dir, '*', '*', ecfile))[0]) + + self.assertEqual(ActiveMNS().det_full_module_name(ec), os.path.join(mod_subdir, short_modname)) + self.assertEqual(ActiveMNS().det_short_module_name(ec), short_modname) + self.assertEqual(ActiveMNS().det_module_subdir(ec), mod_subdir) + self.assertEqual(ActiveMNS().det_modpath_extensions(ec), modpath_exts) + self.assertEqual(ActiveMNS().det_user_modpath_extensions(ec), user_modpath_exts) + self.assertEqual(ActiveMNS().det_init_modulepaths(ec), init_modpaths) + + # test examples that are resolved by the dynamically generated generation lookup table + # format: easyconfig_file: (short_mod_name, mod_subdir, modpath_exts, user_modpath_exts, init_modpaths) + test_ecs = { + 'OpenMPI-2.1.2-GCC-6.4.0-2.28.eb': ('OpenMPI/2.1.2', 'releases/2018a', [], [], []), + 'GCCcore-4.9.3.eb': ('GCCcore/4.9.3', 'General', [], [], []), + 'gcccuda-2018a.eb': ('gcccuda/2018a', 'General', [], [], []), + 'toy-0.0-gompi-2018a.eb': ('toy/0.0', 'releases/2018a', [], [], []), + 'foss-2018a.eb': ('foss/2018a', 'General', [], [], []) + } + + for ecfile, mns_vals in test_ecs.items(): + test_ec(ecfile, *mns_vals) + + # test error for examples without toolchain-generation mapping in lookup table. EasyConfig() calls + # det_module_subdir() of the generationModuleNamingScheme object for the toolchain (binutils) + with self.assertRaises(EasyBuildError) as cm: + EasyConfig(glob.glob(os.path.join(ecs_dir, '*', '*', 'hwloc-1.6.2-GCC-4.9.3-2.26.eb'))[0]) + + msg = "Couldn't map software version (binutils, 2.26) to a generation. Provide a customtoolchain " \ + "mapping through GENERATION_MODULE_NAMING_SCHEME_LOOKUP_TABLE" + self.assertIn(msg, cm.exception.args[0]) + + # test lookup table extension with user-provided input. User-provided input (GCC 4.9.2 maps on 2018a) + # is provided through a file during setup at the start of the test case. + test_ecs_2 = { + 'bzip2-1.0.6-GCC-4.9.2.eb': ('bzip2/1.0.6', 'releases/2018a', [], [], []) + } + + for ecfile, mns_vals in test_ecs_2.items(): + test_ec(ecfile, *mns_vals) + def test_dependencies_for(self): """Test for dependencies_for function.""" expected = [