Skip to content

Commit f9ea58e

Browse files
authored
Merge pull request #670 from python-cmd2/macro_refactor
Macro refactor
2 parents 46f0aed + db1f35f commit f9ea58e

File tree

5 files changed

+160
-103
lines changed

5 files changed

+160
-103
lines changed

CHANGELOG.md

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
## 0.9.13 (TBD, 2019)
22
* Bug Fixes
33
* Fixed issue where the wrong terminator was being appended by `Statement.expanded_command_line()`
4+
* Fixed issue where aliases and macros could not contain terminator characters in their values
5+
* History now shows what was typed for macros and not the resolved value by default. This is consistent with
6+
the behavior of aliases. Use the `expanded` or `verbose` arguments to `history` to see the resolved value for
7+
the macro.
48
* Enhancements
59
* `pyscript` limits a command's stdout capture to the same period that redirection does.
610
Therefore output from a command's postparsing and finalization hooks isn't saved in the StdSim object.
@@ -11,10 +15,13 @@
1115
scroll the actual error message off the screen.
1216
* Exceptions occurring in tab completion functions are now printed to stderr before returning control back to
1317
readline. This makes debugging a lot easier since readline suppresses these exceptions.
18+
* Potentially breaking changes
19+
* Replaced `unquote_redirection_tokens()` with `unquote_specific_tokens()`. This was to support the fix
20+
that allows terminators in alias and macro values.
1421
* **Python 3.4 EOL notice**
1522
* Python 3.4 reached its [end of life](https://www.python.org/dev/peps/pep-0429/) on March 18, 2019
1623
* This is the last release of `cmd2` which will support Python 3.4
17-
24+
1825
## 0.9.12 (April 22, 2019)
1926
* Bug Fixes
2027
* Fixed a bug in how redirection and piping worked inside ``py`` or ``pyscript`` commands

cmd2/cmd2.py

Lines changed: 113 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -1703,7 +1703,7 @@ def onecmd_plus_hooks(self, line: str, pyscript_bridge_call: bool = False) -> bo
17031703

17041704
stop = False
17051705
try:
1706-
statement = self._complete_statement(line)
1706+
statement = self._input_line_to_statement(line)
17071707
except EmptyStatement:
17081708
return self._run_cmdfinalization_hooks(stop, None)
17091709
except ValueError as ex:
@@ -1867,6 +1867,9 @@ def _complete_statement(self, line: str) -> Statement:
18671867
self.pseudo_raw_input(). It returns a literal 'eof' if the input
18681868
pipe runs out. We can't refactor it because we need to retain
18691869
backwards compatibility with the standard library version of cmd.
1870+
1871+
:param line: the line being parsed
1872+
:return: the completed Statement
18701873
"""
18711874
while True:
18721875
try:
@@ -1914,6 +1917,91 @@ def _complete_statement(self, line: str) -> Statement:
19141917
raise EmptyStatement()
19151918
return statement
19161919

1920+
def _input_line_to_statement(self, line: str) -> Statement:
1921+
"""
1922+
Parse the user's input line and convert it to a Statement, ensuring that all macros are also resolved
1923+
1924+
:param line: the line being parsed
1925+
:return: parsed command line as a Statement
1926+
"""
1927+
used_macros = []
1928+
orig_line = line
1929+
1930+
# Continue until all macros are resolved
1931+
while True:
1932+
# Make sure all input has been read and convert it to a Statement
1933+
statement = self._complete_statement(line)
1934+
1935+
# Check if this command matches a macro and wasn't already processed to avoid an infinite loop
1936+
if statement.command in self.macros.keys() and statement.command not in used_macros:
1937+
used_macros.append(statement.command)
1938+
line = self._resolve_macro(statement)
1939+
if line is None:
1940+
raise EmptyStatement()
1941+
else:
1942+
break
1943+
1944+
# This will be true when a macro was used
1945+
if orig_line != statement.raw:
1946+
# Build a Statement that contains the resolved macro line
1947+
# but the originally typed line for its raw member.
1948+
statement = Statement(statement.args,
1949+
raw=orig_line,
1950+
command=statement.command,
1951+
arg_list=statement.arg_list,
1952+
multiline_command=statement.multiline_command,
1953+
terminator=statement.terminator,
1954+
suffix=statement.suffix,
1955+
pipe_to=statement.pipe_to,
1956+
output=statement.output,
1957+
output_to=statement.output_to,
1958+
)
1959+
return statement
1960+
1961+
def _resolve_macro(self, statement: Statement) -> Optional[str]:
1962+
"""
1963+
Resolve a macro and return the resulting string
1964+
1965+
:param statement: the parsed statement from the command line
1966+
:return: the resolved macro or None on error
1967+
"""
1968+
from itertools import islice
1969+
1970+
if statement.command not in self.macros.keys():
1971+
raise KeyError('{} is not a macro'.format(statement.command))
1972+
1973+
macro = self.macros[statement.command]
1974+
1975+
# Make sure enough arguments were passed in
1976+
if len(statement.arg_list) < macro.minimum_arg_count:
1977+
self.perror("The macro '{}' expects at least {} argument(s)".format(statement.command,
1978+
macro.minimum_arg_count),
1979+
traceback_war=False)
1980+
return None
1981+
1982+
# Resolve the arguments in reverse and read their values from statement.argv since those
1983+
# are unquoted. Macro args should have been quoted when the macro was created.
1984+
resolved = macro.value
1985+
reverse_arg_list = sorted(macro.arg_list, key=lambda ma: ma.start_index, reverse=True)
1986+
1987+
for arg in reverse_arg_list:
1988+
if arg.is_escaped:
1989+
to_replace = '{{' + arg.number_str + '}}'
1990+
replacement = '{' + arg.number_str + '}'
1991+
else:
1992+
to_replace = '{' + arg.number_str + '}'
1993+
replacement = statement.argv[int(arg.number_str)]
1994+
1995+
parts = resolved.rsplit(to_replace, maxsplit=1)
1996+
resolved = parts[0] + replacement + parts[1]
1997+
1998+
# Append extra arguments and use statement.arg_list since these arguments need their quotes preserved
1999+
for arg in islice(statement.arg_list, macro.minimum_arg_count, None):
2000+
resolved += ' ' + arg
2001+
2002+
# Restore any terminator, suffix, redirection, etc.
2003+
return resolved + statement.post_command
2004+
19172005
def _redirect_output(self, statement: Statement) -> Tuple[bool, utils.RedirectionSavedState]:
19182006
"""Handles output redirection for >, >>, and |.
19192007
@@ -2060,73 +2148,25 @@ def onecmd(self, statement: Union[Statement, str]) -> bool:
20602148
"""
20612149
# For backwards compatibility with cmd, allow a str to be passed in
20622150
if not isinstance(statement, Statement):
2063-
statement = self._complete_statement(statement)
2151+
statement = self._input_line_to_statement(statement)
20642152

2065-
# Check if this is a macro
2066-
if statement.command in self.macros:
2067-
stop = self._run_macro(statement)
2068-
else:
2069-
func = self.cmd_func(statement.command)
2070-
if func:
2071-
# Check to see if this command should be stored in history
2072-
if statement.command not in self.exclude_from_history \
2073-
and statement.command not in self.disabled_commands:
2074-
self.history.append(statement)
2153+
func = self.cmd_func(statement.command)
2154+
if func:
2155+
# Check to see if this command should be stored in history
2156+
if statement.command not in self.exclude_from_history \
2157+
and statement.command not in self.disabled_commands:
2158+
self.history.append(statement)
20752159

2076-
stop = func(statement)
2160+
stop = func(statement)
20772161

2078-
else:
2079-
stop = self.default(statement)
2162+
else:
2163+
stop = self.default(statement)
20802164

20812165
if stop is None:
20822166
stop = False
20832167

20842168
return stop
20852169

2086-
def _run_macro(self, statement: Statement) -> bool:
2087-
"""
2088-
Resolve a macro and run the resulting string
2089-
2090-
:param statement: the parsed statement from the command line
2091-
:return: a flag indicating whether the interpretation of commands should stop
2092-
"""
2093-
from itertools import islice
2094-
2095-
if statement.command not in self.macros.keys():
2096-
raise KeyError('{} is not a macro'.format(statement.command))
2097-
2098-
macro = self.macros[statement.command]
2099-
2100-
# Make sure enough arguments were passed in
2101-
if len(statement.arg_list) < macro.minimum_arg_count:
2102-
self.perror("The macro '{}' expects at least {} argument(s)".format(statement.command,
2103-
macro.minimum_arg_count),
2104-
traceback_war=False)
2105-
return False
2106-
2107-
# Resolve the arguments in reverse and read their values from statement.argv since those
2108-
# are unquoted. Macro args should have been quoted when the macro was created.
2109-
resolved = macro.value
2110-
reverse_arg_list = sorted(macro.arg_list, key=lambda ma: ma.start_index, reverse=True)
2111-
2112-
for arg in reverse_arg_list:
2113-
if arg.is_escaped:
2114-
to_replace = '{{' + arg.number_str + '}}'
2115-
replacement = '{' + arg.number_str + '}'
2116-
else:
2117-
to_replace = '{' + arg.number_str + '}'
2118-
replacement = statement.argv[int(arg.number_str)]
2119-
2120-
parts = resolved.rsplit(to_replace, maxsplit=1)
2121-
resolved = parts[0] + replacement + parts[1]
2122-
2123-
# Append extra arguments and use statement.arg_list since these arguments need their quotes preserved
2124-
for arg in islice(statement.arg_list, macro.minimum_arg_count, None):
2125-
resolved += ' ' + arg
2126-
2127-
# Run the resolved command
2128-
return self.onecmd_plus_hooks(resolved)
2129-
21302170
def default(self, statement: Statement) -> Optional[bool]:
21312171
"""Executed when the command given isn't a recognized command implemented by a do_* method.
21322172
@@ -2286,7 +2326,10 @@ def alias_create(self, args: argparse.Namespace) -> None:
22862326
self.perror("Alias cannot have the same name as a macro", traceback_war=False)
22872327
return
22882328

2289-
utils.unquote_redirection_tokens(args.command_args)
2329+
# Unquote redirection and terminator tokens
2330+
tokens_to_unquote = constants.REDIRECTION_TOKENS
2331+
tokens_to_unquote.extend(self.statement_parser.terminators)
2332+
utils.unquote_specific_tokens(args.command_args, tokens_to_unquote)
22902333

22912334
# Build the alias value string
22922335
value = args.command
@@ -2342,8 +2385,8 @@ def alias_list(self, args: argparse.Namespace) -> None:
23422385
alias_create_description = "Create or overwrite an alias"
23432386

23442387
alias_create_epilog = ("Notes:\n"
2345-
" If you want to use redirection or pipes in the alias, then quote them to\n"
2346-
" prevent the 'alias create' command from being redirected.\n"
2388+
" If you want to use redirection, pipes, or terminators like ';' in the value\n"
2389+
" of the alias, then quote them.\n"
23472390
"\n"
23482391
" Since aliases are resolved during parsing, tab completion will function as it\n"
23492392
" would for the actual command the alias resolves to.\n"
@@ -2418,7 +2461,10 @@ def macro_create(self, args: argparse.Namespace) -> None:
24182461
self.perror("Macro cannot have the same name as an alias", traceback_war=False)
24192462
return
24202463

2421-
utils.unquote_redirection_tokens(args.command_args)
2464+
# Unquote redirection and terminator tokens
2465+
tokens_to_unquote = constants.REDIRECTION_TOKENS
2466+
tokens_to_unquote.extend(self.statement_parser.terminators)
2467+
utils.unquote_specific_tokens(args.command_args, tokens_to_unquote)
24222468

24232469
# Build the macro value string
24242470
value = args.command
@@ -2546,16 +2592,13 @@ def macro_list(self, args: argparse.Namespace) -> None:
25462592
"\n"
25472593
" macro create backup !cp \"{1}\" \"{1}.orig\"\n"
25482594
"\n"
2549-
" Be careful! Since macros can resolve into commands, aliases, and macros,\n"
2550-
" it is possible to create a macro that results in infinite recursion.\n"
2551-
"\n"
2552-
" If you want to use redirection or pipes in the macro, then quote them as in\n"
2553-
" this example to prevent the 'macro create' command from being redirected.\n"
2595+
" If you want to use redirection, pipes, or terminators like ';' in the value\n"
2596+
" of the macro, then quote them.\n"
25542597
"\n"
25552598
" macro create show_results print_results -type {1} \"|\" less\n"
25562599
"\n"
2557-
" Because macros do not resolve until after parsing (hitting Enter), tab\n"
2558-
" completion will only complete paths.")
2600+
" Because macros do not resolve until after hitting Enter, tab completion\n"
2601+
" will only complete paths while entering a macro.")
25592602

