Skip to content

Commit ab7ac49

Browse files
committed
Added support for translating function positional and keyword arguments into argparse command positional and flag arguments.
Added initial set of tests
1 parent 54f9a7a commit ab7ac49

File tree

9 files changed

+177
-10
lines changed

9 files changed

+177
-10
lines changed

cmd2/pyscript_bridge.py

Lines changed: 55 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,29 @@
1-
"""Bridges calls made inside of pyscript with the Cmd2 host app while maintaining a reasonable
2-
degree of isolation between the two"""
1+
"""
2+
Bridges calls made inside of pyscript with the Cmd2 host app while maintaining a reasonable
3+
degree of isolation between the two
4+
5+
Copyright 2018 Eric Lin <[email protected]>
6+
Released under MIT license, see LICENSE file
7+
"""
38

49
import argparse
10+
from typing import List, Tuple
11+
512

613
class ArgparseFunctor:
714
def __init__(self, cmd2_app, item, parser):
815
self._cmd2_app = cmd2_app
916
self._item = item
1017
self._parser = parser
1118

19+
# Dictionary mapping command argument name to value
1220
self._args = {}
21+
# argparse object for the current command layer
1322
self.__current_subcommand_parser = parser
1423

1524
def __getattr__(self, item):
16-
# look for sub-command
25+
"""Search for a subcommand matching this item and update internal state to track the traversal"""
26+
# look for sub-command under the current command/sub-command layer
1727
for action in self.__current_subcommand_parser._actions:
1828
if not action.option_strings and isinstance(action, argparse._SubParsersAction):
1929
if item in action.choices:
@@ -22,27 +32,38 @@ def __getattr__(self, item):
2232
self.__current_subcommand_parser = action.choices[item]
2333
self._args[action.dest] = item
2434
return self
35+
2536
return super().__getatttr__(item)
2637

2738
def __call__(self, *args, **kwargs):
39+
"""
40+
Process the arguments at this layer of the argparse command tree. If there are more sub-commands,
41+
return self to accept the next sub-command name. If there are no more sub-commands, execute the
42+
sub-command with the given parameters.
43+
"""
2844
next_pos_index = 0
2945

3046
has_subcommand = False
3147
consumed_kw = []
48+
49+
# Iterate through the current sub-command's arguments in order
3250
for action in self.__current_subcommand_parser._actions:
3351
# is this a flag option?
3452
if action.option_strings:
53+
# this is a flag argument, search for the argument by name in the parameters
3554
if action.dest in kwargs:
3655
self._args[action.dest] = kwargs[action.dest]
3756
consumed_kw.append(action.dest)
3857
else:
58+
# This is a positional argument, search the positional arguments passed in.
3959
if not isinstance(action, argparse._SubParsersAction):
4060
if next_pos_index < len(args):
4161
self._args[action.dest] = args[next_pos_index]
4262
next_pos_index += 1
4363
else:
4464
has_subcommand = True
4565

