Skip to content

Commit 11f956f

Browse files
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](sonic-net/sonic-buildimage#24036)
1 parent e668f7a commit 11f956f

File tree

3 files changed

+670
-6
lines changed

3 files changed

+670
-6
lines changed

show/main.py

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040

4141
from . import acl
4242
from . import bgp_common
43+
from .vtysh_helper import vtysh_command
4344
from . import chassis_modules
4445
from . import dropcounters
4546
from . import fabric
@@ -1411,8 +1412,9 @@ def loopback_action():
14111412
# 'route' subcommand ("show ip route")
14121413
#
14131414

1414-
@ip.command()
1415-
@click.argument('args', metavar='[IPADDRESS] [vrf <vrf_name>] [...]', nargs=-1, required=False)
1415+
1416+
@ip.command(cls=vtysh_command("show ip route"))
1417+
@click.argument('args', nargs=-1, required=False)
14161418
@click.option('--display', '-d', 'display', default=None, show_default=False, type=str, help='all|frontend')
14171419
@click.option('--namespace', '-n', 'namespace', default=None, type=str, show_default=False, help='Namespace name or all')
14181420
@click.option('--verbose', is_flag=True, help="Enable verbose output")
@@ -1508,17 +1510,16 @@ def interfaces(namespace, display):
15081510
# 'route' subcommand ("show ipv6 route")
15091511
#
15101512

1511-
@ipv6.command()
1512-
@click.argument('args', metavar='[IPADDRESS] [vrf <vrf_name>] [...]', nargs=-1, required=False)
1513+
@ipv6.command(cls=vtysh_command("show ipv6 route"))
1514+
@click.argument('args', nargs=-1, required=False)
15131515
@click.option('--display', '-d', 'display', default=None, show_default=False, type=str, help='all|frontend')
15141516
@click.option('--namespace', '-n', 'namespace', default=None, type=str, show_default=False, help='Namespace name or all')
15151517
@click.option('--verbose', is_flag=True, help="Enable verbose output")
15161518
def route(args, namespace, display, verbose):
15171519
"""Show IPv6 routing table"""
1518-
# Call common handler to handle the show ipv6 route cmd
1520+
# Call common handler to handle the show ip route cmd
15191521
bgp_common.show_routes(args, namespace, display, verbose, "ipv6")
15201522

1521-
15221523
# 'protocol' command
15231524
@ipv6.command()
15241525
@click.option('--verbose', is_flag=True, help="Enable verbose output")

show/vtysh_helper.py

Lines changed: 254 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,254 @@
1+
import click
2+
import functools
3+
import os
4+
import subprocess
5+
import sys
6+
7+
import click._bashcomplete as _bashcomplete
8+
9+
10+
class VtyshCommand(click.Command):
11+
"""
12+
Custom Click command class that integrates vtysh help functionality.
13+
This provides enhanced help by showing both Click help and vtysh subcommands.
14+
"""
15+
16+
# List of vtysh commands that support completion
17+
vtysh_completion_commands = []
18+
19+
def __init__(self, name, vtysh_command_prefix, **kwargs):
20+
"""
21+
Initialize the VtyshCommand.
22+
23+
Args:
24+
name: Command name
25+
vtysh_command_prefix: The vtysh command prefix (e.g., "show ip route")
26+
**kwargs: Other Click command arguments
27+
"""
28+
self.vtysh_command_prefix = vtysh_command_prefix
29+
super().__init__(name, **kwargs)
30+
31+
def parse_args(self, ctx, args):
32+
"""Track vtysh command args for later use"""
33+
help_options = ['-h', '--help', '-?', '?']
34+
self.raw_args = []
35+
for arg in args:
36+
if arg in help_options:
37+
break
38+
self.raw_args.append(arg)
39+
# SONiC CLI accepts '?' as a hidden help option, handle it explicitly here
40+
if '?' in args:
41+
click.echo(ctx.get_help())
42+
ctx.exit()
43+
return super().parse_args(ctx, args)
44+
45+
def get_help(self, ctx):
46+
"""Override Click's get_help to provide enhanced vtysh help."""
47+
formatter = click.HelpFormatter()
48+
49+
# Try the full command first
50+
is_valid = False
51+
if len(self.raw_args) == 0:
52+
is_valid = True
53+
last_valid_command = self.vtysh_command_prefix
54+
else:
55+
arg_prefix = ' '.join(self.raw_args[:-1])
56+
full_command_prefix = f"{self.vtysh_command_prefix}"
57+
if arg_prefix != "":
58+
full_command_prefix += f" {arg_prefix}"
59+
full_command = f"{self.vtysh_command_prefix} {' '.join(self.raw_args)}"
60+
# Handle partial commands (ie, "show ip route sum")
61+
completions = self.get_vtysh_completions(full_command)
62+
if len(completions) == 1:
63+
last_valid_command = f"{full_command_prefix} {completions[0]}"
64+
is_valid = True
65+
66+
if not is_valid:
67+
# If the full command failed, work backwards to find last valid command prefix
68+
last_valid_command = self.vtysh_command_prefix
69+
for arg in self.raw_args:
70+
test_command = f"{last_valid_command} {arg}"
71+
72+
# Handle partial commands (ie, "show ip route sum")
73+
completions = self.get_vtysh_completions(test_command)
74+
if len(completions) == 1:
75+
test_command = f"{last_valid_command} {completions[0]}"
76+
elif len(completions) > 1:
77+
usage_args = self.get_usage_args(last_valid_command)
78+
formatter.write_usage(last_valid_command, usage_args)
79+
formatter.write(f'Try "{last_valid_command} -h" for help.')
80+
formatter.write_paragraph()
81+
formatter.write_paragraph()
82+
formatter.write_text(f'Error: Too many matches: {", ".join(sorted(completions))}')
83+
return formatter.getvalue().rstrip()
84+
85+
vtysh_help_text = self.get_vtysh_help(test_command)
86+
if vtysh_help_text and "% There is no matched command." in vtysh_help_text:
87+
usage_args = self.get_usage_args(last_valid_command)
88+
formatter.write_usage(last_valid_command, usage_args)
89+
formatter.write(f'Try "{last_valid_command} -h" for help.')
90+
formatter.write_paragraph()
91+
formatter.write_paragraph()
92+
formatter.write_text(f'Error: No such command "{arg}".')
93+
return formatter.getvalue().rstrip()
94+
95+
last_valid_command = test_command
96+
97+
# Add Usage section
98+
usage_args = self.get_usage_args(last_valid_command)
99+
formatter.write_usage(last_valid_command, usage_args)
100+
101+
# Add description
102+
description = None
103+
if self.raw_args:
104+
description = self.get_vtysh_command_description(last_valid_command)
105+
elif self.callback and self.callback.__doc__:
106+
description = self.callback.__doc__.strip().split('\n')[0]
107+
if description:
108+
formatter.write_paragraph()
109+
formatter.write_text(description)
110+
111+
# Add Options section
112+
opts = []
113+
for param in self.get_params(ctx):
114+
rv = param.get_help_record(ctx)
115+
if rv is not None:
116+
opts.append(rv)
117+
if opts:
118+
with formatter.section("Options"):
119+
formatter.write_dl(opts)
120+
121+
# Add Commands section (from vtysh)
122+
vtysh_subcommands = self.get_vtysh_subcommands(last_valid_command)
123+
if len(vtysh_subcommands) > 0:
124+
with formatter.section("Commands"):
125+
formatter.write_dl(vtysh_subcommands)
126+
127+
return formatter.getvalue().rstrip()
128+
129+
def get_usage_args(self, command):
130+
"""Set usage args appropriately for nested vs. leaf commands."""
131+
vtysh_subcommands = self.get_vtysh_subcommands(command)
132+
if vtysh_subcommands:
133+
return "[OPTIONS] COMMAND [ARGS]..."
134+
return "[OPTIONS]"
135+
136+
def get_vtysh_command_description(self, command):
137+
"""Get description for the current command from vtysh help."""
138+
# remove last arg
139+
curr_command = command.split()[-1]
140+
prev_command = command.split()[:-1]
141+
vtysh_subcommands = self.get_vtysh_subcommands(" ".join(prev_command))
142+
for c, d in vtysh_subcommands:
143+
if c == curr_command:
144+
return d
145+
return ""
146+
147+
def get_vtysh_completions(self, cmd_prefix):
148+
"""
149+
Get completion options from vtysh for the given command.
150+
"""
151+
subcommands = self.get_vtysh_subcommands(cmd_prefix, completion=True)
152+
completions = []
153+
for cmpl, _ in subcommands:
154+
if any(c.isupper() for c in cmpl) or (cmpl.startswith("(") and cmpl.endswith(")")):
155+
# skip user-defined arguments like VRF_NAME or A.B.C.D, or ranges like (1-100)
156+
continue
157+
completions.append(cmpl)
158+
return completions
159+
160+
def get_vtysh_subcommands(self, command, completion=False):
161+
"""Get subcommands from vtysh for the given command."""
162+
vtysh_help_content = self.get_vtysh_help(command, completion)
163+
if (not vtysh_help_content or "Error response from daemon:" in vtysh_help_content
164+
or "failed to connect to any daemons" in vtysh_help_content):
165+
return []
166+
167+
subcommands = []
168+
lines = vtysh_help_content.strip().split('\n')
169+
170+
for line in lines:
171+
line = line.strip()
172+
if not line:
173+
continue
174+
175+
# Vtysh help format is typically: "subcommand description"
176+
parts = line.split(None, 1) # Split on whitespace, max 2 parts
177+
if len(parts) >= 1:
178+
subcommand = parts[0].strip()
179+
description = parts[1].strip() if len(parts) > 1 else ""
180+
181+
# Only filter out obvious non-subcommands
182+
if (subcommand and subcommand != '<cr>' and
183+
not subcommand.startswith('%') and
184+
not subcommand.startswith('Error:')):
185+
subcommands.append((subcommand, description))
186+
return subcommands
187+
188+
@functools.lru_cache()
189+
def get_vtysh_help(self, cmd_prefix, completion=False):
190+
"""
191+
Get help for a vtysh command.
192+
"""
193+
try:
194+
help_command = f"{cmd_prefix}"
195+
help_command += "?" if completion else " ?"
196+
result = subprocess.run(
197+
['vtysh', '-c', help_command],
198+
capture_output=True,
199+
text=True,
200+
timeout=10
201+
)
202+
203+
# Check if command succeeded
204+
if result.returncode == 0:
205+
help_content = result.stdout.strip()
206+
else:
207+
# If there's an error, it might be in stderr
208+
help_content = result.stderr.strip() if result.stderr else None
209+
return help_content
210+
211+
except Exception:
212+
return None
213+
214+
215+
def vtysh_command(vtysh_command_prefix):
216+
"""
217+
Factory function to create a VtyshCommand class with the given command prefix.
218+
219+
Args:
220+
vtysh_command_prefix (str): The vtysh command prefix (e.g., "show ip route")
221+
222+
Returns:
223+
A partial VtyshCommand class that can be used with @click.command(cls=...)
224+
"""
225+
VtyshCommand.vtysh_completion_commands.append(vtysh_command_prefix)
226+
227+
class _VtyshCommand(VtyshCommand):
228+
def __init__(self, name, **kwargs):
229+
super().__init__(name, vtysh_command_prefix, **kwargs)
230+
231+
return _VtyshCommand
232+
233+
234+
_orig_bashcomplete = _bashcomplete.bashcomplete
235+
236+
237+
def custom_bashcomplete(cli, prog_name, complete_var, complete_instr):
238+
"""
239+
Custom bashcomplete function that integrates vtysh completion.
240+
"""
241+
mode = os.environ.get(complete_var)
242+
if mode == "complete":
243+
command = os.environ.get("COMP_WORDS", "")
244+
for base_command in VtyshCommand.vtysh_completion_commands:
245+
if command.startswith(base_command):
246+
vtysh_cmd = VtyshCommand(command, base_command)
247+
completions = vtysh_cmd.get_vtysh_completions(command)
248+
for c in completions:
249+
print(c)
250+
sys.exit(0)
251+
return _orig_bashcomplete(cli, prog_name, complete_var, complete_instr)
252+
253+
254+
_bashcomplete.bashcomplete = custom_bashcomplete

0 commit comments

Comments
 (0)