From c50c5a0938c665e5e611c44618f938b66585395e Mon Sep 17 00:00:00 2001 From: Pieter De Gendt Date: Thu, 5 Sep 2024 20:13:40 +0200 Subject: [PATCH 1/3] app: Skip handling an unknown command if -h is passed Let the builtin help command handle the passed arguments. Signed-off-by: Pieter De Gendt --- src/west/app/main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/west/app/main.py b/src/west/app/main.py index 2b4addb0..8b848fb8 100755 --- a/src/west/app/main.py +++ b/src/west/app/main.py @@ -578,7 +578,7 @@ def handle_early_arg_errors(self, early_args): # gracefully. This provides more user-friendly output than # argparse can do on its own. - if (early_args.command_name and + if (early_args.command_name and not early_args.help and (early_args.command_name not in self.builtins and (not self.extensions or early_args.command_name not in self.extensions))): From e4214008eb0d5cb7269c791568d359cf0b88fe5b Mon Sep 17 00:00:00 2001 From: Pieter De Gendt Date: Wed, 17 Jul 2024 11:33:20 +0200 Subject: [PATCH 2/3] app: support alias commands Replace west commands with alias set in configuration files Signed-off-by: Pieter De Gendt --- src/west/app/main.py | 75 ++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 73 insertions(+), 2 deletions(-) diff --git a/src/west/app/main.py b/src/west/app/main.py index 8b848fb8..adfa3f99 100755 --- a/src/west/app/main.py +++ b/src/west/app/main.py @@ -19,6 +19,7 @@ import os from pathlib import Path, PurePath import platform +import shlex import shutil import signal import sys @@ -183,6 +184,7 @@ def __init__(self): self.mle = None # saved exception if load_manifest() fails self.builtins = {} # command name -> WestCommand instance self.extensions = {} # extension command name -> spec + self.aliases = {} # alias -> WestCommand instance self.builtin_groups = OrderedDict() # group name -> WestCommand list self.extension_groups = OrderedDict() # project path -> ext spec list self.west_parser = None # a WestArgumentParser @@ -243,6 +245,7 @@ def run(self, argv): # Set self.manifest and self.extensions. self.load_manifest() self.load_extension_specs() + self.load_aliases() # Set up initial argument parsers. This requires knowing # self.extensions, so it can't happen before now. @@ -402,6 +405,14 @@ def load_extension_specs(self): self.extension_groups[path] = filtered + def load_aliases(self): + if not self.config: + return + + self.aliases = { + k[6:]: Alias(k[6:], v) for k, v in self.config.items() if k.startswith('alias.') + } + def handle_extension_command_error(self, ece): if self.cmd is not None: msg = f"extension command \"{self.cmd.name}\" couldn't be run" @@ -424,9 +435,11 @@ def setup_parsers(self): # Set up and install command-line argument parsers. west_parser, subparser_gen = self.make_parsers() + real_command_names = set() # Add sub-parsers for the built-in commands. - for command in self.builtins.values(): + for name, command in self.builtins.items(): + real_command_names.add(name) command.add_parser(subparser_gen) # Add stub parsers for extensions. @@ -436,10 +449,19 @@ def setup_parsers(self): # extension's code, which we won't do unless parse_known_args() # says to run that extension. if self.extensions: - for path, specs in self.extension_groups.items(): + for specs in self.extension_groups.values(): for spec in specs: + real_command_names.add(spec.name) subparser_gen.add_parser(spec.name, add_help=False) + # Add aliases, but skip aliases that shadow other commands + # The help parser requires unique commands to be added + if self.aliases: + for name, alias in self.aliases.items(): + # Advanced users shadowing real commands do not get "alias help" + if name not in real_command_names: + alias.add_parser(subparser_gen) + # Save the instance state. self.west_parser = west_parser self.subparser_gen = subparser_gen @@ -491,6 +513,28 @@ def run_command(self, argv, early_args): # If we're running an extension, instantiate it from its # spec and re-parse arguments before running. + if not early_args.help and early_args.command_name != "help": + # Recursively replace alias command(s) if set + aliases = self.aliases.copy() + while early_args.command_name in aliases: + # Make sure we don't end up in an infinite loop + alias = aliases.pop(early_args.command_name) + + self.queued_io.append(lambda cmd, alias=alias: cmd.dbg( + f'Replacing alias {alias.name} with {alias.args}' + )) + + if len(alias.args) == 0: + # This loses the cmd.dbg() above - too bad, don't use empty aliases + self.print_usage_and_exit(f'west: empty alias "{alias.name}"') + + # Find and replace the command name. Must skip any other early args like -v + for i, arg in enumerate(argv): + if arg == early_args.command_name: + argv = argv[:i] + alias.args + argv[i + 1:] + break + early_args = early_args._replace(command_name=alias.args[0]) + self.handle_early_arg_errors(early_args) args, unknown = self.west_parser.parse_known_args(args=argv) @@ -825,6 +869,8 @@ def do_run(self, args, ignored): # exception handling block in app.run_command is in a # parent stack frame. app.run_extension(name, [name, '--help']) + elif app.aliases is not None and name in app.aliases: + app.aliases[name].parser.print_help() else: self.wrn(f'unknown command "{name}"') app.west_parser.print_help(top_level=True) @@ -833,6 +879,25 @@ def do_run(self, args, ignored): 'which may be causing this issue.\n' ' Try running "west update" or fixing the manifest.') +class Alias(WestCommand): + # An alias command, it does not run itself + + def __init__(self, cmd, args): + super().__init__(cmd, args or '', f'An alias that expands to: {args}') + + self.args = shlex.split(args) + + # Pseudo-parser that will never actually run except for ".print_help()" + def do_add_parser(self, parser_adder): + parser = parser_adder.add_parser( + self.name, help=self.help, description=self.description, add_help=False, + formatter_class=argparse.RawDescriptionHelpFormatter) + + return parser + + def do_run(self, args, ignored): + assert False + class WestHelpAction(argparse.Action): def __call__(self, parser, namespace, values, option_string=None): @@ -944,6 +1009,12 @@ def append(*strings): self.format_extension_spec(append, spec, width) append('') + if self.west_app.aliases: + append('aliases:') + for alias in self.west_app.aliases.values(): + self.format_command(append, alias, width) + append('') + if self.epilog: append(self.epilog) From ac4c6a218872bdeb83acee46cadc011af3deaea0 Mon Sep 17 00:00:00 2001 From: Pieter De Gendt Date: Wed, 17 Jul 2024 15:13:58 +0200 Subject: [PATCH 3/3] tests: Add alias testing Add simple test cases to verify alias' functionality. Signed-off-by: Pieter De Gendt --- tests/test_alias.py | 90 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 90 insertions(+) create mode 100644 tests/test_alias.py diff --git a/tests/test_alias.py b/tests/test_alias.py new file mode 100644 index 00000000..6dba3984 --- /dev/null +++ b/tests/test_alias.py @@ -0,0 +1,90 @@ +# Copyright (c) 2024, Basalte bv +# +# SPDX-License-Identifier: Apache-2.0 + +import os +import subprocess + +import pytest + +from conftest import cmd + +assert 'TOXTEMPDIR' in os.environ, "you must run these tests using tox" + +@pytest.fixture(autouse=True) +def autouse_tmpdir(config_tmpdir, west_init_tmpdir): + # Since this module tests west's configuration file features, + # adding autouse=True to the config_tmpdir and west_init_tmpdir fixtures + # saves typing and is less error-prone than using it below in every test case. + pass + +def test_alias_commands(): + cmd('config alias.test1 topdir') + cmd('config --global alias.test2 topdir') + cmd('config --system alias.test3 topdir') + + topdir_out = cmd('topdir') + + assert cmd('test1') == topdir_out + assert cmd('test2') == topdir_out + assert cmd('test3') == topdir_out + +def test_alias_help(): + cmd('config alias.test topdir') + + help_out = cmd('help test') + + assert "An alias that expands to: topdir" in help_out + assert cmd('-h test') == help_out + +def test_alias_recursive_commands(): + list_format = '{revision} TESTALIAS {name}' + cmd(['config', 'alias.test1', f'list -f "{list_format}"']) + cmd('config alias.test2 test1') + + assert cmd('test2') == cmd(['list', '-f', list_format]) + +def test_alias_infinite_recursion(): + cmd('config alias.test1 test2') + cmd('config alias.test2 test3') + cmd('config alias.test3 test1') + + with pytest.raises(subprocess.CalledProcessError) as excinfo: + cmd('test1', stderr=subprocess.STDOUT) + + assert 'unknown command "test1";' in str(excinfo.value.stdout) + +def test_alias_empty(): + cmd(['config', 'alias.empty', '']) + + # help command shouldn't fail + cmd('help') + + with pytest.raises(subprocess.CalledProcessError) as excinfo: + cmd('empty', stderr=subprocess.STDOUT) + + assert 'empty alias "empty"' in str(excinfo.value.stdout) + +def test_alias_early_args(): + cmd('config alias.test1 topdir') + + # An alias with an early command argument shouldn't fail + assert "Replacing alias test1 with ['topdir']" in cmd('-v test1') + +def test_alias_command_with_arguments(): + list_format = '{revision} TESTALIAS {name}' + cmd(['config', 'alias.revs', f'list -f "{list_format}"']) + + assert cmd('revs') == cmd(['list', '-f', list_format]) + +def test_alias_override(): + before = cmd('list') + list_format = '{name} : {revision}' + formatted = cmd(['list', '-f', list_format]) + + cmd(['config', 'alias.list', f'list -f "{list_format}"']) + + after = cmd('list') + + assert before != after + assert formatted == after