From ea1de6b941c1eb9eec70f970cc363c6e35d08edf Mon Sep 17 00:00:00 2001 From: Peter Scheibel Date: Wed, 10 Apr 2019 16:00:12 -0700 Subject: [PATCH] Maintain a view for an environment (#10017) Environments are nowm by default, created with views. When activated, if an environment includes a view, this view will be added to `PATH`, `CPATH`, and other shell variables to expose the Spack environment in the user's shell. Example: ``` spack env create e1 #by default this will maintain a view in the directory Spack maintains for the env spack env create e1 --with-view=/abs/path/to/anywhere spack env create e1 --without-view ``` The `spack.yaml` manifest file now looks like this: ``` spack: specs: - python view: true #or false, or a string ``` These commands can be used to control the view configuration for the active environment, without hand-editing the `spack.yaml` file: ``` spack env view enable spack env view envable /abs/path/to/anywhere spack env view disable ``` Views are automatically updated when specs are installed to an environment. A view only maintains one copy of any package. An environment may refer to a package multiple times, in particular if it appears as a dependency. This PR establishes a prioritization for which environment specs are added to views: a spec has higher priority if it was concretized first. This does not necessarily exactly match the order in which specs were added, for example, given `X->Z` and `Y->Z'`: ``` spack env activate e1 spack add X spack install Y # immediately concretizes and installs Y and Z' spack install # concretizes X and Z ``` In this case `Z'` will be favored over `Z`. Specs in the environment must be concrete and installed to be added to the view, so there is another minor ordering effect: by default the view maintained for the environment ignores file conflicts between packages. If packages are not installed in order, and there are file conflicts, then the version chosen depends on the order. Both ordering issues are avoided if `spack install`/`spack add` and `spack install ` are not mixed. --- lib/spack/llnl/util/filesystem.py | 25 ++- lib/spack/spack/cmd/env.py | 133 ++++++++++----- lib/spack/spack/environment.py | 249 +++++++++++++++++++++++----- lib/spack/spack/filesystem_view.py | 26 ++- lib/spack/spack/schema/env.py | 3 + lib/spack/spack/test/cmd/env.py | 176 ++++++++++++++++++++ lib/spack/spack/util/environment.py | 73 +++++--- 7 files changed, 570 insertions(+), 115 deletions(-) diff --git a/lib/spack/llnl/util/filesystem.py b/lib/spack/llnl/util/filesystem.py index 00e4dc5f37..f5017f5236 100644 --- a/lib/spack/llnl/util/filesystem.py +++ b/lib/spack/llnl/util/filesystem.py @@ -703,15 +703,32 @@ def set_executable(path): os.chmod(path, mode) +def remove_empty_directories(root): + """Ascend up from the leaves accessible from `root` and remove empty + directories. + + Parameters: + root (str): path where to search for empty directories + """ + for dirpath, subdirs, files in os.walk(root, topdown=False): + for sd in subdirs: + sdp = os.path.join(dirpath, sd) + try: + os.rmdir(sdp) + except OSError: + pass + + def remove_dead_links(root): - """Removes any dead link that is present in root. + """Recursively removes any dead link that is present in root. Parameters: root (str): path where to search for dead links """ - for file in os.listdir(root): - path = join_path(root, file) - remove_if_dead_link(path) + for dirpath, subdirs, files in os.walk(root, topdown=False): + for f in files: + path = join_path(dirpath, f) + remove_if_dead_link(path) def remove_if_dead_link(path): diff --git a/lib/spack/spack/cmd/env.py b/lib/spack/spack/cmd/env.py index e8ac8a5c86..1b85849e8f 100644 --- a/lib/spack/spack/cmd/env.py +++ b/lib/spack/spack/cmd/env.py @@ -5,6 +5,7 @@ import os import sys +from collections import namedtuple import llnl.util.tty as tty import llnl.util.filesystem as fs @@ -20,6 +21,7 @@ import spack.environment as ev import spack.util.string as string + description = "manage virtual environments" section = "environments" level = "short" @@ -34,6 +36,7 @@ ['list', 'ls'], ['status', 'st'], 'loads', + 'view', ] @@ -49,10 +52,20 @@ def env_activate_setup_parser(subparser): shells.add_argument( '--csh', action='store_const', dest='shell', const='csh', help="print csh commands to activate the environment") - shells.add_argument( + + view_options = subparser.add_mutually_exclusive_group() + view_options.add_argument( + '-v', '--with-view', action='store_const', dest='with_view', + const=True, default=True, + help="update PATH etc. with associated view") + view_options.add_argument( + '-V', '--without-view', action='store_const', dest='with_view', + const=False, default=True, + help="do not update PATH etc. with associated view") + + subparser.add_argument( '-d', '--dir', action='store_true', default=False, help="force spack to treat env as a directory, not a name") - subparser.add_argument( '-p', '--prompt', action='store_true', default=False, help="decorate the command line prompt when activating") @@ -93,25 +106,13 @@ def env_activate(args): if spack_env == os.environ.get('SPACK_ENV'): tty.die("Environment %s is already active" % args.activate_env) - if args.shell == 'csh': - # TODO: figure out how to make color work for csh - sys.stdout.write('setenv SPACK_ENV %s;\n' % spack_env) - sys.stdout.write('alias despacktivate "spack env deactivate";\n') - if args.prompt: - sys.stdout.write('if (! $?SPACK_OLD_PROMPT ) ' - 'setenv SPACK_OLD_PROMPT "${prompt}";\n') - sys.stdout.write('set prompt="%s ${prompt}";\n' % env_prompt) - - else: - if 'color' in os.environ['TERM']: - env_prompt = colorize('@G{%s} ' % env_prompt, color=True) - - sys.stdout.write('export SPACK_ENV=%s;\n' % spack_env) - sys.stdout.write("alias despacktivate='spack env deactivate';\n") - if args.prompt: - sys.stdout.write('if [ -z "${SPACK_OLD_PS1}" ]; then\n') - sys.stdout.write('export SPACK_OLD_PS1="${PS1}"; fi;\n') - sys.stdout.write('export PS1="%s ${PS1}";\n' % env_prompt) + active_env = ev.get_env(namedtuple('args', ['env'])(env), + 'activate') + cmds = ev.activate( + active_env, add_view=args.with_view, shell=args.shell, + prompt=env_prompt if args.prompt else None + ) + sys.stdout.write(cmds) # @@ -146,20 +147,8 @@ def env_deactivate(args): if 'SPACK_ENV' not in os.environ: tty.die('No environment is currently active.') - if args.shell == 'csh': - sys.stdout.write('unsetenv SPACK_ENV;\n') - sys.stdout.write('if ( $?SPACK_OLD_PROMPT ) ' - 'set prompt="$SPACK_OLD_PROMPT" && ' - 'unsetenv SPACK_OLD_PROMPT;\n') - sys.stdout.write('unalias despacktivate;\n') - - else: - sys.stdout.write('unset SPACK_ENV; export SPACK_ENV;\n') - sys.stdout.write('unalias despacktivate;\n') - sys.stdout.write('if [ -n "$SPACK_OLD_PS1" ]; then\n') - sys.stdout.write('export PS1="$SPACK_OLD_PS1";\n') - sys.stdout.write('unset SPACK_OLD_PS1; export SPACK_OLD_PS1;\n') - sys.stdout.write('fi;\n') + cmds = ev.deactivate(shell=args.shell) + sys.stdout.write(cmds) # @@ -172,20 +161,40 @@ def env_create_setup_parser(subparser): subparser.add_argument( '-d', '--dir', action='store_true', help='create an environment in a specific directory') + view_opts = subparser.add_mutually_exclusive_group() + view_opts.add_argument( + '--without-view', action='store_true', + help='do not maintain a view for this environment') + view_opts.add_argument( + '--with-view', + help='specify that this environment should maintain a view at the' + ' specified path (by default the view is maintained in the' + ' environment directory)') subparser.add_argument( 'envfile', nargs='?', default=None, help='optional init file; can be spack.yaml or spack.lock') def env_create(args): + if args.with_view: + with_view = args.with_view + elif args.without_view: + with_view = False + else: + # Note that 'None' means unspecified, in which case the Environment + # object could choose to enable a view by default. False means that + # the environment should not include a view. + with_view = None if args.envfile: with open(args.envfile) as f: - _env_create(args.create_env, f, args.dir) + _env_create(args.create_env, f, args.dir, + with_view=with_view) else: - _env_create(args.create_env, None, args.dir) + _env_create(args.create_env, None, args.dir, + with_view=with_view) -def _env_create(name_or_path, init_file=None, dir=False): +def _env_create(name_or_path, init_file=None, dir=False, with_view=None): """Create a new environment, with an optional yaml description. Arguments: @@ -196,11 +205,11 @@ def _env_create(name_or_path, init_file=None, dir=False): of a named environment """ if dir: - env = ev.Environment(name_or_path, init_file) + env = ev.Environment(name_or_path, init_file, with_view) env.write() tty.msg("Created environment in %s" % env.path) else: - env = ev.create(name_or_path, init_file) + env = ev.create(name_or_path, init_file, with_view) env.write() tty.msg("Created environment '%s' in %s" % (name_or_path, env.path)) return env @@ -272,6 +281,50 @@ def env_list(args): colify(color_names, indent=4) +class ViewAction(object): + regenerate = 'regenerate' + enable = 'enable' + disable = 'disable' + + @staticmethod + def actions(): + return [ViewAction.regenerate, ViewAction.enable, ViewAction.disable] + + +# +# env view +# +def env_view_setup_parser(subparser): + """manage a view associated with the environment""" + subparser.add_argument( + 'action', choices=ViewAction.actions(), + help="action to take for the environment's view") + subparser.add_argument( + 'view_path', nargs='?', + help="when enabling a view, optionally set the path manually" + ) + + +def env_view(args): + env = ev.get_env(args, 'env view') + + if env: + if args.action == ViewAction.regenerate: + env.regenerate_view() + elif args.action == ViewAction.enable: + if args.view_path: + view_path = args.view_path + else: + view_path = env.default_view_path + env.update_view(view_path) + env.write() + elif args.action == ViewAction.disable: + env.update_view(None) + env.write() + else: + tty.msg("No active environment") + + # # env status # diff --git a/lib/spack/spack/environment.py b/lib/spack/spack/environment.py index e9e3328bf6..68639a9deb 100644 --- a/lib/spack/spack/environment.py +++ b/lib/spack/spack/environment.py @@ -9,9 +9,11 @@ import shutil import ruamel.yaml +import six import llnl.util.filesystem as fs import llnl.util.tty as tty +from llnl.util.tty.color import colorize import spack.error import spack.repo @@ -20,7 +22,9 @@ import spack.util.spack_json as sjson import spack.config from spack.spec import Spec +from spack.filesystem_view import YamlFilesystemView +from spack.util.environment import EnvironmentModifications #: environment variable used to indicate the active environment spack_env_var = 'SPACK_ENV' @@ -56,6 +60,7 @@ # add package specs to the `specs` list specs: - + view: true """ #: regex for validating enviroment names valid_environment_name_re = r'^\w[\w-]*$' @@ -79,7 +84,9 @@ def validate_env_name(name): return name -def activate(env, use_env_repo=False): +def activate( + env, use_env_repo=False, add_view=True, shell='sh', prompt=None +): """Activate an environment. To activate an environment, we add its configuration scope to the @@ -90,8 +97,12 @@ def activate(env, use_env_repo=False): env (Environment): the environment to activate use_env_repo (bool): use the packages exactly as they appear in the environment's repository + add_view (bool): generate commands to add view to path variables + shell (string): One of `sh`, `csh`. + prompt (string): string to add to the users prompt, or None - TODO: Add support for views here. Activation should set up the shell + Returns: + cmds: Shell commands to activate environment. TODO: environment to use the activated spack environment. """ global _active_environment @@ -103,13 +114,41 @@ def activate(env, use_env_repo=False): tty.debug("Using environmennt '%s'" % _active_environment.name) + # Construct the commands to run + cmds = '' + if shell == 'csh': + # TODO: figure out how to make color work for csh + cmds += 'setenv SPACK_ENV %s;\n' % env.path + cmds += 'alias despacktivate "spack env deactivate";\n' + if prompt: + cmds += 'if (! $?SPACK_OLD_PROMPT ) ' + cmds += 'setenv SPACK_OLD_PROMPT "${prompt}";\n' + cmds += 'set prompt="%s ${prompt}";\n' % prompt + else: + if 'color' in os.environ['TERM'] and prompt: + prompt = colorize('@G{%s} ' % prompt, color=True) -def deactivate(): + cmds += 'export SPACK_ENV=%s;\n' % env.path + cmds += "alias despacktivate='spack env deactivate';\n" + if prompt: + cmds += 'if [ -z "${SPACK_OLD_PS1}" ]; then\n' + cmds += 'export SPACK_OLD_PS1="${PS1}"; fi;\n' + cmds += 'export PS1="%s ${PS1}";\n' % prompt + + if add_view and env._view_path: + cmds += env.add_view_to_shell(shell) + + return cmds + + +def deactivate(shell='sh'): """Undo any configuration or repo settings modified by ``activate()``. + Arguments: + shell (string): One of `sh`, `csh`. Shell style to use. + Returns: - (bool): True if an environment was deactivated, False if no - environment was active. + (string): shell commands for `shell` to undo environment variables """ global _active_environment @@ -123,9 +162,29 @@ def deactivate(): if _active_environment._repo: spack.repo.path.remove(_active_environment._repo) + cmds = '' + if shell == 'csh': + cmds += 'unsetenv SPACK_ENV;\n' + cmds += 'if ( $?SPACK_OLD_PROMPT ) ' + cmds += 'set prompt="$SPACK_OLD_PROMPT" && ' + cmds += 'unsetenv SPACK_OLD_PROMPT;\n' + cmds += 'unalias despacktivate;\n' + else: + cmds += 'unset SPACK_ENV; export SPACK_ENV;\n' + cmds += 'unalias despacktivate;\n' + cmds += 'if [ -n "$SPACK_OLD_PS1" ]; then\n' + cmds += 'export PS1="$SPACK_OLD_PS1";\n' + cmds += 'unset SPACK_OLD_PS1; export SPACK_OLD_PS1;\n' + cmds += 'fi;\n' + + if _active_environment._view_path: + cmds += _active_environment.rm_view_from_shell(shell) + tty.debug("Deactivated environmennt '%s'" % _active_environment.name) _active_environment = None + return cmds + def find_environment(args): """Find active environment from args, spack.yaml, or environment variable. @@ -265,12 +324,12 @@ def read(name): return Environment(root(name)) -def create(name, init_file=None): +def create(name, init_file=None, with_view=None): """Create a named environment in Spack.""" validate_env_name(name) if exists(name): raise SpackEnvironmentError("'%s': environment already exists" % name) - return Environment(root(name), init_file) + return Environment(root(name), init_file, with_view) def config_dict(yaml_data): @@ -327,7 +386,7 @@ def _write_yaml(data, str_or_file): class Environment(object): - def __init__(self, path, init_file=None): + def __init__(self, path, init_file=None, with_view=None): """Create a new environment. The environment can be optionally initialized with either a @@ -337,39 +396,41 @@ def __init__(self, path, init_file=None): path (str): path to the root directory of this environment init_file (str or file object): filename or file object to initialize the environment + with_view (str or bool): whether a view should be maintained for + the environment. If the value is a string, it specifies the + path to the view. """ self.path = os.path.abspath(path) self.clear() if init_file: - # initialize the environment from a file if provided with fs.open_if_filename(init_file) as f: if hasattr(f, 'name') and f.name.endswith('.lock'): - # Initialize the environment from a lockfile + self._read_manifest(default_manifest_yaml) self._read_lockfile(f) self._set_user_specs_from_lockfile() - self.yaml = _read_yaml(default_manifest_yaml) else: - # Initialize the environment from a spack.yaml file self._read_manifest(f) else: - # read lockfile, if it exists - if os.path.exists(self.lock_path): - with open(self.lock_path) as f: - self._read_lockfile(f) - - if os.path.exists(self.manifest_path): - # read the spack.yaml file, if exists + default_manifest = not os.path.exists(self.manifest_path) + if default_manifest: + self._read_manifest(default_manifest_yaml) + else: with open(self.manifest_path) as f: self._read_manifest(f) - elif self.concretized_user_specs: - # if not, take user specs from the lockfile - self._set_user_specs_from_lockfile() - self.yaml = _read_yaml(default_manifest_yaml) - else: - # if there's no manifest or lockfile, use the default - self._read_manifest(default_manifest_yaml) + if os.path.exists(self.lock_path): + with open(self.lock_path) as f: + self._read_lockfile(f) + if default_manifest: + self._set_user_specs_from_lockfile() + + if with_view is False: + self._view_path = None + elif isinstance(with_view, six.string_types): + self._view_path = with_view + # If with_view is None, then defer to the view settings determined by + # the manifest file def _read_manifest(self, f): """Read manifest file and set up user specs.""" @@ -378,6 +439,17 @@ def _read_manifest(self, f): if spec_list: self.user_specs = [Spec(s) for s in spec_list if s] + enable_view = config_dict(self.yaml).get('view') + # enable_view can be true/false, a string, or None (if the manifest did + # not specify it) + if enable_view is True or enable_view is None: + self._view_path = self.default_view_path + elif isinstance(enable_view, six.string_types): + self._view_path = enable_view + else: + # enable_view is False + self._view_path = None + def _set_user_specs_from_lockfile(self): """Copy user_specs from a read-in lockfile.""" self.user_specs = [Spec(s) for s in self.concretized_user_specs] @@ -436,6 +508,10 @@ def repos_path(self): def log_path(self): return os.path.join(self.path, env_subdir_name, 'logs') + @property + def default_view_path(self): + return os.path.join(self.env_subdir_path, 'view') + @property def repo(self): if self._repo is None: @@ -619,7 +695,97 @@ def install(self, user_spec, concrete_spec=None, **install_args): concrete = spec.concretized() self._add_concrete_spec(spec, concrete) - concrete.package.do_install(**install_args) + self._install(concrete, **install_args) + + def _install(self, spec, **install_args): + spec.package.do_install(**install_args) + + # Make sure log directory exists + log_path = self.log_path + fs.mkdirp(log_path) + + with fs.working_dir(self.path): + # Link the resulting log file into logs dir + build_log_link = os.path.join( + log_path, '%s-%s.log' % (spec.name, spec.dag_hash(7))) + if os.path.lexists(build_log_link): + os.remove(build_log_link) + os.symlink(spec.package.build_log_path, build_log_link) + + def view(self): + if not self._view_path: + raise SpackEnvironmentError( + "{0} does not have a view enabled".format(self.name)) + + return YamlFilesystemView( + self._view_path, spack.store.layout, ignore_conflicts=True) + + def update_view(self, view_path): + if self._view_path and self._view_path != view_path: + shutil.rmtree(self._view_path) + + self._view_path = view_path + + def regenerate_view(self): + if not self._view_path: + tty.debug("Skip view update, this environment does not" + " maintain a view") + return + + specs_for_view = [] + for spec in self._get_environment_specs(): + # The view does not store build deps, so if we want it to + # recognize environment specs (which do store build deps), then + # they need to be stripped + specs_for_view.append(spack.spec.Spec.from_dict( + spec.to_dict(all_deps=False) + )) + installed_specs_for_view = set(s for s in specs_for_view + if s.package.installed) + + view = self.view() + view.clean() + specs_in_view = set(view.get_all_specs()) + tty.msg("Updating view at {0}".format(self._view_path)) + + rm_specs = specs_in_view - installed_specs_for_view + view.remove_specs(*rm_specs, with_dependents=False) + + add_specs = installed_specs_for_view - specs_in_view + view.add_specs(*add_specs, with_dependencies=False) + + def _shell_vars(self): + updates = [ + ('PATH', ['bin']), + ('MANPATH', ['man', 'share/man']), + ('ACLOCAL_PATH', ['share/aclocal']), + ('LD_LIBRARY_PATH', ['lib', 'lib64']), + ('LIBRARY_PATH', ['lib', 'lib64']), + ('CPATH', ['include']), + ('PKG_CONFIG_PATH', ['lib/pkgconfig', 'lib64/pkgconfig']), + ('CMAKE_PREFIX_PATH', ['']), + ] + path_updates = list() + for var, subdirs in updates: + paths = filter(lambda x: os.path.exists(x), + list(os.path.join(self._view_path, x) + for x in subdirs)) + path_updates.append((var, paths)) + return path_updates + + def add_view_to_shell(self, shell): + env_mod = EnvironmentModifications() + for var, paths in self._shell_vars(): + for path in paths: + env_mod.prepend_path(var, path) + return env_mod.shell_modifications(shell) + + def rm_view_from_shell(self, shell): + env_mod = EnvironmentModifications() + for var, paths in self._shell_vars(): + for path in paths: + env_mod.remove_path(var, path) + return env_mod.shell_modifications(shell) def _add_concrete_spec(self, spec, concrete, new=True): """Called when a new concretized spec is added to the environment. @@ -648,11 +814,6 @@ def _add_concrete_spec(self, spec, concrete, new=True): def install_all(self, args=None): """Install all concretized specs in an environment.""" - - # Make sure log directory exists - log_path = self.log_path - fs.mkdirp(log_path) - for concretized_hash in self.concretized_order: spec = self.specs_by_hash[concretized_hash] @@ -662,17 +823,18 @@ def install_all(self, args=None): if args: spack.cmd.install.update_kwargs_from_args(args, kwargs) - with fs.working_dir(self.path): - spec.package.do_install(**kwargs) + self._install(spec, **kwargs) if not spec.external: # Link the resulting log file into logs dir build_log_link = os.path.join( - log_path, '%s-%s.log' % (spec.name, spec.dag_hash(7))) - if os.path.exists(build_log_link): + self.log_path, '%s-%s.log' % (spec.name, spec.dag_hash(7))) + if os.path.lexists(build_log_link): os.remove(build_log_link) os.symlink(spec.package.build_log_path, build_log_link) + self.regenerate_view() + def all_specs_by_hash(self): """Map of hashes to spec for all specs in this environment.""" hashes = {} @@ -857,13 +1019,26 @@ def write(self): self._repo = None # put the new user specs in the YAML - yaml_spec_list = config_dict(self.yaml).setdefault('specs', []) + yaml_dict = config_dict(self.yaml) + yaml_spec_list = yaml_dict.setdefault('specs', []) yaml_spec_list[:] = [str(s) for s in self.user_specs] + if self._view_path == self.default_view_path: + view = True + elif self._view_path: + view = self._view_path + else: + view = False + config_dict(self.yaml)['view'] = view + # if all that worked, write out the manifest file at the top level with fs.write_tmp_and_move(self.manifest_path) as f: _write_yaml(self.yaml, f) + # TODO: for operations that just add to the env (install etc.) this + # could just call update_view + self.regenerate_view() + def __enter__(self): self._previous_active = _active_environment activate(self) diff --git a/lib/spack/spack/filesystem_view.py b/lib/spack/spack/filesystem_view.py index ed69f53df6..abb6eb4c24 100644 --- a/lib/spack/spack/filesystem_view.py +++ b/lib/spack/spack/filesystem_view.py @@ -14,7 +14,8 @@ from llnl.util import tty from llnl.util.lang import match_predicate, index_by from llnl.util.tty.color import colorize -from llnl.util.filesystem import mkdirp +from llnl.util.filesystem import ( + mkdirp, remove_dead_links, remove_empty_directories) import spack.util.spack_yaml as s_yaml @@ -407,7 +408,7 @@ def remove_specs(self, *specs, **kwargs): set(map(remove_extension, extensions)) set(map(self.remove_standalone, standalones)) - self.purge_empty_directories() + self._purge_empty_directories() def remove_extension(self, spec, with_dependents=True): """ @@ -575,18 +576,15 @@ def print_status(self, *specs, **kwargs): else: tty.warn(self._croot + "No packages found.") - def purge_empty_directories(self): - """ - Ascend up from the leaves accessible from `path` - and remove empty directories. - """ - for dirpath, subdirs, files in os.walk(self._root, topdown=False): - for sd in subdirs: - sdp = os.path.join(dirpath, sd) - try: - os.rmdir(sdp) - except OSError: - pass + def _purge_empty_directories(self): + remove_empty_directories(self._root) + + def _purge_broken_links(self): + remove_dead_links(self._root) + + def clean(self): + self._purge_broken_links() + self._purge_empty_directories() def unlink_meta_folder(self, spec): path = self.get_path_meta_folder(spec) diff --git a/lib/spack/spack/schema/env.py b/lib/spack/spack/schema/env.py index f65ffc987a..9fbc59219c 100644 --- a/lib/spack/spack/schema/env.py +++ b/lib/spack/spack/schema/env.py @@ -47,6 +47,9 @@ {'type': 'object'}, ] } + }, + 'view': { + 'type': ['boolean', 'string'] } } ) diff --git a/lib/spack/spack/test/cmd/env.py b/lib/spack/spack/test/cmd/env.py index 2a0e8a85bc..0da9377196 100644 --- a/lib/spack/spack/test/cmd/env.py +++ b/lib/spack/spack/test/cmd/env.py @@ -603,3 +603,179 @@ def test_uninstall_removes_from_env(mock_stage, mock_fetch, install_mockery): assert not test.specs_by_hash assert not test.concretized_order assert not test.user_specs + + +def test_env_updates_view_install( + tmpdir, mock_stage, mock_fetch, install_mockery +): + view_dir = tmpdir.mkdir('view') + env('create', '--with-view=%s' % view_dir, 'test') + with ev.read('test'): + add('mpileaks') + install('--fake') + + assert os.path.exists(str(view_dir.join('.spack/mpileaks'))) + # Check that dependencies got in too + assert os.path.exists(str(view_dir.join('.spack/libdwarf'))) + + +def test_env_without_view_install( + tmpdir, mock_stage, mock_fetch, install_mockery +): + # Test enabling a view after installing specs + env('create', '--without-view', 'test') + + test_env = ev.read('test') + with pytest.raises(spack.environment.SpackEnvironmentError): + test_env.view() + + view_dir = tmpdir.mkdir('view') + + with ev.read('test'): + add('mpileaks') + install('--fake') + + env('view', 'enable', str(view_dir)) + + # After enabling the view, the specs should be linked into the environment + # view dir + assert os.path.exists(str(view_dir.join('.spack/mpileaks'))) + assert os.path.exists(str(view_dir.join('.spack/libdwarf'))) + + +def test_env_config_view_default( + tmpdir, mock_stage, mock_fetch, install_mockery +): + # This config doesn't mention whether a view is enabled + test_config = """\ +env: + specs: + - mpileaks +""" + + _env_create('test', StringIO(test_config)) + + with ev.read('test'): + install('--fake') + + e = ev.read('test') + # Try retrieving the view object + view = e.view() + assert view.get_spec('mpileaks') + + +def test_env_updates_view_install_package( + tmpdir, mock_stage, mock_fetch, install_mockery +): + view_dir = tmpdir.mkdir('view') + env('create', '--with-view=%s' % view_dir, 'test') + with ev.read('test'): + install('--fake', 'mpileaks') + + assert os.path.exists(str(view_dir.join('.spack/mpileaks'))) + # Check that dependencies got in too + assert os.path.exists(str(view_dir.join('.spack/libdwarf'))) + + +def test_env_updates_view_add_concretize( + tmpdir, mock_stage, mock_fetch, install_mockery +): + view_dir = tmpdir.mkdir('view') + env('create', '--with-view=%s' % view_dir, 'test') + install('--fake', 'mpileaks') + with ev.read('test'): + add('mpileaks') + concretize() + + assert os.path.exists(str(view_dir.join('.spack/mpileaks'))) + # Check that dependencies got in too + assert os.path.exists(str(view_dir.join('.spack/libdwarf'))) + + +def test_env_updates_view_uninstall( + tmpdir, mock_stage, mock_fetch, install_mockery +): + view_dir = tmpdir.mkdir('view') + env('create', '--with-view=%s' % view_dir, 'test') + with ev.read('test'): + install('--fake', 'mpileaks') + + assert os.path.exists(str(view_dir.join('.spack/mpileaks'))) + # Check that dependencies got in too + assert os.path.exists(str(view_dir.join('.spack/libdwarf'))) + + with ev.read('test'): + uninstall('-ay') + + assert (not os.path.exists(str(view_dir.join('.spack'))) or + os.listdir(str(view_dir.join('.spack'))) == ['projections.yaml']) + + +def test_env_updates_view_uninstall_referenced_elsewhere( + tmpdir, mock_stage, mock_fetch, install_mockery +): + view_dir = tmpdir.mkdir('view') + env('create', '--with-view=%s' % view_dir, 'test') + install('--fake', 'mpileaks') + with ev.read('test'): + add('mpileaks') + concretize() + + assert os.path.exists(str(view_dir.join('.spack/mpileaks'))) + # Check that dependencies got in too + assert os.path.exists(str(view_dir.join('.spack/libdwarf'))) + + with ev.read('test'): + uninstall('-ay') + + assert (not os.path.exists(str(view_dir.join('.spack'))) or + os.listdir(str(view_dir.join('.spack'))) == ['projections.yaml']) + + +def test_env_updates_view_remove_concretize( + tmpdir, mock_stage, mock_fetch, install_mockery +): + view_dir = tmpdir.mkdir('view') + env('create', '--with-view=%s' % view_dir, 'test') + install('--fake', 'mpileaks') + with ev.read('test'): + add('mpileaks') + concretize() + + assert os.path.exists(str(view_dir.join('.spack/mpileaks'))) + # Check that dependencies got in too + assert os.path.exists(str(view_dir.join('.spack/libdwarf'))) + + with ev.read('test'): + remove('mpileaks') + concretize() + + assert (not os.path.exists(str(view_dir.join('.spack'))) or + os.listdir(str(view_dir.join('.spack'))) == ['projections.yaml']) + + +def test_env_updates_view_force_remove( + tmpdir, mock_stage, mock_fetch, install_mockery +): + view_dir = tmpdir.mkdir('view') + env('create', '--with-view=%s' % view_dir, 'test') + with ev.read('test'): + install('--fake', 'mpileaks') + + assert os.path.exists(str(view_dir.join('.spack/mpileaks'))) + # Check that dependencies got in too + assert os.path.exists(str(view_dir.join('.spack/libdwarf'))) + + with ev.read('test'): + remove('-f', 'mpileaks') + + assert (not os.path.exists(str(view_dir.join('.spack'))) or + os.listdir(str(view_dir.join('.spack'))) == ['projections.yaml']) + + +def test_env_activate_view_fails( + tmpdir, mock_stage, mock_fetch, install_mockery +): + """Sanity check on env activate to make sure it requires shell support""" + out = env('activate', 'test') + assert "To initialize spack's shell commands:" in out diff --git a/lib/spack/spack/util/environment.py b/lib/spack/spack/util/environment.py index 1666f4711e..4296e2cbee 100644 --- a/lib/spack/spack/util/environment.py +++ b/lib/spack/spack/util/environment.py @@ -25,6 +25,18 @@ system_paths +_shell_set_strings = { + 'sh': 'export {0}={1};\n', + 'csh': 'setenv {0} {1};\n', +} + + +_shell_unset_strings = { + 'sh': 'unset {0};\n', + 'csh': 'unsetenv {0};\n', +} + + def is_system_path(path): """Predicate that given a path returns True if it is a system path, False otherwise. @@ -138,62 +150,62 @@ def update_args(self, **kwargs): class SetEnv(NameValueModifier): - def execute(self): - os.environ[self.name] = str(self.value) + def execute(self, env): + env[self.name] = str(self.value) class AppendFlagsEnv(NameValueModifier): - def execute(self): - if self.name in os.environ and os.environ[self.name]: - os.environ[self.name] += self.separator + str(self.value) + def execute(self, env): + if self.name in env and env[self.name]: + env[self.name] += self.separator + str(self.value) else: - os.environ[self.name] = str(self.value) + env[self.name] = str(self.value) class UnsetEnv(NameModifier): - def execute(self): + def execute(self, env): # Avoid throwing if the variable was not set - os.environ.pop(self.name, None) + env.pop(self.name, None) class SetPath(NameValueModifier): - def execute(self): + def execute(self, env): string_path = concatenate_paths(self.value, separator=self.separator) - os.environ[self.name] = string_path + env[self.name] = string_path class AppendPath(NameValueModifier): - def execute(self): - environment_value = os.environ.get(self.name, '') + def execute(self, env): + environment_value = env.get(self.name, '') directories = environment_value.split( self.separator) if environment_value else [] directories.append(os.path.normpath(self.value)) - os.environ[self.name] = self.separator.join(directories) + env[self.name] = self.separator.join(directories) class PrependPath(NameValueModifier): - def execute(self): - environment_value = os.environ.get(self.name, '') + def execute(self, env): + environment_value = env.get(self.name, '') directories = environment_value.split( self.separator) if environment_value else [] directories = [os.path.normpath(self.value)] + directories - os.environ[self.name] = self.separator.join(directories) + env[self.name] = self.separator.join(directories) class RemovePath(NameValueModifier): - def execute(self): - environment_value = os.environ.get(self.name, '') + def execute(self, env): + environment_value = env.get(self.name, '') directories = environment_value.split( self.separator) if environment_value else [] directories = [os.path.normpath(x) for x in directories if x != os.path.normpath(self.value)] - os.environ[self.name] = self.separator.join(directories) + env[self.name] = self.separator.join(directories) class EnvironmentModifications(object): @@ -361,7 +373,28 @@ def apply_modifications(self): # Apply modifications one variable at a time for name, actions in sorted(modifications.items()): for x in actions: - x.execute() + x.execute(os.environ) + + def shell_modifications(self, shell='sh'): + """Return shell code to apply the modifications and clears the list.""" + modifications = self.group_by_name() + new_env = os.environ.copy() + + for name, actions in sorted(modifications.items()): + for x in actions: + x.execute(new_env) + + cmds = '' + for name in set(new_env) & set(os.environ): + new = new_env.get(name, None) + old = os.environ.get(name, None) + if new != old: + if new is None: + cmds += _shell_unset_strings[shell].format(name) + else: + cmds += _shell_set_strings[shell].format(name, + new_env[name]) + return cmds @staticmethod def from_sourcing_file(filename, *args, **kwargs):