From 3a44baf68903d8604b0f57777e90d1ea211b0696 Mon Sep 17 00:00:00 2001 From: Anders Linn Date: Mon, 10 Nov 2025 12:01:57 -0700 Subject: [PATCH] Issue 24036: Add help for 'show ip route' subcommands Add a new `VtyshCommand` class that can be used to extend click cli commands with `vtysh` help text. The class calls down into `vtysh` to get subcommand information and then generates help text following the existing format used by the top-level SONiC CLI. Add bash completions for the `vtysh` subcommands. Fixes: [24036](https://github.com/sonic-net/sonic-buildimage/issues/24036) --- show/main.py | 13 +- show/vtysh_helper.py | 254 ++++++++++++++++++++++++ tests/vtysh_help_test.py | 409 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 670 insertions(+), 6 deletions(-) create mode 100644 show/vtysh_helper.py create mode 100644 tests/vtysh_help_test.py diff --git a/show/main.py b/show/main.py index 65d2723780..eeb48a750f 100755 --- a/show/main.py +++ b/show/main.py @@ -40,6 +40,7 @@ from . import acl from . import bgp_common +from .vtysh_helper import vtysh_command from . import chassis_modules from . import dropcounters from . import fabric @@ -1411,8 +1412,9 @@ def loopback_action(): # 'route' subcommand ("show ip route") # -@ip.command() -@click.argument('args', metavar='[IPADDRESS] [vrf ] [...]', nargs=-1, required=False) + +@ip.command(cls=vtysh_command("show ip route")) +@click.argument('args', nargs=-1, required=False) @click.option('--display', '-d', 'display', default=None, show_default=False, type=str, help='all|frontend') @click.option('--namespace', '-n', 'namespace', default=None, type=str, show_default=False, help='Namespace name or all') @click.option('--verbose', is_flag=True, help="Enable verbose output") @@ -1508,17 +1510,16 @@ def interfaces(namespace, display): # 'route' subcommand ("show ipv6 route") # -@ipv6.command() -@click.argument('args', metavar='[IPADDRESS] [vrf ] [...]', nargs=-1, required=False) +@ipv6.command(cls=vtysh_command("show ipv6 route")) +@click.argument('args', nargs=-1, required=False) @click.option('--display', '-d', 'display', default=None, show_default=False, type=str, help='all|frontend') @click.option('--namespace', '-n', 'namespace', default=None, type=str, show_default=False, help='Namespace name or all') @click.option('--verbose', is_flag=True, help="Enable verbose output") def route(args, namespace, display, verbose): """Show IPv6 routing table""" - # Call common handler to handle the show ipv6 route cmd + # Call common handler to handle the show ip route cmd bgp_common.show_routes(args, namespace, display, verbose, "ipv6") - # 'protocol' command @ipv6.command() @click.option('--verbose', is_flag=True, help="Enable verbose output") diff --git a/show/vtysh_helper.py b/show/vtysh_helper.py new file mode 100644 index 0000000000..105726eac7 --- /dev/null +++ b/show/vtysh_helper.py @@ -0,0 +1,254 @@ +import click +import functools +import os +import subprocess +import sys + +import click._bashcomplete as _bashcomplete + + +class VtyshCommand(click.Command): + """ + Custom Click command class that integrates vtysh help functionality. + This provides enhanced help by showing both Click help and vtysh subcommands. + """ + + # List of vtysh commands that support completion + vtysh_completion_commands = [] + + def __init__(self, name, vtysh_command_prefix, **kwargs): + """ + Initialize the VtyshCommand. + + Args: + name: Command name + vtysh_command_prefix: The vtysh command prefix (e.g., "show ip route") + **kwargs: Other Click command arguments + """ + self.vtysh_command_prefix = vtysh_command_prefix + super().__init__(name, **kwargs) + + def parse_args(self, ctx, args): + """Track vtysh command args for later use""" + help_options = ['-h', '--help', '-?', '?'] + self.raw_args = [] + for arg in args: + if arg in help_options: + break + self.raw_args.append(arg) + # SONiC CLI accepts '?' as a hidden help option, handle it explicitly here + if '?' in args: + click.echo(ctx.get_help()) + ctx.exit() + return super().parse_args(ctx, args) + + def get_help(self, ctx): + """Override Click's get_help to provide enhanced vtysh help.""" + formatter = click.HelpFormatter() + + # Try the full command first + is_valid = False + if len(self.raw_args) == 0: + is_valid = True + last_valid_command = self.vtysh_command_prefix + else: + arg_prefix = ' '.join(self.raw_args[:-1]) + full_command_prefix = f"{self.vtysh_command_prefix}" + if arg_prefix != "": + full_command_prefix += f" {arg_prefix}" + full_command = f"{self.vtysh_command_prefix} {' '.join(self.raw_args)}" + # Handle partial commands (ie, "show ip route sum") + completions = self.get_vtysh_completions(full_command) + if len(completions) == 1: + last_valid_command = f"{full_command_prefix} {completions[0]}" + is_valid = True + + if not is_valid: + # If the full command failed, work backwards to find last valid command prefix + last_valid_command = self.vtysh_command_prefix + for arg in self.raw_args: + test_command = f"{last_valid_command} {arg}" + + # Handle partial commands (ie, "show ip route sum") + completions = self.get_vtysh_completions(test_command) + if len(completions) == 1 or (len(completions) > 1 and completions[0] == arg): + test_command = f"{last_valid_command} {completions[0]}" + elif len(completions) > 1: + usage_args = self.get_usage_args(last_valid_command) + formatter.write_usage(last_valid_command, usage_args) + formatter.write(f'Try "{last_valid_command} -h" for help.') + formatter.write_paragraph() + formatter.write_paragraph() + formatter.write_text(f'Error: Too many matches: {", ".join(sorted(completions))}') + return formatter.getvalue().rstrip() + + vtysh_help_text = self.get_vtysh_help(test_command) + if vtysh_help_text and "% There is no matched command." in vtysh_help_text: + usage_args = self.get_usage_args(last_valid_command) + formatter.write_usage(last_valid_command, usage_args) + formatter.write(f'Try "{last_valid_command} -h" for help.') + formatter.write_paragraph() + formatter.write_paragraph() + formatter.write_text(f'Error: No such command "{arg}".') + return formatter.getvalue().rstrip() + + last_valid_command = test_command + + # Add Usage section + usage_args = self.get_usage_args(last_valid_command) + formatter.write_usage(last_valid_command, usage_args) + + # Add description + description = None + if self.raw_args: + description = self.get_vtysh_command_description(last_valid_command) + elif self.callback and self.callback.__doc__: + description = self.callback.__doc__.strip().split('\n')[0] + if description: + formatter.write_paragraph() + formatter.write_text(description) + + # Add Options section + opts = [] + for param in self.get_params(ctx): + rv = param.get_help_record(ctx) + if rv is not None: + opts.append(rv) + if opts: + with formatter.section("Options"): + formatter.write_dl(opts) + + # Add Commands section (from vtysh) + vtysh_subcommands = self.get_vtysh_subcommands(last_valid_command) + if len(vtysh_subcommands) > 0: + with formatter.section("Commands"): + formatter.write_dl(vtysh_subcommands) + + return formatter.getvalue().rstrip() + + def get_usage_args(self, command): + """Set usage args appropriately for nested vs. leaf commands.""" + vtysh_subcommands = self.get_vtysh_subcommands(command) + if vtysh_subcommands: + return "[OPTIONS] COMMAND [ARGS]..." + return "[OPTIONS]" + + def get_vtysh_command_description(self, command): + """Get description for the current command from vtysh help.""" + # remove last arg + curr_command = command.split()[-1] + prev_command = command.split()[:-1] + vtysh_subcommands = self.get_vtysh_subcommands(" ".join(prev_command)) + for c, d in vtysh_subcommands: + if c == curr_command: + return d + return "" + + def get_vtysh_completions(self, cmd_prefix): + """ + Get completion options from vtysh for the given command. + """ + subcommands = self.get_vtysh_subcommands(cmd_prefix, completion=True) + completions = [] + for cmpl, _ in subcommands: + if any(c.isupper() for c in cmpl) or (cmpl.startswith("(") and cmpl.endswith(")")): + # skip user-defined arguments like VRF_NAME or A.B.C.D, or ranges like (1-100) + continue + completions.append(cmpl) + return completions + + def get_vtysh_subcommands(self, command, completion=False): + """Get subcommands from vtysh for the given command.""" + vtysh_help_content = self.get_vtysh_help(command, completion) + if (not vtysh_help_content or "Error response from daemon:" in vtysh_help_content + or "failed to connect to any daemons" in vtysh_help_content): + return [] + + subcommands = [] + lines = vtysh_help_content.strip().split('\n') + + for line in lines: + line = line.strip() + if not line: + continue + + # Vtysh help format is typically: "subcommand description" + parts = line.split(None, 1) # Split on whitespace, max 2 parts + if len(parts) >= 1: + subcommand = parts[0].strip() + description = parts[1].strip() if len(parts) > 1 else "" + + # Only filter out obvious non-subcommands + if (subcommand and subcommand != '' and + not subcommand.startswith('%') and + not subcommand.startswith('Error:')): + subcommands.append((subcommand, description)) + return subcommands + + @functools.lru_cache() + def get_vtysh_help(self, cmd_prefix, completion=False): + """ + Get help for a vtysh command. + """ + try: + help_command = f"{cmd_prefix}" + help_command += "?" if completion else " ?" + result = subprocess.run( + ['vtysh', '-c', help_command], + capture_output=True, + text=True, + timeout=10 + ) + + # Check if command succeeded + if result.returncode == 0: + help_content = result.stdout.strip() + else: + # If there's an error, it might be in stderr + help_content = result.stderr.strip() if result.stderr else None + return help_content + + except Exception: + return None + + +def vtysh_command(vtysh_command_prefix): + """ + Factory function to create a VtyshCommand class with the given command prefix. + + Args: + vtysh_command_prefix (str): The vtysh command prefix (e.g., "show ip route") + + Returns: + A partial VtyshCommand class that can be used with @click.command(cls=...) + """ + VtyshCommand.vtysh_completion_commands.append(vtysh_command_prefix) + + class _VtyshCommand(VtyshCommand): + def __init__(self, name, **kwargs): + super().__init__(name, vtysh_command_prefix, **kwargs) + + return _VtyshCommand + + +_orig_bashcomplete = _bashcomplete.bashcomplete + + +def custom_bashcomplete(cli, prog_name, complete_var, complete_instr): + """ + Custom bashcomplete function that integrates vtysh completion. + """ + mode = os.environ.get(complete_var) + if mode == "complete": + command = os.environ.get("COMP_WORDS", "") + for base_command in VtyshCommand.vtysh_completion_commands: + if command.startswith(base_command): + vtysh_cmd = VtyshCommand(command, base_command) + completions = vtysh_cmd.get_vtysh_completions(command) + for c in completions: + print(c) + sys.exit(0) + return _orig_bashcomplete(cli, prog_name, complete_var, complete_instr) + + +_bashcomplete.bashcomplete = custom_bashcomplete diff --git a/tests/vtysh_help_test.py b/tests/vtysh_help_test.py new file mode 100644 index 0000000000..a962ad8af7 --- /dev/null +++ b/tests/vtysh_help_test.py @@ -0,0 +1,409 @@ +import os +import sys +from click.testing import CliRunner +from unittest import mock + +import show.main as show + +from show.vtysh_helper import VtyshCommand + +# Add test path +test_path = os.path.dirname(os.path.abspath(__file__)) +modules_path = os.path.dirname(test_path) +sys.path.insert(0, test_path) +sys.path.insert(0, modules_path) + + +class TestVtyshHelpCommands: + """Test VtyshCommand help functionality for vtysh-integrated commands. + + The test uses 'show ip route' as an example command, but all output is mocked + and we're just testing internals. The coverage should apply to any command + that uses the VtyshCommand class. + """ + + @classmethod + def setup_class(cls): + os.environ["UTILITIES_UNIT_TESTING"] = "1" + + @classmethod + def teardown_class(cls): + os.environ["UTILITIES_UNIT_TESTING"] = "0" + + def teardown_method(self, method): + VtyshCommand.get_vtysh_help.cache_clear() + + def test_vtysh_help_basic_functionality(self): + """Test basic help output structure for VtyshCommand.""" + runner = CliRunner() + result = runner.invoke(show.cli.commands["ip"].commands["route"], ["--help"]) + + assert result.exit_code == 0 + assert "Usage: show ip route" in result.output + assert "Show IP (IPv4) routing table" in result.output + assert "Options:" in result.output + assert "--help" in result.output + + @mock.patch('show.vtysh_helper.subprocess.run') + def test_vtysh_help_with_subcommands(self, mock_subprocess): + """Test help output when vtysh subcommands exist.""" + mock_result = mock.Mock() + mock_result.returncode = 0 + mock_result.stdout = """ + summary Summary of all routes + vrf Specify the VRF + A.B.C.D Network in the IP routing table to display +""" + mock_subprocess.return_value = mock_result + + runner = CliRunner() + result = runner.invoke(show.cli.commands["ip"].commands["route"], ["--help"]) + + assert result.exit_code == 0 + assert "Usage: show ip route [OPTIONS] COMMAND [ARGS]..." in result.output + assert "Commands:" in result.output + assert "summary" in result.output + assert "Summary of all routes" in result.output + assert "vrf" in result.output + assert "Specify the VRF" in result.output + assert "A.B.C.D" in result.output + assert "Network in the IP routing table to display" in result.output + + @mock.patch('show.vtysh_helper.subprocess.run') + def test_vtysh_help_without_subcommands(self, mock_subprocess): + """Test help output when no vtysh subcommands exist.""" + mock_result = mock.Mock() + mock_result.returncode = 0 + mock_result.stdout = "" # No subcommands available + mock_subprocess.return_value = mock_result + + runner = CliRunner() + result = runner.invoke(show.cli.commands["ip"].commands["route"], ["summary", "--help"]) + + assert result.exit_code == 0 + assert "Usage: show ip route summary [OPTIONS]" in result.output + assert "COMMAND [ARGS]" not in result.output + assert "Commands:" not in result.output + + @mock.patch('show.vtysh_helper.subprocess.run') + def test_nested_command_help(self, mock_subprocess): + """Test help for nested commands shows correct usage and description.""" + # Mock vtysh response - need to handle multiple calls + def subprocess_side_effect(*args, **kwargs): + command = args[0] + mock_result = mock.Mock() + mock_result.returncode = 0 + + if 'show ip route ?' in ' '.join(command): + # Parent command help + mock_result.stdout = """ + summary Summary of all routes + vrf Specify the VRF +""" + elif 'show ip route vrf ?' in ' '.join(command): + # Nested command help + mock_result.stdout = """ + NAME VRF name + all All VRFs +""" + else: + mock_result.stdout = "" + + return mock_result + + mock_subprocess.side_effect = subprocess_side_effect + + runner = CliRunner() + result = runner.invoke(show.cli.commands["ip"].commands["route"], ["vrf", "--help"]) + + assert result.exit_code == 0 + assert "Usage: show ip route vrf" in result.output + assert "Commands:" in result.output + assert "NAME" in result.output + assert "all" in result.output + assert "Specify the VRF" in result.output # Description from parent command + assert "summary" not in result.output # Not a subcommand of 'vrf' + assert "Summary of all routes" not in result.output # Not a subcommand of 'vrf' + + @mock.patch('show.vtysh_helper.subprocess.run') + def test_basic_invalid_command_error_handling(self, mock_subprocess): + """Test error handling for invalid commands.""" + def subprocess_side_effect(*args, **kwargs): + command = args[0] + mock_result = mock.Mock() + mock_result.returncode = 0 + + if 'invalid' in ' '.join(command): + mock_result.stdout = "% There is no matched command." + else: + mock_result.stdout = """ + summary Summary of all routes + vrf Specify the VRF +""" + return mock_result + + mock_subprocess.side_effect = subprocess_side_effect + + runner = CliRunner() + result = runner.invoke(show.cli.commands["ip"].commands["route"], ["invalid", "--help"]) + + assert result.exit_code == 0 + assert "Usage: show ip route [OPTIONS] COMMAND [ARGS]..." in result.output + assert "Error:" in result.output + assert 'No such command "invalid"' in result.output + assert 'Try "show ip route -h" for help' in result.output + assert "Commands:" not in result.output + assert "summary" not in result.output + assert "vrf" not in result.output + + @mock.patch('show.vtysh_helper.subprocess.run') + def test_nested_invalid_command_error_handling(self, mock_subprocess): + """Test that error usage line shows the last valid command, not the failing one.""" + def subprocess_side_effect(*args, **kwargs): + command = args[0] + command_str = ' '.join(command) + mock_result = mock.Mock() + mock_result.returncode = 0 + + if 'show ip route ?' in command_str: + mock_result.stdout = """ + summary Summary of all routes + vrf Specify the VRF +""" + elif 'show ip route vrf ?' in command_str: + mock_result.stdout = """ + NAME VRF name + all All VRFs +""" + elif 'invalid' in command_str: + mock_result.stdout = "% There is no matched command." + else: + mock_result.stdout = "" + + return mock_result + + mock_subprocess.side_effect = subprocess_side_effect + + runner = CliRunner() + result = runner.invoke(show.cli.commands["ip"].commands["route"], ["vrf", "invalid", "--help"]) + + assert result.exit_code == 0 + assert "Usage: show ip route vrf [OPTIONS] COMMAND [ARGS]..." in result.output + assert 'No such command "invalid"' in result.output + assert 'Try "show ip route vrf -h" for help' in result.output + assert "Commands:" not in result.output + assert "summary" not in result.output + assert "all" not in result.output + + @mock.patch('show.vtysh_helper.subprocess.run') + def test_vtysh_caching_functionality(self, mock_subprocess): + """Test that vtysh calls are cached to avoid repeated calls.""" + mock_result = mock.Mock() + mock_result.returncode = 0 + mock_result.stdout = """ + summary Summary of all routes + vrf Specify the VRF +""" + mock_subprocess.return_value = mock_result + + runner = CliRunner() + # First call + result1 = runner.invoke(show.cli.commands["ip"].commands["route"], ["--help"]) + # Second call (should use cache) + result2 = runner.invoke(show.cli.commands["ip"].commands["route"], ["--help"]) + + assert result1.exit_code == 0 + assert result2.exit_code == 0 + # Should have been called only once due to caching + assert mock_subprocess.call_count == 1 + + +class TestVtyshCompletionCommands: + """Test VtyshCommand completion functionality for vtysh-integrated commands.""" + + @classmethod + def setup_class(cls): + os.environ["UTILITIES_UNIT_TESTING"] = "1" + + @classmethod + def teardown_class(cls): + os.environ["UTILITIES_UNIT_TESTING"] = "0" + + def teardown_method(self, method): + VtyshCommand.get_vtysh_help.cache_clear() + + @mock.patch('show.vtysh_helper.subprocess.run') + def test_basic_completion_functionality(self, mock_subprocess): + """Test basic completion returns available subcommands.""" + mock_result = mock.Mock() + mock_result.returncode = 0 + mock_result.stdout = """ + summary Summary of all routes + vrf Specify the VRF + A.B.C.D Network in the IP routing table to display + json JavaScript Object Notation + (1-100) Number of entries to display +""" + mock_subprocess.return_value = mock_result + + route_cmd = show.cli.commands["ip"].commands["route"] + completions = route_cmd.get_vtysh_completions("show ip route") + + assert "summary" in completions + assert "vrf" in completions + assert "json" in completions + assert "A.B.C.D" not in completions + assert "(1-100)" not in completions + assert len(completions) == 3 + + @mock.patch('show.vtysh_helper.subprocess.run') + def test_nested_completion_functionality(self, mock_subprocess): + """Test completion for nested commands.""" + mock_result = mock.Mock() + mock_result.returncode = 0 + mock_result.stdout = """ + NAME VRF name + all All VRFs + default Default VRF +""" + mock_subprocess.return_value = mock_result + + route_cmd = show.cli.commands["ip"].commands["route"] + completions = route_cmd.get_vtysh_completions("show ip route vrf") + + assert "NAME" not in completions + assert "all" in completions + assert "default" in completions + assert len(completions) == 2 + + @mock.patch('show.vtysh_helper.subprocess.run') + def test_completion_with_no_results(self, mock_subprocess): + """Test completion when no subcommands are available.""" + mock_result = mock.Mock() + mock_result.returncode = 0 + mock_result.stdout = "" + mock_subprocess.return_value = mock_result + + route_cmd = show.cli.commands["ip"].commands["route"] + completions = route_cmd.get_vtysh_completions("show ip route summary") + + assert completions == [] + + @mock.patch('show.vtysh_helper.subprocess.run') + def test_completion_with_vtysh_error(self, mock_subprocess): + """Test completion when vtysh returns an error.""" + mock_result = mock.Mock() + mock_result.returncode = 1 + mock_result.stdout = "% There is no matched command." + mock_result.stderr = "Error: command not found" + mock_subprocess.return_value = mock_result + + route_cmd = show.cli.commands["ip"].commands["route"] + completions = route_cmd.get_vtysh_completions("show ip route invalid") + + assert completions == [] + + @mock.patch('show.vtysh_helper.subprocess.run') + def test_completion_with_whitespace_handling(self, mock_subprocess): + """Test completion parsing handles whitespace correctly.""" + mock_result = mock.Mock() + mock_result.returncode = 0 + mock_result.stdout = """ + + summary Summary of all routes + + vrf Specify the VRF + json JavaScript Object Notation + +""" + mock_subprocess.return_value = mock_result + + route_cmd = show.cli.commands["ip"].commands["route"] + completions = route_cmd.get_vtysh_completions("show ip route") + + assert "summary" in completions + assert "vrf" in completions + assert "json" in completions + assert len(completions) == 3 + # Should not include empty strings + assert "" not in completions + + @mock.patch('show.vtysh_helper.subprocess.run') + def test_nested_command_help_with_completion(self, mock_subprocess): + """Test help for nested commands shows correct usage and description, with completion.""" + # Mock vtysh response - need to handle multiple calls + def subprocess_side_effect(*args, **kwargs): + command = args[0] + mock_result = mock.Mock() + mock_result.returncode = 0 + + if 'show ip route summ?' in ' '.join(command) or 'show ip route ?' in ' '.join(command): + # Completion command help + mock_result.stdout = """ + summary Summary of all routes +""" + elif 'show ip route summary ?' in ' '.join(command): + # Parent command help + mock_result.stdout = """ + vrf Specify the VRF +""" + else: + mock_result.stdout = "" + + return mock_result + + mock_subprocess.side_effect = subprocess_side_effect + + runner = CliRunner() + result = runner.invoke(show.cli.commands["ip"].commands["route"], ["summ", "--help"]) + + assert result.exit_code == 0 + assert "Usage: show ip route summary" in result.output + assert "Summary of all routes" in result.output + assert "Commands:" in result.output + assert "vrf" in result.output + assert "Specify the VRF" in result.output + + @mock.patch('show.vtysh_helper.subprocess.run') + def test_nested_command_help_with_inline_completion(self, mock_subprocess): + """Test help for nested commands shows correct usage and description, with inline completion.""" + # Mock vtysh response - need to handle multiple calls + def subprocess_side_effect(*args, **kwargs): + command = args[0] + mock_result = mock.Mock() + mock_result.returncode = 0 + + if 'show ip route summ?' in ' '.join(command) or 'show ip route ?' in ' '.join(command): + # Completion command help + mock_result.stdout = """ + summary Summary of all routes +""" + elif 'show ip route summary ?' in ' '.join(command): + # Parent command help + mock_result.stdout = """ + vrf Specify the VRF +""" + elif 'show ip route summary vrf ?' in ' '.join(command): + # Nested command help + mock_result.stdout = """ + NAME VRF name + all All VRFs +""" + else: + mock_result.stdout = "" + + return mock_result + + mock_subprocess.side_effect = subprocess_side_effect + + runner = CliRunner() + result = runner.invoke(show.cli.commands["ip"].commands["route"], ["summ", "vrf", "--help"]) + + assert result.exit_code == 0 + assert "Usage: show ip route summary vrf" in result.output + assert "Commands:" in result.output + assert "NAME" in result.output + assert "Specify the VRF" in result.output + assert "all" in result.output + assert "All VRFs" in result.output + assert "Summary of all routes" not in result.output