From 193e8333fa23a2e9b44d44a80e153d9a27033860 Mon Sep 17 00:00:00 2001 From: Massimiliano Culpo Date: Fri, 31 Jul 2020 12:58:48 +0200 Subject: Update packages.yaml format and support configuration updates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The YAML config for paths and modules of external packages has changed: the new format allows a single spec to load multiple modules. Spack will automatically convert from the old format when reading the configs (the updates do not add new essential properties, so this change in Spack is backwards-compatible). With this update, Spack cannot modify existing configs/environments without updating them (e.g. “spack config add” will fail if the configuration is in a format that predates this PR). The user is prompted to do this explicitly and commands are provided. All config scopes can be updated at once. Each environment must be updated one at a time. --- lib/spack/docs/build_settings.rst | 54 ++++--- lib/spack/docs/build_systems/intelpackage.rst | 32 +++-- lib/spack/docs/getting_started.rst | 57 +++++--- lib/spack/docs/workflows.rst | 5 +- lib/spack/llnl/util/filesystem.py | 35 ++++- lib/spack/spack/build_environment.py | 9 +- lib/spack/spack/cmd/config.py | 187 +++++++++++++++++++++++-- lib/spack/spack/cmd/env.py | 78 +++++++++++ lib/spack/spack/cmd/external.py | 33 +++-- lib/spack/spack/config.py | 69 ++++++++- lib/spack/spack/environment.py | 99 +++++++++++++ lib/spack/spack/installer.py | 4 +- lib/spack/spack/package_prefs.py | 33 +++-- lib/spack/spack/schema/__init__.py | 8 +- lib/spack/spack/schema/env.py | 22 +++ lib/spack/spack/schema/packages.py | 65 +++++++-- lib/spack/spack/spec.py | 84 ++++++++--- lib/spack/spack/test/cmd/ci.py | 12 +- lib/spack/spack/test/cmd/config.py | 140 ++++++++++++++++-- lib/spack/spack/test/cmd/env.py | 75 +++++++++- lib/spack/spack/test/cmd/external.py | 61 ++++---- lib/spack/spack/test/concretize.py | 2 +- lib/spack/spack/test/concretize_preferences.py | 10 +- lib/spack/spack/test/data/config/packages.yaml | 22 +-- lib/spack/spack/test/database.py | 6 +- lib/spack/spack/test/installer.py | 10 +- lib/spack/spack/test/module_parsing.py | 12 +- lib/spack/spack/util/module_cmd.py | 36 +++-- lib/spack/spack/util/spack_yaml.py | 18 ++- 29 files changed, 1063 insertions(+), 215 deletions(-) (limited to 'lib') diff --git a/lib/spack/docs/build_settings.rst b/lib/spack/docs/build_settings.rst index 9f67d8c14f..3e7a21c3e8 100644 --- a/lib/spack/docs/build_settings.rst +++ b/lib/spack/docs/build_settings.rst @@ -57,10 +57,13 @@ directory. Here's an example of an external configuration: packages: openmpi: - paths: - openmpi@1.4.3%gcc@4.4.7 arch=linux-debian7-x86_64: /opt/openmpi-1.4.3 - openmpi@1.4.3%gcc@4.4.7 arch=linux-debian7-x86_64+debug: /opt/openmpi-1.4.3-debug - openmpi@1.6.5%intel@10.1 arch=linux-debian7-x86_64: /opt/openmpi-1.6.5-intel + externals: + - spec: "openmpi@1.4.3%gcc@4.4.7 arch=linux-debian7-x86_64" + prefix: /opt/openmpi-1.4.3 + - spec: "openmpi@1.4.3%gcc@4.4.7 arch=linux-debian7-x86_64+debug" + prefix: /opt/openmpi-1.4.3-debug + - spec: "openmpi@1.6.5%intel@10.1 arch=linux-debian7-x86_64" + prefix: /opt/openmpi-1.6.5-intel This example lists three installations of OpenMPI, one built with GCC, one built with GCC and debug information, and another built with Intel. @@ -76,13 +79,15 @@ of the installation prefixes. The following example says that module .. code-block:: yaml cmake: - modules: - cmake@3.7.2: CMake/3.7.2 - -Each ``packages.yaml`` begins with a ``packages:`` token, followed -by a list of package names. To specify externals, add a ``paths`` or ``modules`` -token under the package name, which lists externals in a -``spec: /path`` or ``spec: module-name`` format. Each spec should be as + externals: + - spec: cmake@3.7.2 + modules: + - CMake/3.7.2 + +Each ``packages.yaml`` begins with a ``packages:`` attribute, followed +by a list of package names. To specify externals, add an ``externals:`` +attribute under the package name, which lists externals. +Each external should specify a ``spec:`` string that should be as well-defined as reasonably possible. If a package lacks a spec component, such as missing a compiler or package version, then Spack will guess the missing component based @@ -106,10 +111,13 @@ be: packages: openmpi: - paths: - openmpi@1.4.3%gcc@4.4.7 arch=linux-debian7-x86_64: /opt/openmpi-1.4.3 - openmpi@1.4.3%gcc@4.4.7 arch=linux-debian7-x86_64+debug: /opt/openmpi-1.4.3-debug - openmpi@1.6.5%intel@10.1 arch=linux-debian7-x86_64: /opt/openmpi-1.6.5-intel + externals: + - spec: "openmpi@1.4.3%gcc@4.4.7 arch=linux-debian7-x86_64" + prefix: /opt/openmpi-1.4.3 + - spec: "openmpi@1.4.3%gcc@4.4.7 arch=linux-debian7-x86_64+debug" + prefix: /opt/openmpi-1.4.3-debug + - spec: "openmpi@1.6.5%intel@10.1 arch=linux-debian7-x86_64" + prefix: /opt/openmpi-1.6.5-intel buildable: False The addition of the ``buildable`` flag tells Spack that it should never build @@ -137,10 +145,13 @@ but more conveniently: mpi: buildable: False openmpi: - paths: - openmpi@1.4.3%gcc@4.4.7 arch=linux-debian7-x86_64: /opt/openmpi-1.4.3 - openmpi@1.4.3%gcc@4.4.7 arch=linux-debian7-x86_64+debug: /opt/openmpi-1.4.3-debug - openmpi@1.6.5%intel@10.1 arch=linux-debian7-x86_64: /opt/openmpi-1.6.5-intel + externals: + - spec: "openmpi@1.4.3%gcc@4.4.7 arch=linux-debian7-x86_64" + prefix: /opt/openmpi-1.4.3 + - spec: "openmpi@1.4.3%gcc@4.4.7 arch=linux-debian7-x86_64+debug" + prefix: /opt/openmpi-1.4.3-debug + - spec: "openmpi@1.6.5%intel@10.1 arch=linux-debian7-x86_64" + prefix: /opt/openmpi-1.6.5-intel Implementations can also be listed immediately under the virtual they provide: @@ -172,8 +183,9 @@ After running this command your ``packages.yaml`` may include new entries: packages: cmake: - paths: - cmake@3.17.2: /usr + externals: + - spec: cmake@3.17.2 + prefix: /usr Generally this is useful for detecting a small set of commonly-used packages; for now this is generally limited to finding build-only dependencies. diff --git a/lib/spack/docs/build_systems/intelpackage.rst b/lib/spack/docs/build_systems/intelpackage.rst index 66f473cbf8..8594c8d425 100644 --- a/lib/spack/docs/build_systems/intelpackage.rst +++ b/lib/spack/docs/build_systems/intelpackage.rst @@ -418,9 +418,13 @@ Adapt the following example. Be sure to maintain the indentation: # other content ... intel-mkl: - modules: - intel-mkl@2018.2.199 arch=linux-centos6-x86_64: intel-mkl/18/18.0.2 - intel-mkl@2018.3.222 arch=linux-centos6-x86_64: intel-mkl/18/18.0.3 + externals: + - spec: "intel-mkl@2018.2.199 arch=linux-centos6-x86_64" + modules: + - intel-mkl/18/18.0.2 + - spec: "intel-mkl@2018.3.222 arch=linux-centos6-x86_64" + modules: + - intel-mkl/18/18.0.3 The version numbers for the ``intel-mkl`` specs defined here correspond to file and directory names that Intel uses for its products because they were adopted @@ -451,12 +455,16 @@ mechanism. packages: intel-parallel-studio: - modules: - intel-parallel-studio@cluster.2018.2.199 +mkl+mpi+ipp+tbb+daal arch=linux-centos6-x86_64: intel/18/18.0.2 - intel-parallel-studio@cluster.2018.3.222 +mkl+mpi+ipp+tbb+daal arch=linux-centos6-x86_64: intel/18/18.0.3 + externals: + - spec: "intel-parallel-studio@cluster.2018.2.199 +mkl+mpi+ipp+tbb+daal arch=linux-centos6-x86_64" + modules: + - intel/18/18.0.2 + - spec: "intel-parallel-studio@cluster.2018.3.222 +mkl+mpi+ipp+tbb+daal arch=linux-centos6-x86_64" + modules: + - intel/18/18.0.3 buildable: False -One additional example illustrates the use of ``paths:`` instead of +One additional example illustrates the use of ``prefix:`` instead of ``modules:``, useful when external modulefiles are not available or not suitable: @@ -464,13 +472,15 @@ suitable: packages: intel-parallel-studio: - paths: - intel-parallel-studio@cluster.2018.2.199 +mkl+mpi+ipp+tbb+daal: /opt/intel - intel-parallel-studio@cluster.2018.3.222 +mkl+mpi+ipp+tbb+daal: /opt/intel + externals: + - spec: "intel-parallel-studio@cluster.2018.2.199 +mkl+mpi+ipp+tbb+daal" + prefix: /opt/intel + - spec: "intel-parallel-studio@cluster.2018.3.222 +mkl+mpi+ipp+tbb+daal" + prefix: /opt/intel buildable: False Note that for the Intel packages discussed here, the directory values in the -``paths:`` entries must be the high-level and typically version-less +``prefix:`` entries must be the high-level and typically version-less "installation directory" that has been used by Intel's product installer. Such a directory will typically accumulate various product versions. Amongst them, Spack will select the correct version-specific product directory based on diff --git a/lib/spack/docs/getting_started.rst b/lib/spack/docs/getting_started.rst index 550f2509e6..26deb1cef5 100644 --- a/lib/spack/docs/getting_started.rst +++ b/lib/spack/docs/getting_started.rst @@ -712,8 +712,9 @@ an OpenMPI installed in /opt/local, one would use: packages: openmpi: - paths: - openmpi@1.10.1: /opt/local + externals: + - spec: openmpi@1.10.1 + prefix: /opt/local buildable: False In general, Spack is easier to use and more reliable if it builds all of @@ -775,8 +776,9 @@ Then add the following to ``~/.spack/packages.yaml``: packages: openssl: - paths: - openssl@1.0.2g: /usr + externals: + - spec: openssl@1.0.2g + prefix: /usr buildable: False @@ -791,8 +793,9 @@ to add the following to ``packages.yaml``: packages: netlib-lapack: - paths: - netlib-lapack@3.6.1: /usr + externals: + - spec: netlib-lapack@3.6.1 + prefix: /usr buildable: False all: providers: @@ -1181,9 +1184,13 @@ Here's an example of an external configuration for cray modules: packages: mpich: - modules: - mpich@7.3.1%gcc@5.2.0 arch=cray_xc-haswell-CNL10: cray-mpich - mpich@7.3.1%intel@16.0.0.109 arch=cray_xc-haswell-CNL10: cray-mpich + externals: + - spec: "mpich@7.3.1%gcc@5.2.0 arch=cray_xc-haswell-CNL10" + modules: + - cray-mpich + - spec: "mpich@7.3.1%intel@16.0.0.109 arch=cray_xc-haswell-CNL10" + modules: + - cray-mpich all: providers: mpi: [mpich] @@ -1195,7 +1202,7 @@ via module load. .. note:: - For Cray-provided packages, it is best to use ``modules:`` instead of ``paths:`` + For Cray-provided packages, it is best to use ``modules:`` instead of ``prefix:`` in ``packages.yaml``, because the Cray Programming Environment heavily relies on modules (e.g., loading the ``cray-mpich`` module adds MPI libraries to the compiler wrapper link line). @@ -1211,19 +1218,31 @@ Here is an example of a full packages.yaml used at NERSC packages: mpich: - modules: - mpich@7.3.1%gcc@5.2.0 arch=cray_xc-CNL10-ivybridge: cray-mpich - mpich@7.3.1%intel@16.0.0.109 arch=cray_xc-SuSE11-ivybridge: cray-mpich + externals: + - spec: "mpich@7.3.1%gcc@5.2.0 arch=cray_xc-CNL10-ivybridge" + modules: + - cray-mpich + - spec: "mpich@7.3.1%intel@16.0.0.109 arch=cray_xc-SuSE11-ivybridge" + modules: + - cray-mpich buildable: False netcdf: - modules: - netcdf@4.3.3.1%gcc@5.2.0 arch=cray_xc-CNL10-ivybridge: cray-netcdf - netcdf@4.3.3.1%intel@16.0.0.109 arch=cray_xc-CNL10-ivybridge: cray-netcdf + externals: + - spec: "netcdf@4.3.3.1%gcc@5.2.0 arch=cray_xc-CNL10-ivybridge" + modules: + - cray-netcdf + - spec: "netcdf@4.3.3.1%intel@16.0.0.109 arch=cray_xc-CNL10-ivybridge" + modules: + - cray-netcdf buildable: False hdf5: - modules: - hdf5@1.8.14%gcc@5.2.0 arch=cray_xc-CNL10-ivybridge: cray-hdf5 - hdf5@1.8.14%intel@16.0.0.109 arch=cray_xc-CNL10-ivybridge: cray-hdf5 + externals: + - spec: "hdf5@1.8.14%gcc@5.2.0 arch=cray_xc-CNL10-ivybridge" + modules: + - cray-hdf5 + - spec: "hdf5@1.8.14%intel@16.0.0.109 arch=cray_xc-CNL10-ivybridge" + modules: + - cray-hdf5 buildable: False all: compiler: [gcc@5.2.0, intel@16.0.0.109] diff --git a/lib/spack/docs/workflows.rst b/lib/spack/docs/workflows.rst index 2f215f8209..7deb5eff54 100644 --- a/lib/spack/docs/workflows.rst +++ b/lib/spack/docs/workflows.rst @@ -1545,8 +1545,9 @@ Avoid double-installing CUDA by adding, e.g. packages: cuda: - paths: - cuda@9.0.176%gcc@5.4.0 arch=linux-ubuntu16-x86_64: /usr/local/cuda + externals: + - spec: "cuda@9.0.176%gcc@5.4.0 arch=linux-ubuntu16-x86_64" + prefix: /usr/local/cuda buildable: False to your ``packages.yaml``. diff --git a/lib/spack/llnl/util/filesystem.py b/lib/spack/llnl/util/filesystem.py index bee6e1e43c..5e08273677 100644 --- a/lib/spack/llnl/util/filesystem.py +++ b/lib/spack/llnl/util/filesystem.py @@ -2,7 +2,6 @@ # Spack Project Developers. See the top-level COPYRIGHT file for details. # # SPDX-License-Identifier: (Apache-2.0 OR MIT) - import collections import errno import hashlib @@ -377,17 +376,17 @@ def install(src, dest): copy(src, dest, _permissions=True) -def resolve_link_target_relative_to_the_link(l): +def resolve_link_target_relative_to_the_link(link): """ os.path.isdir uses os.path.exists, which for links will check the existence of the link target. If the link target is relative to the link, we need to construct a pathname that is valid from our cwd (which may not be the same as the link's directory) """ - target = os.readlink(l) + target = os.readlink(link) if os.path.isabs(target): return target - link_dir = os.path.dirname(os.path.abspath(l)) + link_dir = os.path.dirname(os.path.abspath(link)) return os.path.join(link_dir, target) @@ -1570,6 +1569,19 @@ def can_access_dir(path): return os.path.isdir(path) and os.access(path, os.R_OK | os.X_OK) +@memoized +def can_write_to_dir(path): + """Return True if the argument is a directory in which we can write. + + Args: + path: path to be tested + + Returns: + True if ``path`` is an writeable directory, else False + """ + return os.path.isdir(path) and os.access(path, os.R_OK | os.X_OK | os.W_OK) + + @memoized def files_in(*search_paths): """Returns all the files in paths passed as arguments. @@ -1683,3 +1695,18 @@ def prefixes(path): pass return paths + + +def md5sum(file): + """Compute the MD5 sum of a file. + + Args: + file (str): file to be checksummed + + Returns: + MD5 sum of the file's content + """ + md5 = hashlib.md5() + with open(file, "rb") as f: + md5.update(f.read()) + return md5.digest() diff --git a/lib/spack/spack/build_environment.py b/lib/spack/spack/build_environment.py index 7ef2126766..7206ddb247 100644 --- a/lib/spack/spack/build_environment.py +++ b/lib/spack/spack/build_environment.py @@ -62,7 +62,7 @@ from spack.util.environment import ( from spack.util.environment import system_dirs from spack.error import NoLibrariesError, NoHeadersError from spack.util.executable import Executable -from spack.util.module_cmd import load_module, get_path_from_module, module +from spack.util.module_cmd import load_module, path_from_modules, module from spack.util.log_parse import parse_log_events, make_log_context @@ -642,7 +642,7 @@ def get_rpaths(pkg): # Second module is our compiler mod name. We use that to get rpaths from # module show output. if pkg.compiler.modules and len(pkg.compiler.modules) > 1: - rpaths.append(get_path_from_module(pkg.compiler.modules[1])) + rpaths.append(path_from_modules(pkg.compiler.modules[1])) return list(dedupe(filter_system_paths(rpaths))) @@ -706,8 +706,9 @@ def load_external_modules(pkg): pkg (PackageBase): package to load deps for """ for dep in list(pkg.spec.traverse()): - if dep.external_module: - load_module(dep.external_module) + external_modules = dep.external_modules or [] + for external_module in external_modules: + load_module(external_module) def setup_package(pkg, dirty): diff --git a/lib/spack/spack/cmd/config.py b/lib/spack/spack/cmd/config.py index 954d4e4585..e684364d8a 100644 --- a/lib/spack/spack/cmd/config.py +++ b/lib/spack/spack/cmd/config.py @@ -2,16 +2,20 @@ # Spack Project Developers. See the top-level COPYRIGHT file for details. # # SPDX-License-Identifier: (Apache-2.0 OR MIT) - from __future__ import print_function + +import collections import os import re +import shutil +import llnl.util.filesystem as fs import llnl.util.tty as tty - import spack.config +import spack.cmd.common.arguments import spack.schema.env import spack.environment as ev +import spack.schema.packages import spack.util.spack_yaml as syaml from spack.util.editor import editor @@ -80,6 +84,19 @@ def setup_parser(subparser): # Make the add parser available later setup_parser.add_parser = add_parser + update = sp.add_parser( + 'update', help='update configuration files to the latest format' + ) + spack.cmd.common.arguments.add_common_arguments(update, ['yes_to_all']) + update.add_argument('section', help='section to update') + + revert = sp.add_parser( + 'revert', + help='revert configuration files to their state before update' + ) + spack.cmd.common.arguments.add_common_arguments(revert, ['yes_to_all']) + revert.add_argument('section', help='section to update') + def _get_scope_and_section(args): """Extract config scope and section from arguments.""" @@ -275,12 +292,164 @@ def config_remove(args): set_config(args, path, existing, scope) +def _can_update_config_file(scope_dir, cfg_file): + dir_ok = fs.can_write_to_dir(scope_dir) + cfg_ok = fs.can_access(cfg_file) + return dir_ok and cfg_ok + + +def config_update(args): + # Read the configuration files + spack.config.config.get_config(args.section, scope=args.scope) + updates = spack.config.config.format_updates[args.section] + + cannot_overwrite, skip_system_scope = [], False + for scope in updates: + cfg_file = spack.config.config.get_config_filename( + scope.name, args.section + ) + scope_dir = scope.path + can_be_updated = _can_update_config_file(scope_dir, cfg_file) + if not can_be_updated: + if scope.name == 'system': + skip_system_scope = True + msg = ('Not enough permissions to write to "system" scope. ' + 'Skipping update at that location [cfg={0}]') + tty.warn(msg.format(cfg_file)) + continue + cannot_overwrite.append((scope, cfg_file)) + + if cannot_overwrite: + msg = 'Detected permission issues with the following scopes:\n\n' + for scope, cfg_file in cannot_overwrite: + msg += '\t[scope={0}, cfg={1}]\n'.format(scope.name, cfg_file) + msg += ('\nEither ensure that you have sufficient permissions to ' + 'modify these files or do not include these scopes in the ' + 'update.') + tty.die(msg) + + if skip_system_scope: + updates = [x for x in updates if x.name != 'system'] + + # Report if there are no updates to be done + if not updates: + msg = 'No updates needed for "{0}" section.' + tty.msg(msg.format(args.section)) + return + + proceed = True + if not args.yes_to_all: + msg = ('The following configuration files are going to be updated to' + ' the latest schema format:\n\n') + for scope in updates: + cfg_file = spack.config.config.get_config_filename( + scope.name, args.section + ) + msg += '\t[scope={0}, file={1}]\n'.format(scope.name, cfg_file) + msg += ('\nIf the configuration files are updated, versions of Spack ' + 'that are older than this version may not be able to read ' + 'them. Spack stores backups of the updated files which can ' + 'be retrieved with "spack config revert"') + tty.msg(msg) + proceed = tty.get_yes_or_no('Do you want to proceed?', default=False) + + if not proceed: + tty.die('Operation aborted.') + + # Get a function to update the format + update_fn = spack.config.ensure_latest_format_fn(args.section) + for scope in updates: + cfg_file = spack.config.config.get_config_filename( + scope.name, args.section + ) + with open(cfg_file) as f: + data = syaml.load(f) or {} + data = data.pop(args.section, {}) + update_fn(data) + + # Make a backup copy and rewrite the file + bkp_file = cfg_file + '.bkp' + shutil.copy(cfg_file, bkp_file) + spack.config.config.update_config( + args.section, data, scope=scope.name, force=True + ) + msg = 'File "{0}" updated [backup={1}]' + tty.msg(msg.format(cfg_file, bkp_file)) + + +def _can_revert_update(scope_dir, cfg_file, bkp_file): + dir_ok = fs.can_write_to_dir(scope_dir) + cfg_ok = not os.path.exists(cfg_file) or fs.can_access(cfg_file) + bkp_ok = fs.can_access(bkp_file) + return dir_ok and cfg_ok and bkp_ok + + +def config_revert(args): + scopes = [args.scope] if args.scope else [ + x.name for x in spack.config.config.file_scopes + ] + + # Search for backup files in the configuration scopes + Entry = collections.namedtuple('Entry', ['scope', 'cfg', 'bkp']) + to_be_restored, cannot_overwrite = [], [] + for scope in scopes: + cfg_file = spack.config.config.get_config_filename(scope, args.section) + bkp_file = cfg_file + '.bkp' + + # If the backup files doesn't exist move to the next scope + if not os.path.exists(bkp_file): + continue + + # If it exists and we don't have write access in this scope + # keep track of it and report a comprehensive error later + entry = Entry(scope, cfg_file, bkp_file) + scope_dir = os.path.dirname(bkp_file) + can_be_reverted = _can_revert_update(scope_dir, cfg_file, bkp_file) + if not can_be_reverted: + cannot_overwrite.append(entry) + continue + + to_be_restored.append(entry) + + # Report errors if we can't revert a configuration + if cannot_overwrite: + msg = 'Detected permission issues with the following scopes:\n\n' + for e in cannot_overwrite: + msg += '\t[scope={0.scope}, cfg={0.cfg}, bkp={0.bkp}]\n'.format(e) + msg += ('\nEither ensure to have the right permissions before retrying' + ' or be more specific on the scope to revert.') + tty.die(msg) + + proceed = True + if not args.yes_to_all: + msg = ('The following scopes will be restored from the corresponding' + ' backup files:\n') + for entry in to_be_restored: + msg += '\t[scope={0.scope}, bkp={0.bkp}]\n'.format(entry) + msg += 'This operation cannot be undone.' + tty.msg(msg) + proceed = tty.get_yes_or_no('Do you want to proceed?', default=False) + + if not proceed: + tty.die('Operation aborted.') + + for _, cfg_file, bkp_file in to_be_restored: + shutil.copy(bkp_file, cfg_file) + os.unlink(bkp_file) + msg = 'File "{0}" reverted to old state' + tty.msg(msg.format(cfg_file)) + + def config(parser, args): - action = {'get': config_get, - 'blame': config_blame, - 'edit': config_edit, - 'list': config_list, - 'add': config_add, - 'rm': config_remove, - 'remove': config_remove} + action = { + 'get': config_get, + 'blame': config_blame, + 'edit': config_edit, + 'list': config_list, + 'add': config_add, + 'rm': config_remove, + 'remove': config_remove, + 'update': config_update, + 'revert': config_revert + } action[args.config_command](args) diff --git a/lib/spack/spack/cmd/env.py b/lib/spack/spack/cmd/env.py index e3c45cc27b..7bd8052528 100644 --- a/lib/spack/spack/cmd/env.py +++ b/lib/spack/spack/cmd/env.py @@ -4,6 +4,7 @@ # SPDX-License-Identifier: (Apache-2.0 OR MIT) import os +import shutil import sys from collections import namedtuple @@ -14,6 +15,7 @@ from llnl.util.tty.color import colorize import spack.config import spack.schema.env +import spack.cmd.common.arguments import spack.cmd.install import spack.cmd.uninstall import spack.cmd.modules @@ -37,6 +39,8 @@ subcommands = [ ['status', 'st'], 'loads', 'view', + 'update', + 'revert' ] @@ -394,6 +398,80 @@ def env_loads(args): print(' source %s' % loads_file) +def env_update_setup_parser(subparser): + """update environments to the latest format""" + subparser.add_argument( + metavar='env', dest='env', + help='name or directory of the environment to activate' + ) + spack.cmd.common.arguments.add_common_arguments(subparser, ['yes_to_all']) + + +def env_update(args): + manifest_file = ev.manifest_file(args.env) + backup_file = manifest_file + ".bkp" + needs_update = not ev.is_latest_format(manifest_file) + + if not needs_update: + tty.msg('No update needed for the environment "{0}"'.format(args.env)) + return + + proceed = True + if not args.yes_to_all: + msg = ('The environment "{0}" is going to be updated to the latest ' + 'schema format.\nIf the environment is updated, versions of ' + 'Spack that are older than this version may not be able to ' + 'read it. Spack stores backups of the updated environment ' + 'which can be retrieved with "spack env revert"') + tty.msg(msg.format(args.env)) + proceed = tty.get_yes_or_no('Do you want to proceed?', default=False) + + if not proceed: + tty.die('Operation aborted.') + + ev.update_yaml(manifest_file, backup_file=backup_file) + msg = 'Environment "{0}" has been updated [backup={1}]' + tty.msg(msg.format(args.env, backup_file)) + + +def env_revert_setup_parser(subparser): + """restore environments to their state before update""" + subparser.add_argument( + metavar='env', dest='env', + help='name or directory of the environment to activate' + ) + spack.cmd.common.arguments.add_common_arguments(subparser, ['yes_to_all']) + + +def env_revert(args): + manifest_file = ev.manifest_file(args.env) + backup_file = manifest_file + ".bkp" + + # Check that both the spack.yaml and the backup exist, the inform user + # on what is going to happen and ask for confirmation + if not os.path.exists(manifest_file): + msg = 'cannot fine the manifest file of the environment [file={0}]' + tty.die(msg.format(manifest_file)) + if not os.path.exists(backup_file): + msg = 'cannot find the old manifest file to be restored [file={0}]' + tty.die(msg.format(backup_file)) + + proceed = True + if not args.yes_to_all: + msg = ('Spack is going to overwrite the current manifest file' + ' with a backup copy [manifest={0}, backup={1}]') + tty.msg(msg.format(manifest_file, backup_file)) + proceed = tty.get_yes_or_no('Do you want to proceed?', default=False) + + if not proceed: + tty.die('Operation aborted.') + + shutil.copy(backup_file, manifest_file) + os.remove(backup_file) + msg = 'Environment "{0}" reverted to old state' + tty.msg(msg.format(manifest_file)) + + #: Dictionary mapping subcommand names and aliases to functions subcommand_functions = {} diff --git a/lib/spack/spack/cmd/external.py b/lib/spack/spack/cmd/external.py index afdd40e2a0..6fe7825070 100644 --- a/lib/spack/spack/cmd/external.py +++ b/lib/spack/spack/cmd/external.py @@ -74,19 +74,28 @@ def _generate_pkg_config(external_pkg_entries): This does not generate the entire packages.yaml. For example, given some external entries for the CMake package, this could return:: - { 'paths': { - 'cmake@3.17.1': '/opt/cmake-3.17.1/', - 'cmake@3.16.5': '/opt/cmake-3.16.5/' - } + { + 'externals': [{ + 'spec': 'cmake@3.17.1', + 'prefix': '/opt/cmake-3.17.1/' + }, { + 'spec': 'cmake@3.16.5', + 'prefix': '/opt/cmake-3.16.5/' + }] } """ - paths_dict = syaml.syaml_dict() + + pkg_dict = syaml.syaml_dict() + pkg_dict['externals'] = [] for e in external_pkg_entries: if not _spec_is_valid(e.spec): continue - paths_dict[str(e.spec)] = e.base_dir - pkg_dict = syaml.syaml_dict() - pkg_dict['paths'] = paths_dict + + external_items = [('spec', str(e.spec)), ('prefix', e.base_dir)] + external_items.extend(e.spec.extra_attributes.items()) + pkg_dict['externals'].append( + syaml.syaml_dict(external_items) + ) return pkg_dict @@ -259,6 +268,14 @@ def _get_external_packages(packages_to_check, system_path_to_exe=None): else: resolved_specs[spec] = prefix + try: + spec.validate_detection() + except Exception as e: + msg = ('"{0}" has been detected on the system but will ' + 'not be added to packages.yaml [{1}]') + tty.warn(msg.format(spec, str(e))) + continue + pkg_to_entries[pkg.name].append( ExternalPackageEntry(spec=spec, base_dir=pkg_prefix)) diff --git a/lib/spack/spack/config.py b/lib/spack/spack/config.py index a3d8101cad..425fcec8ee 100644 --- a/lib/spack/spack/config.py +++ b/lib/spack/spack/config.py @@ -30,6 +30,7 @@ schemas are in submodules of :py:mod:`spack.schema`. """ +import collections import copy import os import re @@ -352,6 +353,7 @@ class Configuration(object): self.scopes = OrderedDict() for scope in scopes: self.push_scope(scope) + self.format_updates = collections.defaultdict(list) def push_scope(self, scope): """Add a higher precedence scope to the Configuration.""" @@ -440,7 +442,7 @@ class Configuration(object): for scope in self.scopes.values(): scope.clear() - def update_config(self, section, update_data, scope=None): + def update_config(self, section, update_data, scope=None, force=False): """Update the configuration file for a particular scope. Overwrites contents of a section in a scope with update_data, @@ -449,7 +451,26 @@ class Configuration(object): update_data should have the top-level section name stripped off (it will be re-added). Data itself can be a list, dict, or any other yaml-ish structure. + + Configuration scopes that are still written in an old schema + format will fail to update unless ``force`` is True. + + Args: + section (str): section of the configuration to be updated + update_data (dict): data to be used for the update + scope (str): scope to be updated + force (str): force the update """ + if self.format_updates.get(section) and not force: + msg = ('The "{0}" section of the configuration needs to be written' + ' to disk, but is currently using a deprecated format. ' + 'Please update it using:\n\n' + '\tspack config [--scope= 1: @@ -1025,7 +1027,7 @@ class Spec(object): @property def external(self): - return bool(self.external_path) or bool(self.external_module) + return bool(self.external_path) or bool(self.external_modules) def get_dependency(self, name): dep = self._dependencies.get(name) @@ -1526,7 +1528,8 @@ class Spec(object): if self.external: d['external'] = syaml.syaml_dict([ ('path', self.external_path), - ('module', self.external_module), + ('module', self.external_modules), + ('extra_attributes', self.extra_attributes) ]) if not self._concrete: @@ -1695,21 +1698,21 @@ class Spec(object): for name in FlagMap.valid_compiler_flags(): spec.compiler_flags[name] = [] + spec.external_path = None + spec.external_modules = None if 'external' in node: - spec.external_path = None - spec.external_module = None # This conditional is needed because sometimes this function is # called with a node already constructed that contains a 'versions' # and 'external' field. Related to virtual packages provider # indexes. if node['external']: spec.external_path = node['external']['path'] - spec.external_module = node['external']['module'] - if spec.external_module is False: - spec.external_module = None - else: - spec.external_path = None - spec.external_module = None + spec.external_modules = node['external']['module'] + if spec.external_modules is False: + spec.external_modules = None + spec.extra_attributes = node['external'].get( + 'extra_attributes', syaml.syaml_dict() + ) # specs read in are concrete unless marked abstract spec._concrete = node.get('concrete', True) @@ -1970,6 +1973,44 @@ class Spec(object): tty.debug(e) raise sjson.SpackJSONError("error parsing JSON spec:", str(e)) + @staticmethod + def from_detection(spec_str, extra_attributes=None): + """Construct a spec from a spec string determined during external + detection and attach extra attributes to it. + + Args: + spec_str (str): spec string + extra_attributes (dict): dictionary containing extra attributes + + Returns: + spack.spec.Spec: external spec + """ + s = Spec(spec_str) + extra_attributes = syaml.sorted_dict(extra_attributes or {}) + # This is needed to be able to validate multi-valued variants, + # otherwise they'll still be abstract in the context of detection. + vt.substitute_abstract_variants(s) + s.extra_attributes = extra_attributes + return s + + def validate_detection(self): + """Validate the detection of an external spec. + + This method is used as part of Spack's detection protocol, and is + not meant for client code use. + """ + # Assert that _extra_attributes is a Mapping and not None, + # which likely means the spec was created with Spec.from_detection + msg = ('cannot validate "{0}" since it was not created ' + 'using Spec.from_detection'.format(self)) + assert isinstance(self.extra_attributes, collections.Mapping), msg + + # Validate the spec calling a package specific method + validate_fn = getattr( + self.package, 'validate_detected_spec', lambda x, y: None + ) + validate_fn(self, self.extra_attributes) + def _concretize_helper(self, concretizer, presets=None, visited=None): """Recursive helper function for concretize(). This concretizes everything bottom-up. As things are @@ -2115,8 +2156,8 @@ class Spec(object): feq(replacement.variants, spec.variants) and feq(replacement.external_path, spec.external_path) and - feq(replacement.external_module, - spec.external_module)): + feq(replacement.external_modules, + spec.external_modules)): continue # Refine this spec to the candidate. This uses # replace_with AND dup so that it can work in @@ -2250,7 +2291,7 @@ class Spec(object): t[-1] for t in ordered_hashes) for s in self.traverse(): - if s.external_module and not s.external_path: + if s.external_modules and not s.external_path: compiler = spack.compilers.compiler_for_spec( s.compiler, s.architecture) for mod in compiler.modules: @@ -2259,8 +2300,8 @@ class Spec(object): # get the path from the module # the package can override the default s.external_path = getattr(s.package, 'external_prefix', - md.get_path_from_module( - s.external_module)) + md.path_from_modules( + s.external_modules)) # Mark everything in the spec as concrete, as well. self._mark_concrete() @@ -3046,7 +3087,7 @@ class Spec(object): self._normal != other._normal and self.concrete != other.concrete and self.external_path != other.external_path and - self.external_module != other.external_module and + self.external_modules != other.external_modules and self.compiler_flags != other.compiler_flags) self._package = None @@ -3074,7 +3115,8 @@ class Spec(object): self.variants.spec = self self.external_path = other.external_path - self.external_module = other.external_module + self.external_modules = other.external_modules + self.extra_attributes = other.extra_attributes self.namespace = other.namespace # Cached fields are results of expensive operations. diff --git a/lib/spack/spack/test/cmd/ci.py b/lib/spack/spack/test/cmd/ci.py index afa7c7fc07..7e61a885ff 100644 --- a/lib/spack/spack/test/cmd/ci.py +++ b/lib/spack/spack/test/cmd/ci.py @@ -527,14 +527,10 @@ spack: ci_cmd('generate', '--output-file', outputfile) with open(outputfile) as f: - contents = f.read() - print('generated contents: ') - print(contents) - yaml_contents = syaml.load(contents) - for ci_key in yaml_contents.keys(): - if 'externaltool' in ci_key: - print('Erroneously staged "externaltool" pkg') - assert(False) + yaml_contents = syaml.load(f) + + # Check that the "externaltool" package was not erroneously staged + assert not any('externaltool' in key for key in yaml_contents) def test_ci_generate_debug_with_custom_spack(tmpdir, mutable_mock_env_path, diff --git a/lib/spack/spack/test/cmd/config.py b/lib/spack/spack/test/cmd/config.py index 6dbf50676d..524636fed6 100644 --- a/lib/spack/spack/test/cmd/config.py +++ b/lib/spack/spack/test/cmd/config.py @@ -2,17 +2,40 @@ # Spack Project Developers. See the top-level COPYRIGHT file for details. # # SPDX-License-Identifier: (Apache-2.0 OR MIT) -import pytest import os -from llnl.util.filesystem import mkdirp +import pytest +import llnl.util.filesystem as fs import spack.config import spack.environment as ev -from spack.main import SpackCommand - -config = SpackCommand('config') -env = SpackCommand('env') +import spack.main +import spack.util.spack_yaml as syaml + +config = spack.main.SpackCommand('config') +env = spack.main.SpackCommand('env') + + +@pytest.fixture() +def packages_yaml_v015(mutable_config): + """Create a packages.yaml in the old format""" + def _create(scope=None): + old_data = { + 'packages': { + 'cmake': { + 'paths': {'cmake@3.14.0': '/usr'} + }, + 'gcc': { + 'modules': {'gcc@8.3.0': 'gcc-8'} + } + } + } + scope = scope or spack.config.default_modify_scope() + cfg_file = spack.config.config.get_config_filename(scope, 'packages') + with open(cfg_file, 'w') as f: + syaml.dump(old_data, stream=f) + return cfg_file + return _create def test_get_config_scope(mock_low_high_config): @@ -23,8 +46,8 @@ def test_get_config_scope_merged(mock_low_high_config): low_path = mock_low_high_config.scopes['low'].path high_path = mock_low_high_config.scopes['high'].path - mkdirp(low_path) - mkdirp(high_path) + fs.mkdirp(low_path) + fs.mkdirp(high_path) with open(os.path.join(low_path, 'repos.yaml'), 'w') as f: f.write('''\ @@ -403,3 +426,104 @@ def test_config_remove_from_env(mutable_empty_config, mutable_mock_env_path): """ assert output == expected + + +def test_config_update_packages(packages_yaml_v015): + """Test Spack updating old packages.yaml format for externals + to new format. Ensure that data is preserved and converted + properly. + """ + packages_yaml_v015() + config('update', '-y', 'packages') + + # Check the entries have been transformed + data = spack.config.get('packages') + check_update(data) + + +def test_config_update_not_needed(mutable_config): + data_before = spack.config.get('repos') + config('update', '-y', 'repos') + data_after = spack.config.get('repos') + assert data_before == data_after + + +def test_config_update_fail_on_permission_issue( + packages_yaml_v015, monkeypatch +): + # The first time it will update and create the backup file + packages_yaml_v015() + # Mock a global scope where we cannot write + monkeypatch.setattr( + spack.cmd.config, '_can_update_config_file', lambda x, y: False + ) + with pytest.raises(spack.main.SpackCommandError): + config('update', '-y', 'packages') + + +def test_config_revert(packages_yaml_v015): + cfg_file = packages_yaml_v015() + bkp_file = cfg_file + '.bkp' + + config('update', '-y', 'packages') + + # Check that the backup file exists, compute its md5 sum + assert os.path.exists(bkp_file) + md5bkp = fs.md5sum(bkp_file) + + config('revert', '-y', 'packages') + + # Check that the backup file does not exist anymore and + # that the md5 sum of the configuration file is the same + # as that of the old backup file + assert not os.path.exists(bkp_file) + assert md5bkp == fs.md5sum(cfg_file) + + +def test_config_revert_raise_if_cant_write(packages_yaml_v015, monkeypatch): + packages_yaml_v015() + config('update', '-y', 'packages') + + # Mock a global scope where we cannot write + monkeypatch.setattr( + spack.cmd.config, '_can_revert_update', lambda x, y, z: False + ) + # The command raises with an helpful error if a configuration + # file is to be deleted and we don't have sufficient permissions + with pytest.raises(spack.main.SpackCommandError): + config('revert', '-y', 'packages') + + +def test_updating_config_implicitly_raises(packages_yaml_v015): + # Trying to write implicitly to a scope with a configuration file + # in the old format raises an exception + packages_yaml_v015() + with pytest.raises(RuntimeError): + config('add', 'packages:cmake:buildable:false') + + +def test_updating_multiple_scopes_at_once(packages_yaml_v015): + # Create 2 config files in the old format + packages_yaml_v015(scope='user') + packages_yaml_v015(scope='site') + + # Update both of them at once + config('update', '-y', 'packages') + + for scope in ('user', 'site'): + data = spack.config.get('packages', scope=scope) + check_update(data) + + +def check_update(data): + """Check that the data from the packages_yaml_v015 + has been updated. + """ + assert 'externals' in data['cmake'] + externals = data['cmake']['externals'] + assert {'spec': 'cmake@3.14.0', 'prefix': '/usr'} in externals + assert 'paths' not in data['cmake'] + assert 'externals' in data['gcc'] + externals = data['gcc']['externals'] + assert {'spec': 'gcc@8.3.0', 'modules': ['gcc-8']} in externals + assert 'modules' not in data['gcc'] diff --git a/lib/spack/spack/test/cmd/env.py b/lib/spack/spack/test/cmd/env.py index 87f7a58667..955693ca0f 100644 --- a/lib/spack/spack/test/cmd/env.py +++ b/lib/spack/spack/test/cmd/env.py @@ -448,8 +448,9 @@ env: external_config = StringIO("""\ packages: a: - paths: - a: {a_prefix} + externals: + - spec: a + prefix: {a_prefix} buildable: false """.format(a_prefix=str(fake_prefix))) external_config_dict = spack.util.spack_yaml.load_config(external_config) @@ -2041,3 +2042,73 @@ def test_env_write_only_non_default(): yaml = f.read() assert yaml == ev.default_manifest_yaml + + +@pytest.fixture +def packages_yaml_v015(tmpdir): + """Return the path to an existing manifest in the v0.15.x format + and the path to a non yet existing backup file. + """ + raw_yaml = """ +spack: + specs: + - mpich + packages: + cmake: + paths: + cmake@3.17.3: /usr +""" + manifest = tmpdir.ensure('spack.yaml') + backup_file = tmpdir.join('spack.yaml.bkp') + manifest.write(raw_yaml) + return manifest, backup_file + + +def test_update_anonymous_env(packages_yaml_v015): + manifest, backup_file = packages_yaml_v015 + env('update', '-y', str(manifest.dirname)) + + # The environment is now at the latest format + assert ev.is_latest_format(str(manifest)) + # A backup file has been created and it's not at the latest format + assert os.path.exists(str(backup_file)) + assert not ev.is_latest_format(str(backup_file)) + + +def test_double_update(packages_yaml_v015): + manifest, backup_file = packages_yaml_v015 + + # Update the environment + env('update', '-y', str(manifest.dirname)) + # Try to read the environment (it should not error) + ev.create('test', str(manifest)) + # Updating again does nothing since the manifest is up-to-date + env('update', '-y', str(manifest.dirname)) + + # The environment is at the latest format + assert ev.is_latest_format(str(manifest)) + # A backup file has been created and it's not at the latest format + assert os.path.exists(str(backup_file)) + assert not ev.is_latest_format(str(backup_file)) + + +def test_update_and_revert(packages_yaml_v015): + manifest, backup_file = packages_yaml_v015 + + # Update the environment + env('update', '-y', str(manifest.dirname)) + assert os.path.exists(str(backup_file)) + assert not ev.is_latest_format(str(backup_file)) + assert ev.is_latest_format(str(manifest)) + + # Revert to previous state + env('revert', '-y', str(manifest.dirname)) + assert not os.path.exists(str(backup_file)) + assert not ev.is_latest_format(str(manifest)) + + +def test_old_format_cant_be_updated_implicitly(packages_yaml_v015): + manifest, backup_file = packages_yaml_v015 + env('activate', str(manifest.dirname)) + with pytest.raises(spack.main.SpackCommandError): + add('hdf5') diff --git a/lib/spack/spack/test/cmd/external.py b/lib/spack/spack/test/cmd/external.py index 0bdf67fe3e..31175843ec 100644 --- a/lib/spack/spack/test/cmd/external.py +++ b/lib/spack/spack/test/cmd/external.py @@ -70,21 +70,20 @@ def test_find_external_two_instances_same_package(create_exe): def test_find_external_update_config(mutable_config): - pkg_to_entries = { - 'cmake': [ - ExternalPackageEntry(Spec('cmake@1.foo'), '/x/y1/'), - ExternalPackageEntry(Spec('cmake@3.17.2'), '/x/y2/'), - ] - } + entries = [ + ExternalPackageEntry(Spec.from_detection('cmake@1.foo'), '/x/y1/'), + ExternalPackageEntry(Spec.from_detection('cmake@3.17.2'), '/x/y2/'), + ] + pkg_to_entries = {'cmake': entries} spack.cmd.external._update_pkg_config(pkg_to_entries, False) pkgs_cfg = spack.config.get('packages') cmake_cfg = pkgs_cfg['cmake'] - cmake_paths_cfg = cmake_cfg['paths'] + cmake_externals = cmake_cfg['externals'] - assert cmake_paths_cfg['cmake@1.foo'] == '/x/y1/' - assert cmake_paths_cfg['cmake@3.17.2'] == '/x/y2/' + assert {'spec': 'cmake@1.foo', 'prefix': '/x/y1/'} in cmake_externals + assert {'spec': 'cmake@3.17.2', 'prefix': '/x/y2/'} in cmake_externals def test_get_executables(working_env, create_exe): @@ -103,15 +102,16 @@ def test_find_external_cmd(mutable_config, working_env, create_exe): which restricts the set of packages that Spack looks for. """ cmake_path1 = create_exe("cmake", "cmake version 1.foo") + prefix = os.path.dirname(os.path.dirname(cmake_path1)) os.environ['PATH'] = ':'.join([os.path.dirname(cmake_path1)]) external('find', 'cmake') pkgs_cfg = spack.config.get('packages') cmake_cfg = pkgs_cfg['cmake'] - cmake_paths_cfg = cmake_cfg['paths'] + cmake_externals = cmake_cfg['externals'] - assert 'cmake@1.foo' in cmake_paths_cfg + assert {'spec': 'cmake@1.foo', 'prefix': prefix} in cmake_externals def test_find_external_cmd_not_buildable( @@ -134,16 +134,18 @@ def test_find_external_cmd_full_repo( """ exe_path1 = create_exe( - "find-externals1-exe", "find-externals1 version 1.foo") + "find-externals1-exe", "find-externals1 version 1.foo" + ) + prefix = os.path.dirname(os.path.dirname(exe_path1)) os.environ['PATH'] = ':'.join([os.path.dirname(exe_path1)]) external('find') pkgs_cfg = spack.config.get('packages') pkg_cfg = pkgs_cfg['find-externals1'] - pkg_paths_cfg = pkg_cfg['paths'] + pkg_externals = pkg_cfg['externals'] - assert 'find-externals1@1.foo' in pkg_paths_cfg + assert {'spec': 'find-externals1@1.foo', 'prefix': prefix} in pkg_externals def test_find_external_merge(mutable_config, mutable_mock_repo): @@ -152,26 +154,31 @@ def test_find_external_merge(mutable_config, mutable_mock_repo): """ pkgs_cfg_init = { 'find-externals1': { - 'paths': { - 'find-externals1@1.1': '/preexisting-prefix/' - }, + 'externals': [{ + 'spec': 'find-externals1@1.1', + 'prefix': '/preexisting-prefix/' + }], 'buildable': False } } mutable_config.update_config('packages', pkgs_cfg_init) - - pkg_to_entries = { - 'find-externals1': [ - ExternalPackageEntry(Spec('find-externals1@1.1'), '/x/y1/'), - ExternalPackageEntry(Spec('find-externals1@1.2'), '/x/y2/'), - ] - } + entries = [ + ExternalPackageEntry( + Spec.from_detection('find-externals1@1.1'), '/x/y1/' + ), + ExternalPackageEntry( + Spec.from_detection('find-externals1@1.2'), '/x/y2/' + ) + ] + pkg_to_entries = {'find-externals1': entries} spack.cmd.external._update_pkg_config(pkg_to_entries, False) pkgs_cfg = spack.config.get('packages') pkg_cfg = pkgs_cfg['find-externals1'] - pkg_paths_cfg = pkg_cfg['paths'] + pkg_externals = pkg_cfg['externals'] - assert pkg_paths_cfg['find-externals1@1.1'] == '/preexisting-prefix/' - assert pkg_paths_cfg['find-externals1@1.2'] == '/x/y2/' + assert {'spec': 'find-externals1@1.1', + 'prefix': '/preexisting-prefix/'} in pkg_externals + assert {'spec': 'find-externals1@1.2', + 'prefix': '/x/y2/'} in pkg_externals diff --git a/lib/spack/spack/test/concretize.py b/lib/spack/spack/test/concretize.py index cfe0748c4b..a7a89cc78d 100644 --- a/lib/spack/spack/test/concretize.py +++ b/lib/spack/spack/test/concretize.py @@ -373,7 +373,7 @@ class TestConcretize(object): spec = Spec('externalmodule') spec.concretize() - assert spec['externalmodule'].external_module == 'external-module' + assert spec['externalmodule'].external_modules == ['external-module'] assert 'externalprereq' not in spec assert spec['externalmodule'].compiler.satisfies('gcc') diff --git a/lib/spack/spack/test/concretize_preferences.py b/lib/spack/spack/test/concretize_preferences.py index df46ed9fe8..c941ccad94 100644 --- a/lib/spack/spack/test/concretize_preferences.py +++ b/lib/spack/spack/test/concretize_preferences.py @@ -198,8 +198,9 @@ all: mpi: [mpich] mpich: buildable: false - paths: - mpich@3.0.4: /dummy/path + externals: + - spec: mpich@3.0.4 + prefix: /dummy/path """) spack.config.set('packages', conf, scope='concretize') @@ -229,8 +230,9 @@ all: mpi: [mpich] mpi: buildable: false - modules: - mpich@3.0.4: dummy + externals: + - spec: mpich@3.0.4 + modules: [dummy] """) spack.config.set('packages', conf, scope='concretize') diff --git a/lib/spack/spack/test/data/config/packages.yaml b/lib/spack/spack/test/data/config/packages.yaml index 63e63e525d..c2e8d558b3 100644 --- a/lib/spack/spack/test/data/config/packages.yaml +++ b/lib/spack/spack/test/data/config/packages.yaml @@ -4,15 +4,21 @@ packages: mpi: [openmpi, mpich] externaltool: buildable: False - paths: - externaltool@1.0%gcc@4.5.0: /path/to/external_tool - externaltool@0.9%gcc@4.5.0: /usr + externals: + - spec: externaltool@1.0%gcc@4.5.0 + prefix: /path/to/external_tool + - spec: externaltool@0.9%gcc@4.5.0 + prefix: /usr externalvirtual: buildable: False - paths: - externalvirtual@2.0%clang@3.3: /path/to/external_virtual_clang - externalvirtual@1.0%gcc@4.5.0: /path/to/external_virtual_gcc + externals: + - spec: externalvirtual@2.0%clang@3.3 + prefix: /path/to/external_virtual_clang + - spec: externalvirtual@1.0%gcc@4.5.0 + prefix: /path/to/external_virtual_gcc externalmodule: buildable: False - modules: - externalmodule@1.0%gcc@4.5.0: external-module + externals: + - spec: externalmodule@1.0%gcc@4.5.0 + modules: + - external-module diff --git a/lib/spack/spack/test/database.py b/lib/spack/spack/test/database.py index 63276c0e7b..a161a22908 100644 --- a/lib/spack/spack/test/database.py +++ b/lib/spack/spack/test/database.py @@ -689,17 +689,17 @@ def test_115_reindex_with_packages_not_in_repo(mutable_database): def test_external_entries_in_db(mutable_database): rec = mutable_database.get_record('mpileaks ^zmpi') assert rec.spec.external_path is None - assert rec.spec.external_module is None + assert not rec.spec.external_modules rec = mutable_database.get_record('externaltool') assert rec.spec.external_path == '/path/to/external_tool' - assert rec.spec.external_module is None + assert not rec.spec.external_modules assert rec.explicit is False rec.spec.package.do_install(fake=True, explicit=True) rec = mutable_database.get_record('externaltool') assert rec.spec.external_path == '/path/to/external_tool' - assert rec.spec.external_module is None + assert not rec.spec.external_modules assert rec.explicit is True diff --git a/lib/spack/spack/test/installer.py b/lib/spack/spack/test/installer.py index 68b70e0840..0b3f409641 100644 --- a/lib/spack/spack/test/installer.py +++ b/lib/spack/spack/test/installer.py @@ -157,11 +157,11 @@ def test_process_external_package_module(install_mockery, monkeypatch, capfd): monkeypatch.setattr(spack.database.Database, 'get_record', _none) spec.external_path = '/actual/external/path/not/checked' - spec.external_module = 'unchecked_module' + spec.external_modules = ['unchecked_module'] inst._process_external_package(spec.package, False) out = capfd.readouterr()[0] - assert 'has external module in {0}'.format(spec.external_module) in out + assert 'has external module in {0}'.format(spec.external_modules) in out def test_process_binary_cache_tarball_none(install_mockery, monkeypatch, @@ -257,15 +257,15 @@ def test_installer_ensure_ready_errors(install_mockery): fmt = r'cannot be installed locally.*{0}' # Force an external package error - path, module = spec.external_path, spec.external_module + path, modules = spec.external_path, spec.external_modules spec.external_path = '/actual/external/path/not/checked' - spec.external_module = 'unchecked_module' + spec.external_modules = ['unchecked_module'] msg = fmt.format('is external') with pytest.raises(inst.ExternalPackageError, match=msg): installer._ensure_install_ready(spec.package) # Force an upstream package error - spec.external_path, spec.external_module = path, module + spec.external_path, spec.external_modules = path, modules spec.package._installed_upstream = True msg = fmt.format('is upstream') with pytest.raises(inst.UpstreamPackageError, match=msg): diff --git a/lib/spack/spack/test/module_parsing.py b/lib/spack/spack/test/module_parsing.py index 0bf485913f..8dc06b058b 100644 --- a/lib/spack/spack/test/module_parsing.py +++ b/lib/spack/spack/test/module_parsing.py @@ -9,7 +9,7 @@ import spack from spack.util.module_cmd import ( module, - get_path_from_module, + path_from_modules, get_path_args_from_module_line, get_path_from_module_contents ) @@ -55,7 +55,7 @@ def test_get_path_from_module_faked(monkeypatch): return line monkeypatch.setattr(spack.util.module_cmd, 'module', fake_module) - path = get_path_from_module('mod') + path = path_from_modules(['mod']) assert path == '/path/to' @@ -116,10 +116,10 @@ def test_get_argument_from_module_line(): bad_lines = ['prepend_path(PATH,/lib/path)', 'prepend-path (LD_LIBRARY_PATH) /lib/path'] - assert all(get_path_args_from_module_line(l) == ['/lib/path'] - for l in simple_lines) - assert all(get_path_args_from_module_line(l) == ['/lib/path', '/pkg/path'] - for l in complex_lines) + assert all(get_path_args_from_module_line(x) == ['/lib/path'] + for x in simple_lines) + assert all(get_path_args_from_module_line(x) == ['/lib/path', '/pkg/path'] + for x in complex_lines) for bl in bad_lines: with pytest.raises(ValueError): get_path_args_from_module_line(bl) diff --git a/lib/spack/spack/util/module_cmd.py b/lib/spack/spack/util/module_cmd.py index 7017b2ecb6..3b11851b60 100644 --- a/lib/spack/spack/util/module_cmd.py +++ b/lib/spack/spack/util/module_cmd.py @@ -135,18 +135,32 @@ def get_path_args_from_module_line(line): return paths -def get_path_from_module(mod): - """Inspects a TCL module for entries that indicate the absolute path - at which the library supported by said module can be found. +def path_from_modules(modules): + """Inspect a list of TCL modules for entries that indicate the absolute + path at which the library supported by said module can be found. + + Args: + modules (list): module files to be loaded to get an external package + + Returns: + Guess of the prefix path where the package """ - # Read the module - text = module('show', mod).split('\n') - - p = get_path_from_module_contents(text, mod) - if p and not os.path.exists(p): - tty.warn("Extracted path from module does not exist:" - "\n\tExtracted path: " + p) - return p + best_choice = None + for module_name in modules: + # Read the current module and return a candidate path + text = module('show', module_name).split('\n') + candidate_path = get_path_from_module_contents(text, module_name) + + if candidate_path and not os.path.exists(candidate_path): + msg = ("Extracted path from module does not exist " + "[module={0}, path={0}]") + tty.warn(msg.format(module_name, candidate_path)) + + # If anything is found, then it's the best choice. This means + # that we give preference to the last module to be loaded + # for packages requiring to load multiple modules in sequence + best_choice = candidate_path or best_choice + return best_choice def get_path_from_module_contents(text, module_name): diff --git a/lib/spack/spack/util/spack_yaml.py b/lib/spack/spack/util/spack_yaml.py index 46e8e35543..565a9be4ea 100644 --- a/lib/spack/spack/util/spack_yaml.py +++ b/lib/spack/spack/util/spack_yaml.py @@ -13,7 +13,7 @@ """ import ctypes - +import collections from ordereddict_backport import OrderedDict from six import string_types, StringIO @@ -332,6 +332,22 @@ def dump_annotated(data, stream=None, *args, **kwargs): return getvalue() +def sorted_dict(dict_like): + """Return an ordered dict with all the fields sorted recursively. + + Args: + dict_like (dict): dictionary to be sorted + + Returns: + dictionary sorted recursively + """ + result = syaml_dict(sorted(dict_like.items())) + for key, value in result.items(): + if isinstance(value, collections.Mapping): + result[key] = sorted_dict(value) + return result + + class SpackYAMLError(spack.error.SpackError): """Raised when there are issues with YAML parsing.""" def __init__(self, msg, yaml_error): -- cgit v1.2.3-70-g09d2