Skip to content

Commit 4895d5d

Browse files
authored
Merge pull request #264 from lobocv/feature/submenu
added a decorator function that allows nesting of Cmd classes to create submenus
2 parents 519f438 + 68326c0 commit 4895d5d

3 files changed

Lines changed: 476 additions & 0 deletions

File tree

cmd2.py

Lines changed: 225 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -547,6 +547,230 @@ def strip_ansi(text):
547547
return ANSI_ESCAPE_RE.sub('', text)
548548

549549

550+
def _pop_readline_history(clear_history=True):
551+
"""Returns a copy of readline's history and optionally clears it (default)"""
552+
history = [
553+
readline.get_history_item(i)
554+
for i in range(1, 1 + readline.get_current_history_length())
555+
]
556+
if clear_history:
557+
readline.clear_history()
558+
return history
559+
560+
561+
def _push_readline_history(history, clear_history=True):
562+
"""Restores readline's history and optionally clears it first (default)"""
563+
if clear_history:
564+
readline.clear_history()
565+
for line in history:
566+
readline.add_history(line)
567+
568+
569+
def _complete_from_cmd(cmd_obj, text, line, begidx, endidx):
570+
"""Complete as though the user was typing inside cmd's cmdloop()"""
571+
from itertools import takewhile
572+
command_subcommand_params = line.split(None, 3)
573+
574+
if len(command_subcommand_params) < (3 if text else 2):
575+
return cmd_obj.completenames(text)
576+
577+
command, subcommand = command_subcommand_params[:2]
578+
n = len(command) + sum(1 for _ in takewhile(str.isspace, line))
579+
cfun = getattr(cmd_obj, 'complete_' + subcommand, cmd_obj.complete)
580+
return cfun(text, line[n:], begidx - n, endidx - n)
581+
582+
583+
class AddSubmenu(object):
584+
"""Conveniently add a submenu (Cmd-like class) to a Cmd
585+
586+
e.g. given "class SubMenu(Cmd): ..." then
587+
588+
@AddSubmenu(SubMenu(), 'sub')
589+
class MyCmd(cmd.Cmd):
590+
....
591+
592+
will have the following effects:
593+
1. 'sub' will interactively enter the cmdloop of a SubMenu instance
594+
2. 'sub cmd args' will call do_cmd(args) in a SubMenu instance
595+
3. 'sub ... [TAB]' will have the same behavior as [TAB] in a SubMenu cmdloop
596+
i.e., autocompletion works the way you think it should
597+
4. 'help sub [cmd]' will print SubMenu's help (calls its do_help())
598+
"""
599+
600+
class _Nonexistent(object):
601+
"""
602+
Used to mark missing attributes.
603+
Disable __dict__ creation since this class does nothing
604+
"""
605+
__slots__ = () #
606+
607+
def __init__(self,
608+
submenu,
609+
command,
610+
aliases=(),
611+
reformat_prompt="{super_prompt}>> {sub_prompt}",
612+
shared_attributes=None,
613+
require_predefined_shares=True,
614+
create_subclass=False
615+
):
616+
"""Set up the class decorator
617+
618+
submenu (Cmd): Instance of something cmd.Cmd-like
619+
620+
command (str): The command the user types to access the SubMenu instance
621+
622+
aliases (iterable): More commands that will behave like "command"
623+
624+
reformat_prompt (str): Format str or None to disable
625+
if it's a string, it should contain one or more of:
626+
{super_prompt}: The current cmd's prompt
627+
{command}: The command in the current cmd with which it was called
628+
{sub_prompt}: The subordinate cmd's original prompt
629+
the default is "{super_prompt}{command} {sub_prompt}"
630+
631+
shared_attributes (dict): dict of the form {'subordinate_attr': 'parent_attr'}
632+
the attributes are copied to the submenu at the last moment; the submenu's
633+
attributes are backed up before this and restored afterward
634+
635+
require_predefined_shares: The shared attributes above must be independently
636+
defined in the subordinate Cmd (default: True)
637+
638+
create_subclass: put the modifications in a subclass rather than modifying
639+
the existing class (default: False)
640+
"""
641+
self.submenu = submenu
642+
self.command = command
643+
self.aliases = aliases
644+
645+
if reformat_prompt is not None and not isinstance(reformat_prompt, str):
646+
raise ValueError("reformat_prompt should be either a format string or None")
647+
self.reformat_prompt = reformat_prompt
648+
649+
if require_predefined_shares:
650+
for attr in shared_attributes.keys():
651+
if not hasattr(submenu, attr):
652+
raise AttributeError("The shared attribute '{attr}' is not defined in {cmd}. Either define {attr} "
653+
"in {cmd} or set require_predefined_shares=False."
654+
.format(cmd=submenu.__class__.__name__, attr=attr))
655+
656+
self.shared_attributes = {} if shared_attributes is None else shared_attributes
657+
self.create_subclass = create_subclass
658+
659+
def __call__(self, cmd_obj):
660+
"""Creates a subclass of Cmd wherein the given submenu can be accessed via the given command"""
661+
def enter_submenu(parent_cmd, line):
662+
"""
663+
This function will be bound to do_<submenu> and will change the scope of the CLI to that of the
664+
submenu.
665+
"""
666+
submenu = self.submenu
667+
original_attributes = {attr: getattr(submenu, attr, AddSubmenu._Nonexistent)
668+
for attr in self.shared_attributes.keys()
669+
}
670+
try:
671+
# copy over any shared attributes
672+
for sub_attr, par_attr in self.shared_attributes.items():
673+
setattr(submenu, sub_attr, getattr(parent_cmd, par_attr))
674+
675+
if line.parsed.args:
676+
# Remove the menu argument and execute the command in the submenu
677+
line = submenu.parser_manager.parsed(line.parsed.args)
678+
submenu.precmd(line)
679+
ret = submenu.onecmd(line)
680+
submenu.postcmd(ret, line)
681+
else:
682+
history = _pop_readline_history()
683+
if self.reformat_prompt is not None:
684+
prompt = submenu.prompt
685+
submenu.prompt = self.reformat_prompt.format(
686+
super_prompt=parent_cmd.prompt,
687+
command=self.command,
688+
sub_prompt=prompt,
689+
)
690+
submenu.cmdloop()
691+
if self.reformat_prompt is not None:
692+
self.submenu.prompt = prompt
693+
_push_readline_history(history)
694+
finally:
695+
# copy back original attributes
696+
for attr, value in original_attributes.items():
697+
if attr is not AddSubmenu._Nonexistent:
698+
setattr(submenu, attr, value)
699+
else:
700+
delattr(submenu, attr)
701+
702+
def complete_submenu(_self, text, line, begidx, endidx):
703+
"""
704+
This function will be bound to complete_<submenu> and will perform the complete commands of the submenu.
705+
"""
706+
submenu = self.submenu
707+
original_attributes = {
708+
attr: getattr(submenu, attr, AddSubmenu._Nonexistent)
709+
for attr in self.shared_attributes.keys()
710+
}
711+
try:
712+
# copy over any shared attributes
713+
for sub_attr, par_attr in self.shared_attributes.items():
714+
setattr(submenu, sub_attr, getattr(_self, par_attr))
715+
716+
return _complete_from_cmd(submenu, text, line, begidx, endidx)
717+
finally:
718+
# copy back original attributes
719+
for attr, value in original_attributes.items():
720+
if attr is not AddSubmenu._Nonexistent:
721+
setattr(submenu, attr, value)
722+
else:
723+
delattr(submenu, attr)
724+
725+
original_do_help = cmd_obj.do_help
726+
original_complete_help = cmd_obj.complete_help
727+
728+
def help_submenu(_self, line):
729+
"""
730+
This function will be bound to help_<submenu> and will call the help commands of the submenu.
731+
"""
732+
tokens = line.split(None, 1)
733+
if tokens and (tokens[0] == self.command or tokens[0] in self.aliases):
734+
self.submenu.do_help(tokens[1] if len(tokens) == 2 else '')
735+
else:
736+
original_do_help(_self, line)
737+
738+
def _complete_submenu_help(_self, text, line, begidx, endidx):
739+
"""autocomplete to match help_submenu()'s behavior"""
740+
tokens = line.split(None, 1)
741+
if len(tokens) == 2 and (
742+
not (not tokens[1].startswith(self.command) and not any(
743+
tokens[1].startswith(alias) for alias in self.aliases))
744+
):
745+
return self.submenu.complete_help(
746+
text,
747+
tokens[1],
748+
begidx - line.index(tokens[1]),
749+
endidx - line.index(tokens[1]),
750+
)
751+
else:
752+
return original_complete_help(_self, text, line, begidx, endidx)
753+
754+
if self.create_subclass:
755+
class _Cmd(cmd_obj):
756+
do_help = help_submenu
757+
complete_help = _complete_submenu_help
758+
else:
759+
_Cmd = cmd_obj
760+
_Cmd.do_help = help_submenu
761+
_Cmd.complete_help = _complete_submenu_help
762+
763+
# Create bindings in the parent command to the submenus commands.
764+
setattr(_Cmd, 'do_' + self.command, enter_submenu)
765+
setattr(_Cmd, 'complete_' + self.command, complete_submenu)
766+
767+
# Create additional bindings for aliases
768+
for _alias in self.aliases:
769+
setattr(_Cmd, 'do_' + _alias, enter_submenu)
770+
setattr(_Cmd, 'complete_' + _alias, complete_submenu)
771+
return _Cmd
772+
773+
550774
class Cmd(cmd.Cmd):
551775
"""An easy but powerful framework for writing line-oriented command interpreters.
552776
@@ -1386,6 +1610,7 @@ def do_shortcuts(self, _):
13861610
def do_eof(self, _):
13871611
"""Called when <Ctrl>-D is pressed."""
13881612
# End of script should not exit app, but <Ctrl>-D should.
1613+
print('') # Required for clearing line when exiting submenu
13891614
return self._STOP_AND_EXIT
13901615

13911616
def do_quit(self, _):

examples/submenus.py

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
#!/usr/bin/env python
2+
"""
3+
Create a CLI with a nested command structure as follows. The commands 'second' and 'third' navigate the CLI to the scope
4+
of the submenu. Nesting of the submenus is done with the cmd2.AddSubmenu() decorator.
5+
6+
(Top Level)----second----->(2nd Level)----third----->(3rd Level)
7+
| | |
8+
---> say ---> say ---> say
9+
10+
11+
12+
"""
13+
from __future__ import print_function
14+
import sys
15+
16+
import cmd2
17+
from IPython import embed
18+
19+
20+
class ThirdLevel(cmd2.Cmd):
21+
"""To be used as a third level command class. """
22+
23+
def __init__(self, *args, **kwargs):
24+
cmd2.Cmd.__init__(self, *args, **kwargs)
25+
self.prompt = '3rdLevel '
26+
self.top_level_attr = None
27+
self.second_level_attr = None
28+
29+
def do_say(self, line):
30+
print("You called a command in ThirdLevel with '%s'. "
31+
"It has access to top_level_attr: %s "
32+
"and second_level_attr: %s" % (line, self.top_level_attr, self.second_level_attr))
33+
34+
def help_say(self):
35+
print("This is a third level submenu (submenu_ab). Options are qwe, asd, zxc")
36+
37+
def complete_say(self, text, line, begidx, endidx):
38+
return [s for s in ['qwe', 'asd', 'zxc'] if s.startswith(text)]
39+
40+
41+
@cmd2.AddSubmenu(ThirdLevel(),
42+
command='third',
43+
aliases=('third_alias',),
44+
shared_attributes=dict(second_level_attr='second_level_attr', top_level_attr='top_level_attr'))
45+
class SecondLevel(cmd2.Cmd):
46+
"""To be used as a second level command class. """
47+
def __init__(self, *args, **kwargs):
48+
cmd2.Cmd.__init__(self, *args, **kwargs)
49+
self.prompt = '2ndLevel '
50+
self.top_level_attr = None
51+
self.second_level_attr = 987654321
52+
53+
def do_ipy(self, arg):
54+
"""Enters an interactive IPython shell.
55+
56+
Run python code from external files with ``run filename.py``
57+
End with ``Ctrl-D`` (Unix) / ``Ctrl-Z`` (Windows), ``quit()``, '`exit()``.
58+
"""
59+
banner = 'Entering an embedded IPython shell type quit() or <Ctrl>-d to exit ...'
60+
exit_msg = 'Leaving IPython, back to {}'.format(sys.argv[0])
61+
embed(banner1=banner, exit_msg=exit_msg)
62+
63+
def do_say(self, line):
64+
print("You called a command in SecondLevel with '%s'. "
65+
"It has access to top_level_attr: %s" % (line, self.top_level_attr))
66+
67+
def help_say(self):
68+
print("This is a SecondLevel menu. Options are qwe, asd, zxc")
69+
70+
def complete_say(self, text, line, begidx, endidx):
71+
return [s for s in ['qwe', 'asd', 'zxc'] if s.startswith(text)]
72+
73+
74+
75+
@cmd2.AddSubmenu(SecondLevel(),
76+
command='second',
77+
aliases=('second_alias',),
78+
shared_attributes=dict(top_level_attr='top_level_attr'))
79+
class TopLevel(cmd2.Cmd):
80+
"""To be used as the main / top level command class that will contain other submenus."""
81+
82+
def __init__(self, *args, **kwargs):
83+
cmd2.Cmd.__init__(self, *args, **kwargs)
84+
self.prompt = 'TopLevel '
85+
self.top_level_attr = 123456789
86+
87+
def do_ipy(self, arg):
88+
"""Enters an interactive IPython shell.
89+
90+
Run python code from external files with ``run filename.py``
91+
End with ``Ctrl-D`` (Unix) / ``Ctrl-Z`` (Windows), ``quit()``, '`exit()``.
92+
"""
93+
banner = 'Entering an embedded IPython shell type quit() or <Ctrl>-d to exit ...'
94+
exit_msg = 'Leaving IPython, back to {}'.format(sys.argv[0])
95+
embed(banner1=banner, exit_msg=exit_msg)
96+
97+
def do_say(self, line):
98+
print("You called a command in TopLevel with '%s'. "
99+
"TopLevel has attribute top_level_attr=%s" % (line, self.top_level_attr))
100+
101+
def help_say(self):
102+
print("This is a top level submenu. Options are qwe, asd, zxc")
103+
104+
def complete_say(self, text, line, begidx, endidx):
105+
return [s for s in ['qwe', 'asd', 'zxc'] if s.startswith(text)]
106+
107+
108+
109+
if __name__ == '__main__':
110+
111+
root = TopLevel()
112+
root.cmdloop()
113+

0 commit comments

Comments
 (0)