25602603
macro_create_parser = macro_subparsers.add_parser('create', help=macro_create_help,
25612604
description=macro_create_description,

cmd2/parsing.py

Lines changed: 27 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -198,9 +198,9 @@ def command_and_args(self) -> str:
198198
return rtn
199199

200200
@property
201-
def expanded_command_line(self) -> str:
202-
"""Contains command_and_args plus any ending terminator, suffix, and redirection chars"""
203-
rtn = self.command_and_args
201+
def post_command(self) -> str:
202+
"""A string containing any ending terminator, suffix, and redirection chars"""
203+
rtn = ''
204204
if self.terminator:
205205
rtn += self.terminator
206206

@@ -217,6 +217,11 @@ def expanded_command_line(self) -> str:
217217

218218
return rtn
219219

220+
@property
221+
def expanded_command_line(self) -> str:
222+
"""Combines command_and_args and post_command"""
223+
return self.command_and_args + self.post_command
224+
220225
@property
221226
def argv(self) -> List[str]:
222227
"""a list of arguments a la sys.argv.
@@ -618,26 +623,27 @@ def get_command_arg_list(self, command_name: str, to_parse: Union[Statement, str
618623
return to_parse, to_parse.argv[1:]
619624

620625
def _expand(self, line: str) -> str:
621-
"""Expand shortcuts and aliases"""
626+
"""Expand aliases and shortcuts"""
627+
628+
# Make a copy of aliases so we can keep track of what aliases have been resolved to avoid an infinite loop
629+
remaining_aliases = list(self.aliases.keys())
630+
keep_expanding = bool(remaining_aliases)
622631

623-
# expand aliases
624-
# make a copy of aliases so we can edit it
625-
tmp_aliases = list(self.aliases.keys())
626-
keep_expanding = bool(tmp_aliases)
627632
while keep_expanding:
628-
for cur_alias in tmp_aliases:
629-
keep_expanding = False
630-
# apply our regex to line
631-
match = self._command_pattern.search(line)
632-
if match:
633-
# we got a match, extract the command
634-
command = match.group(1)
635-
if command and command == cur_alias:
636-
# rebuild line with the expanded alias
637-
line = self.aliases[cur_alias] + match.group(2) + line[match.end(2):]
638-
tmp_aliases.remove(cur_alias)
639-
keep_expanding = bool(tmp_aliases)
640-
break
633+
keep_expanding = False
634+
635+
# apply our regex to line
636+
match = self._command_pattern.search(line)
637+
if match:
638+
# we got a match, extract the command
639+
command = match.group(1)
640+
641+
# Check if this command matches an alias that wasn't already processed
642+
if command in remaining_aliases:
643+
# rebuild line with the expanded alias
644+
line = self.aliases[command] + match.group(2) + line[match.end(2):]
645+
remaining_aliases.remove(command)
646+
keep_expanding = bool(remaining_aliases)
641647

642648
# expand shortcuts
643649
for (shortcut, expansion) in self.shortcuts:

cmd2/utils.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -262,15 +262,16 @@ def natural_sort(list_to_sort: Iterable[str]) -> List[str]:
262262
return sorted(list_to_sort, key=natural_keys)
263263

264264

265-
def unquote_redirection_tokens(args: List[str]) -> None:
265+
def unquote_specific_tokens(args: List[str], tokens_to_unquote: List[str]) -> None:
266266
"""
267-
Unquote redirection tokens in a list of command-line arguments
268-
This is used when redirection tokens have to be passed to another command
267+
Unquote a specific tokens in a list of command-line arguments
268+
This is used when certain tokens have to be passed to another command
269269
:param args: the command line args
270+
:param tokens_to_unquote: the tokens, which if present in args, to unquote
270271
"""
271272
for i, arg in enumerate(args):
272273
unquoted_arg = strip_quotes(arg)
273-
if unquoted_arg in constants.REDIRECTION_TOKENS:
274+
if unquoted_arg in tokens_to_unquote:
274275
args[i] = unquoted_arg
275276

276277

0 commit comments

Comments
 (0)