Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

app: support alias commands #716

Merged
merged 3 commits into from
Sep 6, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 74 additions & 3 deletions src/west/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
import os
from pathlib import Path, PurePath
import platform
import shlex
import shutil
import signal
import sys
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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"
Expand All @@ -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.
Expand All @@ -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
pdgendt marked this conversation as resolved.
Show resolved Hide resolved
# 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
Expand Down Expand Up @@ -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}"')
pdgendt marked this conversation as resolved.
Show resolved Hide resolved

# 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)

Expand Down Expand Up @@ -578,7 +622,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))):
Expand Down Expand Up @@ -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)
Expand All @@ -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 '<empty>', 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):
pdgendt marked this conversation as resolved.
Show resolved Hide resolved
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):
Expand Down Expand Up @@ -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)

Expand Down
90 changes: 90 additions & 0 deletions tests/test_alias.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
# Copyright (c) 2024, Basalte bv
pdgendt marked this conversation as resolved.
Show resolved Hide resolved
#
# 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')
pdgendt marked this conversation as resolved.
Show resolved Hide resolved

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():
pdgendt marked this conversation as resolved.
Show resolved Hide resolved
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
Loading