diff options
author | Todd Leonhardt <todd.leonhardt@gmail.com> | 2018-05-09 23:29:33 -0400 |
---|---|---|
committer | GitHub <noreply@github.com> | 2018-05-09 23:29:33 -0400 |
commit | 9d4d929709ffbcfcbd0974d8193c44d514f5a9b4 (patch) | |
tree | d8043347df39aa5511543fa09d030b6d67116880 | |
parent | 6a3dbec1111ef1131781a6af441b89f68801c82a (diff) | |
parent | b88b13ea9a157196bef4269564b0583adc531053 (diff) | |
download | cmd2-git-9d4d929709ffbcfcbd0974d8193c44d514f5a9b4.tar.gz |
Merge pull request #397 from python-cmd2/extract_submenu
Extract submenu code to a new project
-rw-r--r-- | CHANGELOG.md | 1 | ||||
-rwxr-xr-x | cmd2/cmd2.py | 264 | ||||
-rwxr-xr-x | examples/submenus.py | 109 | ||||
-rw-r--r-- | tests/test_completion.py | 121 | ||||
-rw-r--r-- | tests/test_submenu.py | 181 |
5 files changed, 1 insertions, 675 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md index dac0756f..503f15e0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ * Changes * ``strip_ansi()`` and ``strip_quotes()`` functions have moved to new utils module * Several constants moved to new constants module + * Submenu support has been moved to a new [cmd2-submenu](https://github.com/python-cmd2/cmd2-submenu) plugin. If you use submenus, you will need to update your dependencies and modify your imports. * Deletions (potentially breaking changes) * Deleted all ``optparse`` code which had previously been deprecated in release 0.8.0 * The ``options`` decorator no longer exists diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index 725b6497..02ae96fe 100755 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -327,270 +327,6 @@ class EmptyStatement(Exception): pass -def _pop_readline_history(clear_history: bool=True) -> List[str]: - """Returns a copy of readline's history and optionally clears it (default)""" - # noinspection PyArgumentList - if rl_type == RlType.NONE: - return [] - - history = [ - readline.get_history_item(i) - for i in range(1, 1 + readline.get_current_history_length()) - ] - if clear_history: - readline.clear_history() - return history - - -def _push_readline_history(history, clear_history=True): - """Restores readline's history and optionally clears it first (default)""" - if rl_type != RlType.NONE: - if clear_history: - readline.clear_history() - for line in history: - readline.add_history(line) - - -def _complete_from_cmd(cmd_obj, text, line, begidx, endidx): - """Complete as though the user was typing inside cmd's cmdloop()""" - from itertools import takewhile - command_subcommand_params = line.split(None, 3) - - if len(command_subcommand_params) < (3 if text else 2): - n = len(command_subcommand_params[0]) - n += sum(1 for _ in takewhile(str.isspace, line[n:])) - return cmd_obj.completenames(text, line[n:], begidx - n, endidx - n) - - command, subcommand = command_subcommand_params[:2] - n = len(command) + sum(1 for _ in takewhile(str.isspace, line)) - cfun = getattr(cmd_obj, 'complete_' + subcommand, cmd_obj.complete) - return cfun(text, line[n:], begidx - n, endidx - n) - - -class AddSubmenu(object): - """Conveniently add a submenu (Cmd-like class) to a Cmd - - e.g. given "class SubMenu(Cmd): ..." then - - @AddSubmenu(SubMenu(), 'sub') - class MyCmd(cmd.Cmd): - .... - - will have the following effects: - 1. 'sub' will interactively enter the cmdloop of a SubMenu instance - 2. 'sub cmd args' will call do_cmd(args) in a SubMenu instance - 3. 'sub ... [TAB]' will have the same behavior as [TAB] in a SubMenu cmdloop - i.e., autocompletion works the way you think it should - 4. 'help sub [cmd]' will print SubMenu's help (calls its do_help()) - """ - - class _Nonexistent(object): - """ - Used to mark missing attributes. - Disable __dict__ creation since this class does nothing - """ - __slots__ = () # - - def __init__(self, - submenu, - command, - aliases=(), - reformat_prompt="{super_prompt}>> {sub_prompt}", - shared_attributes=None, - require_predefined_shares=True, - create_subclass=False, - preserve_shares=False, - persistent_history_file=None - ): - """Set up the class decorator - - submenu (Cmd): Instance of something cmd.Cmd-like - - command (str): The command the user types to access the SubMenu instance - - aliases (iterable): More commands that will behave like "command" - - reformat_prompt (str): Format str or None to disable - if it's a string, it should contain one or more of: - {super_prompt}: The current cmd's prompt - {command}: The command in the current cmd with which it was called - {sub_prompt}: The subordinate cmd's original prompt - the default is "{super_prompt}{command} {sub_prompt}" - - shared_attributes (dict): dict of the form {'subordinate_attr': 'parent_attr'} - the attributes are copied to the submenu at the last moment; the submenu's - attributes are backed up before this and restored afterward - - require_predefined_shares: The shared attributes above must be independently - defined in the subordinate Cmd (default: True) - - create_subclass: put the modifications in a subclass rather than modifying - the existing class (default: False) - """ - self.submenu = submenu - self.command = command - self.aliases = aliases - if persistent_history_file: - self.persistent_history_file = os.path.expanduser(persistent_history_file) - else: - self.persistent_history_file = None - - if reformat_prompt is not None and not isinstance(reformat_prompt, str): - raise ValueError("reformat_prompt should be either a format string or None") - self.reformat_prompt = reformat_prompt - - self.shared_attributes = {} if shared_attributes is None else shared_attributes - if require_predefined_shares: - for attr in self.shared_attributes.keys(): - if not hasattr(submenu, attr): - raise AttributeError("The shared attribute '{attr}' is not defined in {cmd}. Either define {attr} " - "in {cmd} or set require_predefined_shares=False." - .format(cmd=submenu.__class__.__name__, attr=attr)) - - self.create_subclass = create_subclass - self.preserve_shares = preserve_shares - - def _get_original_attributes(self): - return { - attr: getattr(self.submenu, attr, AddSubmenu._Nonexistent) - for attr in self.shared_attributes.keys() - } - - def _copy_in_shared_attrs(self, parent_cmd): - for sub_attr, par_attr in self.shared_attributes.items(): - setattr(self.submenu, sub_attr, getattr(parent_cmd, par_attr)) - - def _copy_out_shared_attrs(self, parent_cmd, original_attributes): - if self.preserve_shares: - for sub_attr, par_attr in self.shared_attributes.items(): - setattr(parent_cmd, par_attr, getattr(self.submenu, sub_attr)) - else: - for attr, value in original_attributes.items(): - if attr is not AddSubmenu._Nonexistent: - setattr(self.submenu, attr, value) - else: - delattr(self.submenu, attr) - - def __call__(self, cmd_obj): - """Creates a subclass of Cmd wherein the given submenu can be accessed via the given command""" - def enter_submenu(parent_cmd, statement): - """ - This function will be bound to do_<submenu> and will change the scope of the CLI to that of the - submenu. - """ - submenu = self.submenu - original_attributes = self._get_original_attributes() - history = _pop_readline_history() - - if self.persistent_history_file and rl_type != RlType.NONE: - try: - readline.read_history_file(self.persistent_history_file) - except FileNotFoundError: - pass - - try: - # copy over any shared attributes - self._copy_in_shared_attrs(parent_cmd) - - if statement.args: - # Remove the menu argument and execute the command in the submenu - submenu.onecmd_plus_hooks(statement.args) - else: - if self.reformat_prompt is not None: - prompt = submenu.prompt - submenu.prompt = self.reformat_prompt.format( - super_prompt=parent_cmd.prompt, - command=self.command, - sub_prompt=prompt, - ) - submenu.cmdloop() - if self.reformat_prompt is not None: - # noinspection PyUnboundLocalVariable - self.submenu.prompt = prompt - finally: - # copy back original attributes - self._copy_out_shared_attrs(parent_cmd, original_attributes) - - # write submenu history - if self.persistent_history_file and rl_type != RlType.NONE: - readline.write_history_file(self.persistent_history_file) - # reset main app history before exit - _push_readline_history(history) - - def complete_submenu(_self, text, line, begidx, endidx): - """ - This function will be bound to complete_<submenu> and will perform the complete commands of the submenu. - """ - submenu = self.submenu - original_attributes = self._get_original_attributes() - try: - # copy over any shared attributes - self._copy_in_shared_attrs(_self) - - # Reset the submenu's tab completion parameters - submenu.allow_appended_space = True - submenu.allow_closing_quote = True - submenu.display_matches = [] - - return _complete_from_cmd(submenu, text, line, begidx, endidx) - finally: - # copy back original attributes - self._copy_out_shared_attrs(_self, original_attributes) - - # Pass the submenu's tab completion parameters back up to the menu that called complete() - _self.allow_appended_space = submenu.allow_appended_space - _self.allow_closing_quote = submenu.allow_closing_quote - _self.display_matches = copy.copy(submenu.display_matches) - - original_do_help = cmd_obj.do_help - original_complete_help = cmd_obj.complete_help - - def help_submenu(_self, line): - """ - This function will be bound to help_<submenu> and will call the help commands of the submenu. - """ - tokens = line.split(None, 1) - if tokens and (tokens[0] == self.command or tokens[0] in self.aliases): - self.submenu.do_help(tokens[1] if len(tokens) == 2 else '') - else: - original_do_help(_self, line) - - def _complete_submenu_help(_self, text, line, begidx, endidx): - """autocomplete to match help_submenu()'s behavior""" - tokens = line.split(None, 1) - if len(tokens) == 2 and ( - not (not tokens[1].startswith(self.command) and not any( - tokens[1].startswith(alias) for alias in self.aliases)) - ): - return self.submenu.complete_help( - text, - tokens[1], - begidx - line.index(tokens[1]), - endidx - line.index(tokens[1]), - ) - else: - return original_complete_help(_self, text, line, begidx, endidx) - - if self.create_subclass: - class _Cmd(cmd_obj): - do_help = help_submenu - complete_help = _complete_submenu_help - else: - _Cmd = cmd_obj - _Cmd.do_help = help_submenu - _Cmd.complete_help = _complete_submenu_help - - # Create bindings in the parent command to the submenus commands. - setattr(_Cmd, 'do_' + self.command, enter_submenu) - setattr(_Cmd, 'complete_' + self.command, complete_submenu) - - # Create additional bindings for aliases - for _alias in self.aliases: - setattr(_Cmd, 'do_' + _alias, enter_submenu) - setattr(_Cmd, 'complete_' + _alias, complete_submenu) - return _Cmd - - class Cmd(cmd.Cmd): """An easy but powerful framework for writing line-oriented command interpreters. diff --git a/examples/submenus.py b/examples/submenus.py deleted file mode 100755 index 27c8cb10..00000000 --- a/examples/submenus.py +++ /dev/null @@ -1,109 +0,0 @@ -#!/usr/bin/env python -# coding=utf-8 -""" -Create a CLI with a nested command structure as follows. The commands 'second' and 'third' navigate the CLI to the scope -of the submenu. Nesting of the submenus is done with the cmd2.AddSubmenu() decorator. - - (Top Level)----second----->(2nd Level)----third----->(3rd Level) - | | | - ---> say ---> say ---> say -""" -from __future__ import print_function -import sys - -from cmd2 import cmd2 -from IPython import embed - - -class ThirdLevel(cmd2.Cmd): - """To be used as a third level command class. """ - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.prompt = '3rdLevel ' - self.top_level_attr = None - self.second_level_attr = None - - def do_say(self, line): - print("You called a command in ThirdLevel with '%s'. " - "It has access to top_level_attr: %s " - "and second_level_attr: %s" % (line, self.top_level_attr, self.second_level_attr)) - - def help_say(self): - print("This is a third level submenu (submenu_ab). Options are qwe, asd, zxc") - - def complete_say(self, text, line, begidx, endidx): - return [s for s in ['qwe', 'asd', 'zxc'] if s.startswith(text)] - - -@cmd2.AddSubmenu(ThirdLevel(), - command='third', - aliases=('third_alias',), - shared_attributes=dict(second_level_attr='second_level_attr', top_level_attr='top_level_attr')) -class SecondLevel(cmd2.Cmd): - """To be used as a second level command class. """ - def __init__(self, *args, **kwargs): - cmd2.Cmd.__init__(self, *args, **kwargs) - self.prompt = '2ndLevel ' - self.top_level_attr = None - self.second_level_attr = 987654321 - - def do_ipy(self, arg): - """Enters an interactive IPython shell. - - Run python code from external files with ``run filename.py`` - End with ``Ctrl-D`` (Unix) / ``Ctrl-Z`` (Windows), ``quit()``, '`exit()``. - """ - banner = 'Entering an embedded IPython shell type quit() or <Ctrl>-d to exit ...' - exit_msg = 'Leaving IPython, back to {}'.format(sys.argv[0]) - embed(banner1=banner, exit_msg=exit_msg) - - def do_say(self, line): - print("You called a command in SecondLevel with '%s'. " - "It has access to top_level_attr: %s" % (line, self.top_level_attr)) - - def help_say(self): - print("This is a SecondLevel menu. Options are qwe, asd, zxc") - - def complete_say(self, text, line, begidx, endidx): - return [s for s in ['qwe', 'asd', 'zxc'] if s.startswith(text)] - - -@cmd2.AddSubmenu(SecondLevel(), - command='second', - aliases=('second_alias',), - shared_attributes=dict(top_level_attr='top_level_attr')) -class TopLevel(cmd2.Cmd): - """To be used as the main / top level command class that will contain other submenus.""" - - def __init__(self, *args, **kwargs): - cmd2.Cmd.__init__(self, *args, **kwargs) - self.prompt = 'TopLevel ' - self.top_level_attr = 123456789 - - def do_ipy(self, arg): - """Enters an interactive IPython shell. - - Run python code from external files with ``run filename.py`` - End with ``Ctrl-D`` (Unix) / ``Ctrl-Z`` (Windows), ``quit()``, '`exit()``. - """ - banner = 'Entering an embedded IPython shell type quit() or <Ctrl>-d to exit ...' - exit_msg = 'Leaving IPython, back to {}'.format(sys.argv[0]) - embed(banner1=banner, exit_msg=exit_msg) - - def do_say(self, line): - print("You called a command in TopLevel with '%s'. " - "TopLevel has attribute top_level_attr=%s" % (line, self.top_level_attr)) - - def help_say(self): - print("This is a top level submenu. Options are qwe, asd, zxc") - - def complete_say(self, text, line, begidx, endidx): - return [s for s in ['qwe', 'asd', 'zxc'] if s.startswith(text)] - - -if __name__ == '__main__': - - root = TopLevel() - root.cmdloop() - diff --git a/tests/test_completion.py b/tests/test_completion.py index bda5bb8a..c7650dbb 100644 --- a/tests/test_completion.py +++ b/tests/test_completion.py @@ -949,124 +949,3 @@ def test_subcommand_tab_completion_space_in_text_scu(scu_app): assert first_match is not None and \ scu_app.completion_matches == ['Ball" '] and \ scu_app.display_matches == ['Space Ball'] - -#################################################### - - -class SecondLevel(cmd2.Cmd): - """To be used as a second level command class. """ - - def __init__(self, *args, **kwargs): - cmd2.Cmd.__init__(self, *args, **kwargs) - self.prompt = '2ndLevel ' - - def do_foo(self, line): - self.poutput("You called a command in SecondLevel with '%s'. " % line) - - def help_foo(self): - self.poutput("This is a second level menu. Options are qwe, asd, zxc") - - def complete_foo(self, text, line, begidx, endidx): - return [s for s in ['qwe', 'asd', 'zxc'] if s.startswith(text)] - - -second_level_cmd = SecondLevel() - - -@cmd2.AddSubmenu(second_level_cmd, - command='second', - require_predefined_shares=False) -class SubmenuApp(cmd2.Cmd): - """To be used as the main / top level command class that will contain other submenus.""" - - def __init__(self, *args, **kwargs): - cmd2.Cmd.__init__(self, *args, **kwargs) - self.prompt = 'TopLevel ' - - -@pytest.fixture -def sb_app(): - app = SubmenuApp() - return app - - -def test_cmd2_submenu_completion_single_end(sb_app): - text = 'f' - line = 'second {}'.format(text) - endidx = len(line) - begidx = endidx - len(text) - - first_match = complete_tester(text, line, begidx, endidx, sb_app) - - # It is at end of line, so extra space is present - assert first_match is not None and sb_app.completion_matches == ['foo '] - - -def test_cmd2_submenu_completion_multiple(sb_app): - text = 'e' - line = 'second {}'.format(text) - endidx = len(line) - begidx = endidx - len(text) - - expected = ['edit', 'eof', 'eos'] - first_match = complete_tester(text, line, begidx, endidx, sb_app) - - assert first_match is not None and sb_app.completion_matches == expected - - -def test_cmd2_submenu_completion_nomatch(sb_app): - text = 'z' - line = 'second {}'.format(text) - endidx = len(line) - begidx = endidx - len(text) - - first_match = complete_tester(text, line, begidx, endidx, sb_app) - assert first_match is None - - -def test_cmd2_submenu_completion_after_submenu_match(sb_app): - text = 'a' - line = 'second foo {}'.format(text) - endidx = len(line) - begidx = endidx - len(text) - - first_match = complete_tester(text, line, begidx, endidx, sb_app) - assert first_match is not None and sb_app.completion_matches == ['asd '] - - -def test_cmd2_submenu_completion_after_submenu_nomatch(sb_app): - text = 'b' - line = 'second foo {}'.format(text) - endidx = len(line) - begidx = endidx - len(text) - - first_match = complete_tester(text, line, begidx, endidx, sb_app) - assert first_match is None - - -def test_cmd2_help_submenu_completion_multiple(sb_app): - text = 'p' - line = 'help second {}'.format(text) - endidx = len(line) - begidx = endidx - len(text) - - matches = sorted(sb_app.complete_help(text, line, begidx, endidx)) - assert matches == ['py', 'pyscript'] - - -def test_cmd2_help_submenu_completion_nomatch(sb_app): - text = 'fake' - line = 'help second {}'.format(text) - endidx = len(line) - begidx = endidx - len(text) - assert sb_app.complete_help(text, line, begidx, endidx) == [] - - -def test_cmd2_help_submenu_completion_subcommands(sb_app): - text = 'p' - line = 'help second {}'.format(text) - endidx = len(line) - begidx = endidx - len(text) - - matches = sorted(sb_app.complete_help(text, line, begidx, endidx)) - assert matches == ['py', 'pyscript'] diff --git a/tests/test_submenu.py b/tests/test_submenu.py deleted file mode 100644 index db334daa..00000000 --- a/tests/test_submenu.py +++ /dev/null @@ -1,181 +0,0 @@ -# coding=utf-8 -""" -Cmd2 testing for argument parsing -""" -import pytest - -from cmd2 import cmd2 -from .conftest import run_cmd, StdOut, normalize - - -class SecondLevelB(cmd2.Cmd): - """To be used as a second level command class. """ - - def __init__(self, *args, **kwargs): - cmd2.Cmd.__init__(self, *args, **kwargs) - self.prompt = '2ndLevel B ' - - def do_get_top_level_attr(self, line): - self.poutput(str(self.top_level_attr)) - - def do_set_top_level_attr(self, line): - self.top_level_attr = 987654321 - - -class SecondLevel(cmd2.Cmd): - """To be used as a second level command class. """ - - def __init__(self, *args, **kwargs): - cmd2.Cmd.__init__(self, *args, **kwargs) - self.prompt = '2ndLevel ' - self.top_level_attr = None - - def do_say(self, line): - self.poutput("You called a command in SecondLevel with '%s'. " % line) - - def help_say(self): - self.poutput("This is a second level menu. Options are qwe, asd, zxc") - - def complete_say(self, text, line, begidx, endidx): - return [s for s in ['qwe', 'asd', 'zxc'] if s.startswith(text)] - - def do_get_top_level_attr(self, line): - self.poutput(str(self.top_level_attr)) - - def do_get_prompt(self, line): - self.poutput(self.prompt) - - -second_level_cmd = SecondLevel() -second_level_b_cmd = SecondLevelB() - - -@cmd2.AddSubmenu(SecondLevelB(), - command='should_work_with_default_kwargs') -@cmd2.AddSubmenu(second_level_b_cmd, - command='secondb', - shared_attributes=dict(top_level_attr='top_level_attr'), - require_predefined_shares=False, - preserve_shares=True - ) -@cmd2.AddSubmenu(second_level_cmd, - command='second', - aliases=('second_alias',), - shared_attributes=dict(top_level_attr='top_level_attr')) -class SubmenuApp(cmd2.Cmd): - """To be used as the main / top level command class that will contain other submenus.""" - - def __init__(self, *args, **kwargs): - cmd2.Cmd.__init__(self, *args, **kwargs) - self.prompt = 'TopLevel ' - self.top_level_attr = 123456789 - - def do_say(self, line): - self.poutput("You called a command in TopLevel with '%s'. " % line) - - def help_say(self): - self.poutput("This is a top level submenu. Options are qwe, asd, zxc") - - def complete_say(self, text, line, begidx, endidx): - return [s for s in ['qwe', 'asd', 'zxc'] if s.startswith(text)] - - -@pytest.fixture -def submenu_app(): - app = SubmenuApp() - app.stdout = StdOut() - second_level_cmd.stdout = StdOut() - second_level_b_cmd.stdout = StdOut() - return app - - -@pytest.fixture -def secondlevel_app(): - app = SecondLevel() - app.stdout = StdOut() - return app - - -@pytest.fixture -def secondlevel_app_b(): - app = SecondLevelB() - app.stdout = StdOut() - return app - - -def run_submenu_cmd(app, second_level_app, cmd): - """ Clear StdOut buffers, run the command, extract the buffer contents.""" - app.stdout.clear() - second_level_app.stdout.clear() - app.onecmd_plus_hooks(cmd) - out1 = app.stdout.buffer - out2 = second_level_app.stdout.buffer - app.stdout.clear() - second_level_app.stdout.clear() - return normalize(out1), normalize(out2) - - -def test_submenu_say_from_top_level(submenu_app): - line = 'testing' - out1, out2 = run_submenu_cmd(submenu_app, second_level_cmd, 'say ' + line) - assert len(out1) == 1 - assert len(out2) == 0 - assert out1[0] == "You called a command in TopLevel with {!r}.".format(line) - - -def test_submenu_second_say_from_top_level(submenu_app): - line = 'testing' - out1, out2 = run_submenu_cmd(submenu_app, second_level_cmd, 'second say ' + line) - - # No output expected from the top level - assert out1 == [] - - # Output expected from the second level - assert len(out2) == 1 - assert out2[0] == "You called a command in SecondLevel with {!r}.".format(line) - - -def test_submenu_say_from_second_level(secondlevel_app): - line = 'testing' - out = run_cmd(secondlevel_app, 'say ' + line) - assert out == ["You called a command in SecondLevel with '%s'." % line] - - -def test_submenu_help_second_say_from_top_level(submenu_app): - out1, out2 = run_submenu_cmd(submenu_app, second_level_cmd, 'help second say') - # No output expected from the top level - assert out1 == [] - - # Output expected from the second level - assert out2 == ["This is a second level menu. Options are qwe, asd, zxc"] - - -def test_submenu_help_say_from_second_level(secondlevel_app): - out = run_cmd(secondlevel_app, 'help say') - assert out == ["This is a second level menu. Options are qwe, asd, zxc"] - - -def test_submenu_help_second(submenu_app): - out1, out2 = run_submenu_cmd(submenu_app, second_level_cmd, 'help second') - out3 = run_cmd(second_level_cmd, 'help') - assert out2 == out3 - - -def test_submenu_from_top_help_second_say(submenu_app): - out1, out2 = run_submenu_cmd(submenu_app, second_level_cmd, 'help second say') - out3 = run_cmd(second_level_cmd, 'help say') - assert out2 == out3 - - -def test_submenu_shared_attribute(submenu_app): - out1, out2 = run_submenu_cmd(submenu_app, second_level_cmd, 'second get_top_level_attr') - assert out2 == [str(submenu_app.top_level_attr)] - - -def test_submenu_shared_attribute_preserve(submenu_app): - out1, out2 = run_submenu_cmd(submenu_app, second_level_b_cmd, 'secondb get_top_level_attr') - assert out2 == [str(submenu_app.top_level_attr)] - out1, out2 = run_submenu_cmd(submenu_app, second_level_b_cmd, 'secondb set_top_level_attr') - assert submenu_app.top_level_attr == 987654321 - out1, out2 = run_submenu_cmd(submenu_app, second_level_b_cmd, 'secondb get_top_level_attr') - assert out2 == [str(987654321)] |