66+
# Check if there are any extra arguments we don't know how to handle
4667
for kw in kwargs:
4768
if kw not in consumed_kw:
4869
raise TypeError('{}() got an unexpected keyword argument \'{}\''.format(
@@ -60,19 +81,38 @@ def _run(self):
6081
# reconstruct the cmd2 command from the python call
6182
cmd_str = ['']
6283

84+
def process_flag(action, value):
85+
# was the argument a flag?
86+
if action.option_strings:
87+
cmd_str[0] += '{} '.format(action.option_strings[0])
88+
89+
if isinstance(value, List) or isinstance(value, Tuple):
90+
for item in value:
91+
item = str(item).strip()
92+
if ' ' in item:
93+
item = '"{}"'.format(item)
94+
cmd_str[0] += '{} '.format(item)
95+
else:
96+
value = str(value).strip()
97+
if ' ' in value:
98+
value = '"{}"'.format(value)
99+
cmd_str[0] += '{} '.format(value)
100+
63101
def traverse_parser(parser):
64102
for action in parser._actions:
65103
# was something provided for the argument
66104
if action.dest in self._args:
67105
if isinstance(action, argparse._SubParsersAction):
106+
cmd_str[0] += '{} '.format(self._args[action.dest])
68107
traverse_parser(action.choices[self._args[action.dest]])
108+
elif isinstance(action, argparse._AppendAction):
109+
if isinstance(self._args[action.dest], List) or isinstance(self._args[action.dest], Tuple):
110+
for values in self._args[action.dest]:
111+
process_flag(action, values)
112+
else:
113+
process_flag(action, self._args[action.dest])
69114
else:
70-
# was the argument a flag?
71-
if action.option_strings:
72-
cmd_str[0] += '{} '.format(action.option_strings[0])
73-
74-
# TODO: Handle 'narg' and 'append' options
75-
cmd_str[0] += '"{}" '.format(self._args[action.dest])
115+
process_flag(action, self._args[action.dest])
76116

77117
traverse_parser(self._parser)
78118

@@ -81,23 +121,29 @@ def traverse_parser(parser):
81121

82122

83123
class PyscriptBridge(object):
124+
"""Preserves the legacy 'cmd' interface for pyscript while also providing a new python API wrapper for
125+
application commands."""
84126
def __init__(self, cmd2_app):
85127
self._cmd2_app = cmd2_app
86128
self._last_result = None
87129

88130
def __getattr__(self, item: str):
131+
"""Check if the attribute is a command. If so, return a callable."""
89132
commands = self._cmd2_app.get_all_commands()
90133
if item in commands:
91134
func = getattr(self._cmd2_app, 'do_' + item)
92135

93136
try:
137+
# See if the command uses argparse
94138
parser = getattr(func, 'argparser')
95139
except AttributeError:
140+
# Command doesn't, we will accept parameters in the form of a command string
96141
def wrap_func(args=''):
97142
func(args)
98143
return self._cmd2_app._last_result
99144
return wrap_func
100145
else:
146+
# Command does use argparse, return an object that can traverse the argparse subcommands and arguments
101147
return ArgparseFunctor(self._cmd2_app, item, parser)
102148

103149
return super().__getattr__(item)

examples/tab_autocompletion.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
#!/usr/bin/env python
1+
#!/usr/bin/env python3
22
# coding=utf-8
33
"""
44
A example usage of the AutoCompleter

tests/pyscript/help.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
app.help()

tests/pyscript/help_media.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
app.help('media')
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
app.media.movies.list()
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
app.media().movies().list()
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
app('media movies list')
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
app.media.movies.list()

tests/test_pyscript.py

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
"""
2+
Unit/functional testing for argparse completer in cmd2
3+
4+
Copyright 2018 Eric Lin <[email protected]>
5+
Released under MIT license, see LICENSE file
6+
"""
7+
import os
8+
import pytest
9+
from cmd2.cmd2 import Cmd, with_argparser
10+
from cmd2 import argparse_completer
11+
from .conftest import run_cmd, normalize, StdOut, complete_tester
12+
13+
class PyscriptExample(Cmd):
14+
ratings_types = ['G', 'PG', 'PG-13', 'R', 'NC-17']
15+
16+
def _do_media_movies(self, args) -> None:
17+
if not args.command:
18+
self.do_help('media movies')
19+
else:
20+
print('media movies ' + str(args.__dict__))
21+
22+
def _do_media_shows(self, args) -> None:
23+
if not args.command:
24+
self.do_help('media shows')
25+
26+
if not args.command:
27+
self.do_help('media shows')
28+
else:
29+
print('media shows ' + str(args.__dict__))
30+
31+
media_parser = argparse_completer.ACArgumentParser(prog='media')
32+
33+
media_types_subparsers = media_parser.add_subparsers(title='Media Types', dest='type')
34+
35+
movies_parser = media_types_subparsers.add_parser('movies')
36+
movies_parser.set_defaults(func=_do_media_movies)
37+
38+
movies_commands_subparsers = movies_parser.add_subparsers(title='Commands', dest='command')
39+
40+
movies_list_parser = movies_commands_subparsers.add_parser('list')
41+
42+
movies_list_parser.add_argument('-t', '--title', help='Title Filter')
43+
movies_list_parser.add_argument('-r', '--rating', help='Rating Filter', nargs='+',
44+
choices=ratings_types)
45+
movies_list_parser.add_argument('-d', '--director', help='Director Filter')
46+
movies_list_parser.add_argument('-a', '--actor', help='Actor Filter', action='append')
47+
48+
movies_add_parser = movies_commands_subparsers.add_parser('add')
49+
movies_add_parser.add_argument('title', help='Movie Title')
50+
movies_add_parser.add_argument('rating', help='Movie Rating', choices=ratings_types)
51+
movies_add_parser.add_argument('-d', '--director', help='Director', nargs=(1, 2), required=True)
52+
movies_add_parser.add_argument('actor', help='Actors', nargs='*')
53+
54+
movies_delete_parser = movies_commands_subparsers.add_parser('delete')
55+
56+
shows_parser = media_types_subparsers.add_parser('shows')
57+
shows_parser.set_defaults(func=_do_media_shows)
58+
59+
shows_commands_subparsers = shows_parser.add_subparsers(title='Commands', dest='command')
60+
61+
shows_list_parser = shows_commands_subparsers.add_parser('list')
62+
63+
@with_argparser(media_parser)
64+
def do_media(self, args):
65+
"""Media management command demonstrates multiple layers of subcommands being handled by AutoCompleter"""
66+
func = getattr(args, 'func', None)
67+
if func is not None:
68+
# Call whatever subcommand function was selected
69+
func(self, args)
70+
else:
71+
# No subcommand was provided, so call help
72+
self.do_help('media')
73+
74+
75+
@pytest.fixture
76+
def ps_app():
77+
c = PyscriptExample()
78+
c.stdout = StdOut()
79+
80+
return c
81+
82+
83+
@pytest.mark.parametrize('command, pyscript_file', [
84+
('help', 'help.py'),
85+
('help media', 'help_media.py'),
86+
])
87+
def test_pyscript_help(ps_app, capsys, request, command, pyscript_file):
88+
test_dir = os.path.dirname(request.module.__file__)
89+
python_script = os.path.join(test_dir, 'pyscript', pyscript_file)
90+
expected = run_cmd(ps_app, command)
91+
92+
assert len(expected) > 0
93+
assert len(expected[0]) > 0
94+
out = run_cmd(ps_app, 'pyscript {}'.format(python_script))
95+
assert len(out) > 0
96+
assert out == expected
97+
98+
99+
@pytest.mark.parametrize('command, pyscript_file', [
100+
('media movies list', 'media_movies_list1.py'),
101+
('media movies list', 'media_movies_list2.py'),
102+
('media movies list', 'media_movies_list3.py'),
103+
])
104+
def test_pyscript_out(ps_app, capsys, request, command, pyscript_file):
105+
test_dir = os.path.dirname(request.module.__file__)
106+
python_script = os.path.join(test_dir, 'pyscript', pyscript_file)
107+
run_cmd(ps_app, command)
108+
expected, _ = capsys.readouterr()
109+
110+
assert len(expected) > 0
111+
run_cmd(ps_app, 'pyscript {}'.format(python_script))
112+
out, _ = capsys.readouterr()
113+
assert len(out) > 0
114+
assert out == expected
115+

0 commit comments

Comments
 (0)