diff --git a/etc/spack/defaults/config.yaml b/etc/spack/defaults/config.yaml index b4d81f69da..018e8deb55 100644 --- a/etc/spack/defaults/config.yaml +++ b/etc/spack/defaults/config.yaml @@ -229,3 +229,11 @@ config: flags: # Whether to keep -Werror flags active in package builds. keep_werror: 'none' + + # A mapping of aliases that can be used to define new commands. For instance, + # `sp: spec -I` will define a new command `sp` that will execute `spec` with + # the `-I` argument. Aliases cannot override existing commands. + aliases: + concretise: concretize + containerise: containerize + rm: remove diff --git a/lib/spack/docs/config_yaml.rst b/lib/spack/docs/config_yaml.rst index 294f7c3436..d54977beba 100644 --- a/lib/spack/docs/config_yaml.rst +++ b/lib/spack/docs/config_yaml.rst @@ -304,3 +304,17 @@ To work properly, this requires your terminal to reset its title after Spack has finished its work, otherwise Spack's status information will remain in the terminal's title indefinitely. Most terminals should already be set up this way and clear Spack's status information. + +----------- +``aliases`` +----------- + +Aliases can be used to define new Spack commands. They can be either shortcuts +for longer commands or include specific arguments for convenience. For instance, +if users want to use ``spack install``'s ``-v`` argument all the time, they can +create a new alias called ``inst`` that will always call ``install -v``: + +.. code-block:: yaml + + aliases: + inst: install -v diff --git a/lib/spack/spack/cmd/commands.py b/lib/spack/spack/cmd/commands.py index 9ebaa62239..25e1a24d00 100644 --- a/lib/spack/spack/cmd/commands.py +++ b/lib/spack/spack/cmd/commands.py @@ -796,7 +796,9 @@ def names(args: Namespace, out: IO) -> None: commands = copy.copy(spack.cmd.all_commands()) if args.aliases: - commands.extend(spack.main.aliases.keys()) + aliases = spack.config.get("config:aliases") + if aliases: + commands.extend(aliases.keys()) colify(commands, output=out) @@ -812,8 +814,10 @@ def bash(args: Namespace, out: IO) -> None: parser = spack.main.make_argument_parser() spack.main.add_all_commands(parser) - aliases = ";".join(f"{key}:{val}" for key, val in spack.main.aliases.items()) - out.write(f'SPACK_ALIASES="{aliases}"\n\n') + aliases_config = spack.config.get("config:aliases") + if aliases_config: + aliases = ";".join(f"{key}:{val}" for key, val in aliases_config.items()) + out.write(f'SPACK_ALIASES="{aliases}"\n\n') writer = BashCompletionWriter(parser.prog, out, args.aliases) writer.write(parser) diff --git a/lib/spack/spack/main.py b/lib/spack/spack/main.py index bc29b6f1f1..87408d363a 100644 --- a/lib/spack/spack/main.py +++ b/lib/spack/spack/main.py @@ -16,11 +16,13 @@ import os.path import pstats import re +import shlex import signal import subprocess as sp import sys import traceback import warnings +from typing import List, Tuple import archspec.cpu @@ -49,9 +51,6 @@ #: names of profile statistics stat_names = pstats.Stats.sort_arg_dict_default -#: top-level aliases for Spack commands -aliases = {"concretise": "concretize", "containerise": "containerize", "rm": "remove"} - #: help levels in order of detail (i.e., number of commands shown) levels = ["short", "long"] @@ -359,7 +358,10 @@ def add_command(self, cmd_name): module = spack.cmd.get_module(cmd_name) # build a list of aliases - alias_list = [k for k, v in aliases.items() if v == cmd_name] + alias_list = [] + aliases = spack.config.get("config:aliases") + if aliases: + alias_list = [k for k, v in aliases.items() if shlex.split(v)[0] == cmd_name] subparser = self.subparsers.add_parser( cmd_name, @@ -670,7 +672,6 @@ def __init__(self, command_name, subprocess=False): Windows, where it is always False. """ self.parser = make_argument_parser() - self.command = self.parser.add_command(command_name) self.command_name = command_name # TODO: figure out how to support this on windows self.subprocess = subprocess if sys.platform != "win32" else False @@ -702,13 +703,14 @@ def __call__(self, *argv, **kwargs): if self.subprocess: p = sp.Popen( - [spack.paths.spack_script, self.command_name] + prepend + list(argv), + [spack.paths.spack_script] + prepend + [self.command_name] + list(argv), stdout=sp.PIPE, stderr=sp.STDOUT, ) out, self.returncode = p.communicate() out = out.decode() else: + command = self.parser.add_command(self.command_name) args, unknown = self.parser.parse_known_args( prepend + [self.command_name] + list(argv) ) @@ -716,7 +718,7 @@ def __call__(self, *argv, **kwargs): out = io.StringIO() try: with log_output(out, echo=True): - self.returncode = _invoke_command(self.command, self.parser, args, unknown) + self.returncode = _invoke_command(command, self.parser, args, unknown) except SystemExit as e: self.returncode = e.code @@ -870,6 +872,46 @@ def restore_macos_dyld_vars(): os.environ[dyld_var] = os.environ[stored_var_name] +def resolve_alias(cmd_name: str, cmd: List[str]) -> Tuple[str, List[str]]: + """Resolves aliases in the given command. + + Args: + cmd_name: command name. + cmd: command line arguments. + + Returns: + new command name and arguments. + """ + all_commands = spack.cmd.all_commands() + aliases = spack.config.get("config:aliases") + + if aliases: + for key, value in aliases.items(): + if " " in key: + tty.warn( + f"Alias '{key}' (mapping to '{value}') contains a space" + ", which is not supported." + ) + if key in all_commands: + tty.warn( + f"Alias '{key}' (mapping to '{value}') attempts to override" + " built-in command." + ) + + if cmd_name not in all_commands: + alias = None + + if aliases: + alias = aliases.get(cmd_name) + + if alias is not None: + alias_parts = shlex.split(alias) + cmd_name = alias_parts[0] + cmd = alias_parts + cmd[1:] + + return cmd_name, cmd + + def _main(argv=None): """Logic for the main entry point for the Spack command. @@ -962,7 +1004,7 @@ def _main(argv=None): # Try to load the particular command the caller asked for. cmd_name = args.command[0] - cmd_name = aliases.get(cmd_name, cmd_name) + cmd_name, args.command = resolve_alias(cmd_name, args.command) # set up a bootstrap context, if asked. # bootstrap context needs to include parsing the command, b/c things @@ -974,14 +1016,14 @@ def _main(argv=None): bootstrap_context = bootstrap.ensure_bootstrap_configuration() with bootstrap_context: - return finish_parse_and_run(parser, cmd_name, env_format_error) + return finish_parse_and_run(parser, cmd_name, args.command, env_format_error) -def finish_parse_and_run(parser, cmd_name, env_format_error): +def finish_parse_and_run(parser, cmd_name, cmd, env_format_error): """Finish parsing after we know the command to run.""" # add the found command to the parser and re-run then re-parse command = parser.add_command(cmd_name) - args, unknown = parser.parse_known_args() + args, unknown = parser.parse_known_args(cmd) # Now that we know what command this is and what its args are, determine # whether we can continue with a bad environment and raise if not. diff --git a/lib/spack/spack/schema/config.py b/lib/spack/spack/schema/config.py index 6c30f0aab9..6818cd78f3 100644 --- a/lib/spack/spack/schema/config.py +++ b/lib/spack/spack/schema/config.py @@ -92,6 +92,7 @@ "url_fetch_method": {"type": "string", "enum": ["urllib", "curl"]}, "additional_external_search_paths": {"type": "array", "items": {"type": "string"}}, "binary_index_ttl": {"type": "integer", "minimum": 0}, + "aliases": {"type": "object", "patternProperties": {r"\w[\w-]*": {"type": "string"}}}, }, "deprecatedProperties": { "properties": ["terminal_title"], diff --git a/lib/spack/spack/test/cmd/commands.py b/lib/spack/spack/test/cmd/commands.py index 99faac72b9..3288b092d4 100644 --- a/lib/spack/spack/test/cmd/commands.py +++ b/lib/spack/spack/test/cmd/commands.py @@ -58,6 +58,24 @@ def test_subcommands(): assert "spack compiler add" in out2 +@pytest.mark.not_on_windows("subprocess not supported on Windows") +def test_override_alias(): + """Test that spack commands cannot be overriden by aliases.""" + + install = spack.main.SpackCommand("install", subprocess=True) + instal = spack.main.SpackCommand("instal", subprocess=True) + + out = install(fail_on_error=False, global_args=["-c", "config:aliases:install:find"]) + assert "install requires a package argument or active environment" in out + assert "Alias 'install' (mapping to 'find') attempts to override built-in command" in out + + out = install(fail_on_error=False, global_args=["-c", "config:aliases:foo bar:find"]) + assert "Alias 'foo bar' (mapping to 'find') contains a space, which is not supported" in out + + out = instal(fail_on_error=False, global_args=["-c", "config:aliases:instal:find"]) + assert "install requires a package argument or active environment" not in out + + def test_rst(): """Do some simple sanity checks of the rst writer.""" out1 = commands("--format=rst")